GodspeedOS

Small enough to understand. Rigorous enough to trust.

GodspeedOS is a capability-based microkernel operating system written in Rust. Every privileged action requires an explicit, unforgeable capability. Services are isolated in their own address spaces. Failures are loud, never silent. Authority is never inherited or ambient - only granted.

The kernel is deliberately tiny: memory isolation, scheduling, IPC routing, capability enforcement, interrupt routing, and multi-core coordination. Nothing else. Everything above it - drivers, the filesystem, the shell - is a restartable userspace service. The only unkillable component is the kernel itself.

Where to start

This site is a rendered view of the project's own documents. Ninety-one of its ninety-six pages - including every one linked below - are a two-line stub that pulls the real file straight from the repository, so they cannot drift from the source (that is Commandment III applied to the docs themselves). The five exceptions are written for the site because they have no file to be a view of: this page, the Gallery, the Services tour, the Utilities index and Writing a program for GodspeedOS. Those five are checked against the repository on every build instead - that a utility has a row, that a service's peers match its contract - because prose someone wrote is prose that can go stale.

  • The Ten Commandments - the human-readable distillation of the whole design. Ten laws that bound every decision. Start here.
  • The Constitution - the full specification: the invariants, the capability model, IPC, the scheduler, the memory model, the unsafe policy, and the contribution rules. When the constitution and the code disagree, the constitution wins.
  • The Glossary - the abbreviations (TLB, DMA, IOMMU, AHCI, BSP, IPI, and the rest).
  • The Almanac - the project's logbook: the days something was learned, in the words of the person who learned it. The bugs, the wedges, and the lessons that became commandments.

How it works, in one breath

A capability is a token carrying a resource id, a rights set, and a generation number. Holding one is the necessary and sufficient authority for the actions it permits. Kill a service and its endpoint generation is bumped; every outstanding capability to it goes stale at once, and the next use returns EndpointDead. Clients look the service back up by name through the kernel's name directory, reacquire a fresh capability, and resume - possibly against a new instance on a different core, which is invisible to them. Identity is stable; location is not.

The Design Notes trace how that model plays out in real subsystems - the filesystem, the block driver, DMA confinement, naming, the scripting language, the network stack - and the Forward-Looking note sketches where it goes next: across a cluster.

Seeing it run

The Gallery shows GodspeedOS actually booting and running - captured from the real system, not mocked up.

CLAUDE.md

Capability-Based Microkernel OS

Status: v3.7 - SMP-Integrated Spec, Reviewed, with Forward-Looking Appendices Scope: v1 milestone (multi-core kernel, two services, cross-core IPC, supervisor restart with core reassignment) Audience: Contributors, reviewers, future maintainers


Table of Contents

  1. Purpose of This Document
  2. Project Identity
  3. Constitution: Non-Negotiable Invariants
  4. Architecture Overview
  5. Repository Structure
  6. Trusted Computing Base
  7. Capability System
  8. IPC
  9. Scheduler and SMP
  10. Memory Model
  11. Bootstrap Sequence
  12. Drivers and Interrupts
  13. Service Contracts
  14. Service Lifecycle
  15. State and Persistence
  16. Update Model
  17. Developer Workflow
  18. Unsafe Policy
  19. Debugging Model
  20. Performance Philosophy
  21. Contribution Rules
  22. Identity Test Suite
  23. First Milestone
  24. Glossary
  25. Final Principles
  26. Architectural Discipline

Appendices (forward-looking, mostly non-normative):


1. Purpose of This Document

This document is the constitution of the project. It defines the enforceable laws and concrete decisions of the OS.

It is not aspirational. It is a commitment to specific trade-offs.

It exists to:

  • Ensure the system remains small, correct, and enforceable.
  • Prevent contributors from introducing hidden complexity.
  • Make every architectural decision traceable to a stated rationale.
  • Resolve future arguments by appeal to written law rather than memory.

When this document and the code disagree, the document wins and the code is wrong. When this document and reality disagree, the document is amended and the change is recorded.

How to read this document. Each section's present-tense body is the current, canonical law - read it as settled fact. The inline Amendment YYYY-MM-DD blocks record how a rule reached its present form and why; they are ratified history, not open proposals or drafts. Where an amendment and the body it sits under read differently, the most recent amendment is the canonical state and the body is being brought up to match. You never need the git history to know the current law: it is the section body, as amended. Sections whose state changed the most over time (notably §6, the TCB) open with a short "current canonical state" box so the settled answer is available before the history that produced it.

The appendices at the end of this document collect forward-looking design notes. Appendix A documents a v1 commitment (the bootloader choice). Appendices B, C, and D are explicitly non-normative - they record design intent and discussion, not commitments. Their content does not amend the constitution.

This constitution has a human-readable distillation: COMMANDMENTS.md, the Ten Commandments of Godspeed. Each commandment is grounded in the invariants and sections it enforces here. Where a commandment and this document disagree, this document is the law and the commandment is amended to match (it is the distillation, not the source).


2. Project Identity

2.1 What This Project Is

A deliberately small, fully-understood capability microkernel, built to internalize the design discipline of capability-based systems by writing one.

2.2 What This Project Is Not

  • A novel research system.
  • A POSIX-compatible OS.
  • A production-grade kernel.
  • A general-purpose application platform.
  • A competitor to seL4, Fuchsia, Hubris, Redox, or Genode.

2.3 Goals, Ranked

  1. Clarity over novelty. Every part of the system should be explainable in a paragraph.
  2. Correctness over cleverness. A boring, verifiable design beats a clever, fragile one.
  3. Execution over theory. Shipping a working ping/pong beats refining a perfect spec.

2.4 v1 Architectural Principles

  • Isolation over performance.
  • Explicit transitions over implicit mutation.
  • Identity over location.
  • Simplicity over cleverness.
  • Loud failures over silent fallbacks.

2.5 v1 Permanent Decisions

CapabilityStatusRationale
SMPSupported (static)Cross-core parallelism; no migration
Zero-copy IPCRejected, permanentViolates "no shared mutable memory"
Live code updatesRejected, permanentRestart-with-reacquisition is sufficient

Amendment 2026-10-17 (live handover): a running service may be REPLACED without stopping, by a second program - code is still never changed inside a running one. The operator asked for Erlang's upgrade-in-place. What this row rejects stays rejected: no new code is ever loaded into a running address space, so W^X holds and the bytes that run are always the bytes the supervisor checked from the disk (14.1). What is now allowed is the observable half of Erlang's feature, made out of mechanisms the system already has: the new version is a separate, isolated program, started BESIDE the running one under the same name; the running one hands it its state over IPC and ends itself (gs::handover, docs/developer-services-design.md 14). What Erlang calls code change is the successor reading the old version's state - written by the package, explicitly.

What the kernel learns: one spawn flag, SPAWN_FLAG_HANDOVER. SpawnImage (which only the supervisor can call, 14.1) may start a task beside the ONE task already running under its name; the name directory re-points to the new endpoint, and the old task's death leaves the name alone (the endpoint-id guard that already handles a respawn racing a death). No new syscall, no shared memory, no capability passed between the two except the successor's endpoint, which the supervisor sends with the offer. A name never has more than two instances. Not a seventh responsibility: it is spawning (4.3), with one refusal relaxed for one caller.

What is opt-in, and what is not promised. A package declares handover = true; without it, an upgrade stops the old version and starts the new as before. Capabilities do not travel (the successor holds its own spawn request's), a request sent to the old version after it drained its queue is lost with it - its caller's gs::call reports OutcomeUnknown, exactly as for a death - and the state is one message. Services only; an app's handover (its screen moving with its state) is not built.

2.6 Reference Systems

SystemWhat we borrow
seL4Capability model, synchronous IPC discipline
HubrisDeclarative task contracts, supervisor-driven restart
FuchsiaUserspace drivers, capability handles in IPC messages
GenodeComposition through capability delegation
RedoxRust microkernel patterns

3. Constitution: Non-Negotiable Invariants

These are the laws that bound every design choice. Any change that violates an invariant must first amend the invariant - and amendments require a written rationale.

  1. No ambient authority. Every privileged action requires an explicit capability.

    Amendment 2026-06-12 (H1): DMA-capable devices were an unstated exception to this invariant - a driver could direct its controller's DMA engine at any physical address, kernel-equivalent reach the capability model never granted. H1 (IOMMU confinement) closes the gap: a confined device reaches only its granted arena. See §6.4.

  2. No shared mutable memory by default. Services have isolated address spaces.
  3. All authority is explicit. Capabilities, not identity.
  4. Kernel remains tiny. Memory, scheduling, IPC, capabilities, interrupts, cross-core routing. Nothing else.
  5. Unsafe is isolated and audited. Permitted only in arch/, memory/, capability/, smp/.
  6. Services must be restartable (with stated TCB exceptions).
  7. Contracts are enforced, not interpreted.
  8. State must be explicit and owned. No anonymous singletons.
  9. No unowned global mutable state. Immutable globals are fine.
  10. System must remain understandable. 30-minute whiteboard rule.
  11. Identity is stable; location is not. Services have stable names; their core assignment may change across restarts.
  12. Failures are loud, never silent. No silent fallbacks at the kernel boundary.

4. Architecture Overview

4.1 Layered View

  ┌──────────────────────────────────────────────────┐
  │  Application Services  (replaceable)             │
  ├──────────────────────────────────────────────────┤
  │  System Services                                 │
  │  events  ·  block-driver  ·  fs                  │
  ├──────────────────────────────────────────────────┤
  │  Trusted Root  (trusted, restartable)            │
  │  supervisor   (init removed; registry retired)   │
  ├──────────────────────────────────────────────────┤
  │  Kernel  (mechanism, not policy)                 │
  │  memory · scheduler · ipc · capability           │
  │  syscall · interrupts · smp/routing              │
  ├──────────────────────────────────────────────────┤
  │  Architecture Layer  (unsafe boundary)           │
  │  arch/<isa>  - one directory per ISA, and the    │
  │  ONLY place that knows which machine this is     │
  ├──────────────────────────────────────────────────┤
  │  Hardware  (multi-core)                          │
  └──────────────────────────────────────────────────┘

Amendment 2026-09-12 (multi-architecture): this document described a single-ISA system, and had stopped being true some months ago. It named arch/x86_64 in the layered view above, in the repository map (§5) and in the TCB table (§6.1), and mentioned RISC-V nowhere at all. There are now four complete ports, all verified on real hardware - x86-64 (HP T630, Dell Wyse), ARMv7 (Raspberry Pi 2), AArch64 (Raspberry Pi 4) and RISC-V 64 (StarFive VisionFive 2 Lite) - plus three stubs that boot and print (riscv32, loongarch64, s390x). The gap predates the RISC-V work; the constitution was not amended for the ARM ports either.

What this amendment does NOT change is the point of it. No invariant moves, no kernel responsibility is added, and the TCB does not grow: arch/<isa> was always the unsafe boundary (invariant 5, §18.1), and only the directory built for the machine in front of you is compiled into it. What changed is that the noun is now plural.

The property that makes this affordable is worth stating as law, because it is what is actually being defended. A port is bounded to arch/<isa>/: you write that directory and nothing else in the kernel changes. Architecture-neutral code reaches hardware only through the arch::imp seam and must never name an ISA - enforced mechanically by scripts/arch_boundary_check.py (neutral layers reach hardware only through the seam) and scripts/arch_seam_check.py (every arch answers every member of it, discovered from usage rather than a hand-kept list). The RISC-V port was the first completed with no neutral kernel CODE naming the ISA - two comments in memory/allocator.rs and task/scheduler.rs cite it as explanation, which is the honest extent of the claim and the standard the others are being brought to. For comparison, neutral code still names arm in 8 places, aarch64 in 4 and x86_64 in 3.

The rule the operator set for that work, recorded here because it is the bar: an ISA port is not complete when it boots. It is complete when architecture-neutral code no longer knows that the ISA was added. Where neutral code still enumerates ISAs by name - the supervisor's #[cfg(any(...))] spawn arms, the SDK's hwclass list - that is acknowledged debt, not the model.

Amendment 2026-09-13 (portability-hardening): the debt named above is MEASURED now, not counted by hand, and both of its examples are out of date. The amendment above states the bar correctly and then points at two things as the standing debt. One is gone and the other was never an example of it:

  • The supervisor's #[cfg(any(...))] spawn arms are gone. That file carried 49 arch-conditional sites when this branch began; it carries 4. (47 when the spawn-table work itself started - two had already gone in the commit that named the board facts. The 49 is the figure milestones/ uses, so this says the same thing they do.) The USB host table was FIVE tables, one per arch plus an empty catch-all, and is now one table whose rows are present exactly where their image is; the seven-times-repeated any(x86_64, aarch64, riscv64) was one question - is configuration space reachable - and is now one build.rs fact. Three service crates went the same way, and where the ISA still answers, it is asked ONCE per fact in a build script rather than at every site that needs it.
  • The SDK's hwclass list is not an ISA enumeration and never was. It names DEVICE kinds - NIC, XHCI, EHCI, DWC2, FRAMEBUFFER - plus PCI class-code addressing. That is step D's answer to this problem, not an instance of it: a driver names a device class and the kernel resolves it, which is exactly what stops a new board needing a new name here.

And the count itself was the wrong instrument. "Neutral code still names arm in 8 places, aarch64 in 4 and x86_64 in 3" was true when written and had drifted to 6, 4 and 2 before anybody re-measured, because a hand count is right on the day it is taken and silently wrong afterwards. Worse, nothing was watching: arch_boundary_check.py forbids inline assembly and named arch modules outside arch/, and a #[cfg(target_arch = "arm")] in kernel/src/task/mod.rs is neither, so the debt this paragraph names could grow without failing anything. Userspace had a ratchet (shared_surface_check.py); the neutral kernel had none.

It does now - the same one, which is the honest shape since it is one property asked of two layers. The standing figure is 54 arch-conditional sites outside arch/: 2 in the neutral kernel, 52 above it (46 and 44 when this was written; the rise is board facts in services/supervisor/build.rs and services/wifi-driver/build.rs - the Pi 4's radio (+1), the audio drivers (+4) and, on 2026-10-04, the VisionFive's radio and nic-driver's radio bridge as its own fact (+4) - blessed together that day; and on 2026-10-05 one fewer, when the VisionFive gained the bridge too and the bridge fact became the radio fact by derivation rather than a second test of the instruction set (-1); docs/porting.md has the per-file tree). It may fall freely and may not rise without a recorded reason. What is left is listed rather than implied. The neutral kernel is down to 2, and both are target_pointer_width on one constant - a 32-bit address space genuinely cannot hold a 4 GiB virtual address, so the width IS the question rather than an ISA standing in for one. Above the kernel, nic-driver picks its MAC by ISA on three of four boards and needs a kernel query to stop (backlog/21), hw-enumerator packs a host-bridge config selector that belongs in arch/ (backlog/25), and the rest are the SDK's syscall seam, which §18.1 designates and which is the one place the ISA is genuinely the question.

4.2 SMP View (Per-Core)

  Kernel (shared, concurrent)
  ┌────────────────────────────────────────────────┐
  │  Routing Table: EndpointId → CoreId            │
  │  Capability Table                              │
  └─────────────┬──────────────┬───────────────┬───┘
            IPI │          IPI │           IPI │
  ┌─────────────▼──┐  ┌────────▼───┐  ┌────────▼───┐
  │    Core 0      │  │   Core 1   │  │   Core 2   │
  │   Run Queue    │  │  Run Queue │  │  Run Queue │
  │   Services     │  │  Services  │  │  Services  │
  └────────────────┘  └────────────┘  └────────────┘
    ▲ syscall             ▲ syscall       ▲ syscall

4.3 Kernel Scope (Strict) - MISCIS

The six, and the mnemonic for them - MISCIS, said like "misses": Memory isolation, IPC, Scheduling, Capabilities, Interrupts, SMP routing.

  • Memory isolation (per-service address spaces, page tables)
  • IPC (synchronous message passing, bounded queues, cross-core routing)
  • Scheduling (per-core run queues, round-robin with timer preemption)
  • Capability enforcement (validation on every privileged syscall, generation check)
  • Interrupt routing (delivery to userspace driver services)
  • SMP routing (EndpointId → CoreId map, IPI wakeup)

Leave these six bullets as plain text. scripts/commandments.py parses each line up to the ( as the responsibility's NAME, so bolding the mnemonic letters inside them - **M**emory isolation - turned every name into something it did not recognise and failed six modules at once. Caught in seconds by the gate that reads this list, which is the argument for deriving it from here rather than copying it into config.

"Do not expand MISCIS" is therefore the short form of §4.4: a seventh responsibility is a change to what this kernel IS, not a feature. scripts/commandments.py (I-responsibilities) derives the six from this very list and fails on a module that serves something outside it, so the acronym and the gate read the same source.

The list is ordered to spell the word. It previously ran memory / scheduling / IPC / ... which spells MSICIS, and the order carries no meaning - the checker builds a SET from these bullets and does not care. A mnemonic that does not match the list it names is a small trap, so the list moved rather than the mnemonic.

4.4 Kernel Anti-Scope

The kernel does not contain filesystem logic, network stack, drivers (beyond minimal arch boot), logging infrastructure, application logic, developer tooling, work-stealing scheduler, service migration, or load balancing.

Amendment 2026-06-18 (P2, file-as-capability): delegated resource capabilities do not add file logic to the kernel. The kernel gains the ability to mint, route, and revoke capabilities for resources whose meaning is defined by a service (§7.10). It still contains no filesystem logic: it tracks an opaque ResourceId and its owning endpoint and nothing more; the owning service (fs) alone maps ResourceId → file. "A file is a capability" thus becomes literally true while this anti-scope holds - the kernel never learns what a file is.


5. Repository Structure

os/
  CLAUDE.md
  README.md
  Cargo.toml

  kernel/
    src/
      main.rs
      arch/                # ONE DIRECTORY PER ISA; `arch::imp` aliases the one being built
        x86_64/            #   complete - boot.rs, interrupts.rs, context_switch.rs, page_tables.rs, ap_boot.rs
        arm/               #   complete - ARMv7-A, Raspberry Pi 2 (hardware-verified)
        aarch64/           #   complete - Raspberry Pi 4 (hardware-verified)
        riscv64/           #   complete - QEMU virt + StarFive VisionFive 2 Lite (hardware-verified)
        riscv32/  loongarch64/  s390x/    #   stubs: boot + UART only
      memory/              # frame.rs, page.rs, allocator.rs, bitmap.rs
      task/                # mod.rs, state.rs, scheduler.rs (per-core)
      ipc/                 # message.rs, endpoint.rs, queue.rs, routing.rs, names.rs
      capability/          # cap.rs, table.rs, rights.rs, generation.rs, delegated.rs
      smp/                 # core.rs, ipi.rs, percpu.rs, spinlock.rs, names.rs
      syscall/dispatch.rs
      interrupt/route.rs
      invariants/assertions.rs
      log.rs               # kernel ring buffer
      bootcon/             # boot + panic framebuffer blit (11.4) - NOT a terminal; see console/

  services/
    supervisor/            # restart authority + name authority (TCB); spawned directly by the kernel
                           #   (init removed, Phase 5; registry service retired, Phase 4 - Path C §3.7)
    events/
    console/               # the terminal: ANSI/CSI, grid, cursor, scroll (docs/console-service.md 9)
    block-driver/          # restartable (Phase D)
    fs/                    # restartable (Phase D); depends on block-driver

  sdk/rust/                # service_context.rs, capability.rs, ipc.rs

  osdev/
    src/main.rs
    src/validator.rs       # JSON Schema validation

  examples/
    ping/
    pong/

  contracts/
    schema/service.schema.json

  docs/                  # narrative design notes; `docs/CLAUDE.md` is the index
    observability.md     #   the events sink: logs, traces, metrics, persistence
    service-ownership.md #   who owns a service: images, authority, discovery
    naming-design.md     #   name resolution out of the kernel (Path C)
    persistence.md       #   block driver + filesystem, file-as-capability
    console-service.md   #   the terminal, and the kernel's boot/panic floor
    ...                  #   the index lists them all

  audits/                # append-only EVIDENCE, not documentation
    unsafe-audit.md      #   every unsafe block; CI checks it matches source
    kernel-audit.md  security-audit.md  userspace-audit.md  documentation-audit.md
    2026-07-15-full-trilogy-report.md  #   a dated run of all of them at once

  tests/
    qemu/
      identity/            # identity test suite (§22)
      harness/             # shared test infrastructure
      perf/                # performance benchmarks (§22 B1-B10) - 10/10 ✅

  scripts/               # THE ENFORCEMENT LAYER, and the non-x86 build paths
    commandments.py      #   the Ten Commandments, mechanised where they can be
    commandments_redteam.py #  probes that BREAK each check, so a dead check is caught
    contract_check.py    #   every contract matches its kernel service_config
    unsafe_check.py      #   the unsafe audit matches the source
    doc_refs.py          #   no doc points at a file that does not exist
    facts_check.py       #   a number a doc restates matches the code that owns it
    site_check.py        #   the 5 hand-written site pages still match the repository
    arch_boundary_check.py #  neutral layers reach hardware ONLY through the `arch::imp` seam
    arch_seam_check.py   #   ...and every arch ANSWERS every member of it (the other direction)
    scaffold_check.py    #   ...and the BOUNDED-PORT TEST: how far a fresh ISA gets with only
                         #   `arch/<isa>/` written. The two above prove no RULE is broken; this one
                         #   exercises the claim, because a count is a proxy and a build is not
    shared_surface_check.py #  ratchets arch-conditional code ABOVE the kernel (the other axis:
                         #   `target_arch` there is usually "which BOARD am I on")
    one_way_check.py     #   ONE way to write a service or a driver: `gs`. Ratchets, per crate, the raw
                         #   SDK calls the standard library already covers - may fall, may not rise, and
                         #   a NEW crate starts at zero. Counts only methods with a `gs` replacement, which
                         #   it names; a gap in `gs` is closed in `gs` (backlog/71, 2026-10-05)
    quit_wait_check.py   #   NO SILENT WAIT in the shell, which observes every command: each wait reads
                         #   the command's watch or says on the line above why it cannot trap anyone
                         #   (`// quit-ok:`), and those exceptions may fall, not rise (backlog/fixed/89)
    port_scope_check.py  #   ...and the one the other four cannot answer: did the port EDIT anything
                         #   outside `arch/<isa>/` and the twelve files `docs/porting.md` marks `+`.
                         #   They all ask whether a RULE was broken; an ordinary edit to a neutral
                         #   kernel file breaks none of them, so five ran green over exactly that
    dash_check.py        #   no em/en dashes anywhere (§21)
    foreign_word_check.py #  no DOC shows a POSIX/DOS word being used as a command. `ls`
                         #   became `dir` and the rename reached the shell, the specs and
                         #   the help text - and missed TEN worked examples. Derived from
                         #   the shell's own `FOREIGN_HINTS`, never a hand-kept list
    line_ref_check.py    #   a `path.rs:NNN` citation still points at what it claims. A line
                         #   number rots on the next edit above it, and fifteen checkers
                         #   looked at none: Audit 7 found 7 of 11 wrong, four documents
                         #   citing ONE dead line. `audits/` is exempt - dated evidence
    doc_command_check.py #   a documented INVOCATION must work. `doc_symbols_check` asks
                         #   whether a NAME resolves and `facts_check` whether a NUMBER
                         #   matches; nothing asked whether a documented prompt runs. `dir long /` does
                         #   not - `long` was removed, so the shell lists a directory NAMED
                         #   `long` and discards the path, which is a WRONG ANSWER rather
                         #   than an error. Reads `SUBCMD_FIRST` and osdev's own `match
                         #   suite`, so it cannot drift from either
    comment_symbol_check.py # ...and the same question asked of CODE COMMENTS, which are
                         #   27,126 lines across 238 files - twice the documentation, inside
                         #   the kernel, and read FIRST because they sit beside the code.
                         #   Resolution is STRICTER than for a doc: `doc_symbols_check`
                         #   accepts a name found anywhere in the source, comments included,
                         #   which here would be circular - the comment would satisfy
                         #   itself. A name cited by a comment must exist in CODE. Most of
                         #   its baseline is permanent and correct: registers, SBI calls and
                         #   Linux functions a driver cites on purpose (§26.14)

    # WHERE THESE RUN, which until 2026-09-26 was the hole under all the others. The eight
    # DOCUMENTATION checkers above (doc_refs, docs_index_check, facts_check,
    # foreign_word_check, line_ref_check, site_check, backlog_check, doc_command_check) were
    # on NO developer build path: they ran only in `build.yml`, which is
    # `workflow_dispatch`-only and paused, and in `release.yml`, which fires on a `v*` tag.
    # Documentation was therefore checked AT RELEASE - too late by construction, and v0.20.0
    # is the worked example (its tag run failed late and the release shipped with no
    # artefacts). They are in `osdev`'s `EXTRA_CHECKS` now, so a build runs them. ~11.5 s for
    # all eight, measured. The rule this list already states about `line_ending_check.py`
    # applies to itself: a checker on one build path is a checker on none.
    service_embed_check.py #  every managed service is really embedded in the image
    embed_order_check.py #   the supervisor is newer than the services it embeds
    stack_fit_check.py   #   a service's frames fit the stack it is given (§26.6.1)
    test_report.py       #   collate a suite run into a report
    board.py             #   ONE way to build a bootable image for any port:
                         #   `py scripts/board.py pi2|pi4|visionfive|x86 [--crash-window]`.
                         #   Owns the POLICY the four ports used to state differently - a
                         #   board image is ALWAYS release, every port takes the same flags,
                         #   every build ends by naming the artifact and what to copy where
                         #   (including the per-port RENAMES). The per-port scripts below do
                         #   the work and stay callable
    arm_build.py  pi4_build.py  riscv_build.py  arm_run.py  pi4_run.py  riscv_run.py

  utilities/             # the SPEC for every shell utility, one file each, plus 0_conventions
    0_conventions.md     #   the 14 rules every utility obeys
  website/               # the published book (mdBook); pages `{{#include}}` the
                         #   sources above, so a doc and its page cannot drift
  backlog/               # open items - recorded rather than closed (§26.7)
  boot/                  # per-board boot config (pi2/, pi4/)
  contracts/schema/      # the JSON Schema a service contract is validated against
  examples/              # 15 worked services
  milestones/            # what was achieved and when; ALMANAC.md is the chronicle
  bugs/                  # long-form investigations of four specific hardware bugs
  editors/               # syntax files for the gsh shell language

6. Trusted Computing Base

Current canonical state (as amended below). The non-restartable set is {kernel} alone - the kernel is the only component that cannot be restarted; everything else recovers from death rather than forcing a reboot. Present-tense status of every former TCB member:

  • supervisor - trusted but restartable: the kernel respawns it on death (unconditionally, forever) and it reconciles with the still-running services (§6.2).
  • init - removed entirely; the kernel spawns the supervisor directly (Path C / Phase 5).
  • registry - left the TCB, then the service was retired; naming is now a minimal kernel directory (naming Phase 4 / Path C).
  • block-driver, fs - restartable storage services, made safe by fs's crash-consistent recovery (Phase D); their death is a supervisor restart, not a reboot.
  • DMA drivers (xhci/ehci on x86, dwc2 on ARM) - all userspace services; in the TCB only on a machine with no IOMMU, where unconfined DMA is kernel-equivalent reach. Where an IOMMU confines them they are least-privilege and drop out (§6.4).

The dated amendments in §6.1-§6.3 record how this floor was reached and why. They are ratified history, not proposals; this box is the settled result they add up to.

6.1 TCB Members

ComponentTrusted because
KernelEnforces all isolation
arch/<isa>Direct hardware access. The trust is per-ISA: the directory built for this machine is in the TCB, the others are not compiled
kernel/smpConcurrent-correctness primitives
supervisorHolds restart + name authority; spawned directly by the kernel (init removed, Phase 5); trusted but restartable - the kernel respawns it on death (Phase 6, §6.2), so the only non-restartable thing is the kernel itself
xhci, ehci, dwc2 (DMA drivers)Machine-dependent (H1, §6.4): in the TCB only on a machine with no IOMMU to confine them (DMA-anywhere = kernel-equivalent reach); dropped from it - least-privilege and restartable - wherever an IOMMU confines them to their arena. The case is reported loudly at boot (invariant 12).

Amendment 2026-06-12 (H1): DMA-capable drivers are no longer an unconditional TCB member. Before H1 they were an implicit, unstated member: with no IOMMU a driver directs its controller's DMA engine at any physical address - kernel-equivalent power the capability model never granted (see the invariant 1 amendment). H1 makes their trust machine-dependent: confined by an IOMMU, a compromise is bounded to the granted arena, so the driver is genuinely least-privilege and restartable and leaves the TCB; on a machine with no IOMMU it stays trust-critical by necessity. The same binary, different posture, with the difference printed at boot. §6.4 is the full treatment; §22 Test 12 pins the confined case.

Amendment 2026-06-17 (Phase D / FS robustness): block-driver and fs are no longer TCB members. They were a v1 simplification (§6.3): fs owned persistent state it could not recover after a crash, so its death - and that of the block-driver it depends on - was a panic+reboot. The filesystem robustness program closes that gap: fs now commits every metadata mutation through a crash-consistent redo-journal and recovers to a consistent state on mount (Phase C; docs/persistence.md §6.8). With recovery in hand, both services are restartable like any other: the kernel notifies the supervisor of their death, which respawns them; fs re-mounts (recovering via the journal) and re-registers, block-driver re-initialises the controller, and clients reacquire via the registry and retry (§14.3, §6.2). block-driver holds no persistent state and was already operationally restartable. Their boot-time spawn must still succeed to bootstrap persistence (§11.3); only their runtime death is now recovered, not fatal. Worked example of invariant 11 and the §6.3 goal reached. §22 Test 13 pins it (fs survives its own restart). The remaining non-restartable set is now just init + supervisor + kernel.

Amendment 2026-07-25 (ARM/USB): the Phase D recovery guarantee is BACKEND-CONDITIONAL, and the backend reports which case it is in. Phase D (above) dropped block-driver and fs from the TCB on the strength of crash-consistent recovery. Bringing up USB mass storage on the ARM32 port showed that guarantee is not a property of fs alone: a redo journal is pure ordering - staged blocks durable before the commit record that authorises replaying them, that record durable before any home block moves - and ordering can only be enforced by a device that attests durability. Two cases, and the difference is a printed fact rather than a hidden assumption (exactly the §6.4 posture):

  • A backend that attests durability keeps the guarantee intact. ahci issues FLUSH CACHE EXT when fs asks for it, at the journal barriers (amended 2026-08-22: it used to flush after every 512-byte sector, which cost a transaction a stack of full device flushes and made a single file write take tens of seconds on a SATA SSD; the barriers fs already declares - staged blocks before the commit record, commit record before any home block - are what the guarantee rests on, and a per-sector flush added nothing to them). The consequence is stated plainly: unflushed FILE DATA can be lost on a power cut, while METADATA stays crash-consistent, which is exactly what this section claims and no more. after every write; SD/EMMC completes a write only after the card releases its busy line. Ordering holds, fs recovers to a consistent state on mount, and the Phase D TCB claim stands unchanged.
  • A backend that cannot does not. The Pi 2's USB stick refuses SYNCHRONIZE CACHE outright, and FUA - which the drive does honour - costs more time per write than the driver's command budget can give it (USE_FUA, then in the in-kernel arch/arm/dwc2.rs; that file was DELETED by the 2026-08-17 amendment below and the driver is services/dwc2 now - the DEVICE's refusal is what this paragraph turns on, and it is unchanged by where the driver lives). With no barrier available, a power cut can lose the tail of a write sequence, and fs says so once per mount rather than implying a guarantee it cannot deliver. Metadata is still CRC-verified, so the failure is DETECTED loudly on read; what is lost is automatic recovery, not the ability to notice.

So the honest form of the Phase D claim is: fs is restartable everywhere; it is crash-recoverable on a backend that can be ordered. Where it cannot be, fs remains restartable (its death is still a supervisor restart, never a reboot - §6.2 is untouched) and a power loss may require a reformat. Recorded rather than closed, per §26.7: the fix is a block path that can wait out a busy device without holding the core, which is real work and not a constant.

Amendment 2026-09-23 (hardware): the amendment above is RIGHT about the rule and WRONG about its only example. The Pi 2 is not a backend that cannot be ordered - it was never tested, and when it finally was, it recovered. The 2026-07-25 amendment names the Raspberry Pi 2's USB stick as the worked case of a device that "refuses SYNCHRONIZE CACHE outright", and concludes that on that board a power loss may require a reformat. Both halves are contradicted by the machine.

The refusal. Seven sessions across three USB stacks - dwc2 on the Pi 2, xhci on the Pi 4, xhci on the VisionFive 2, one physical stick - and fs has printed no durability warning on any of them. That warning fires at both journal barriers whenever the flush comes back false, so on every run where writes committed it ran and stayed silent: the device answered SYNCHRONIZE CACHE(10) and accepted it. The only local evidence for the refusal was an instrument that could not tell a refusing device from a dead driver - it fired twice in build/pi2a.log immediately after block-driver died, restarting, blaming the stick for a flush nobody was there to answer. That is fixed (Durable / Refused / NoAnswer), and three of the seven sessions ran with the corrected instrument.

The consequence. On 2026-09-23 a power cut 17 s into churn 30, on a PLAIN image with no crash-window, at a moment nobody chose, landed inside the commit-to-checkpoint window:

fs: journal recovered 4 block(s) from an interrupted write
churn verify: 7 file(s) checked, 0 empty, NONE torn
check: 13 files, 2 dirs, 0 bad; the free count already agreed with the tree - nothing was repaired

A replay of 4 blocks means the commit record was durable BEFORE any home block moved. That ordering is the whole of the guarantee, and this device enforced it. It took three attempts, which is ordinary variance.

Corrected the same day, in this amendment rather than around it: the paragraph above first read "at the ~2% per-cut odds the window's sub-millisecond life implies". That figure was wrong. It took fs's own "normally lasts under a millisecond" comment - written about the Dell Wyse, an AHCI SSD - and applied it to a USB stick. The window IS the checkpoint, the interval between the commit record becoming durable and the last home block landing, and those writes are slow on a stick, so there it is a large fraction of each transaction rather than a sliver of it. Measured across every unassisted cut: six hits in twelve (T630 two of two, Pi 2 third of three, Pi 4 first, VisionFive first, Wyse one of five). At 2% that outcome is about 1 in 10^8, so the estimate is refuted rather than imprecise. All five boards have been cut on the same current build. Nothing else in this amendment depends on it - the evidence for the Pi 2 is the replay itself, not how many tries it took - but the number was acted on, so it is corrected where it was stated rather than left for a reader to trip over.

What does NOT change, and is the reason this is an amendment rather than a retraction. The guarantee stays backend-conditional. That reasoning is sound and untouched: a redo journal is pure ordering, ordering can only be enforced by a device that attests durability, and a device that refuses or LIES about a flush cannot carry the guarantee. fs-lyingflush models exactly that case in QEMU and it stays. The honest form of the Phase D claim - fs is restartable everywhere, and crash-recoverable on a backend that can be ordered - is correct as written.

What changes is that the claim is about a CLASS of device, not about this board. The Pi 2 was the one named instance and it does not belong in it; the sentence naming it stands above as ratified history, and this amendment is the canonical state (§1). No board in this project is currently known to refuse the barrier. If one is found, it belongs here with the evidence that put it here - which is the standard the Pi 2 was held to only after the fact, and the point of recording this at all.

The FUA half is untouched and still true: the stick does honour FUA, and FUA costs more per write than the driver's command budget can give it. That was never the load-bearing claim - the flush is - but it is not contradicted by anything here.

Five boards, four ISAs, three storage backends, power cut 5 of 5 recovered in the strong form, and ALL FIVE cut UNASSISTED - Wyse, T630, Pi 2, Pi 4 and VisionFive 2, each landing in the commit window with no held-open pause helping the device. The Pi 4 and VisionFive also had crash-window cuts a day earlier; those are superseded, so no result this amendment rests on depends on a window held open for the device's convenience. docs/gsfs-carnage.md §4 carries the matrix; backlog/fixed/42 carries the investigation, including the two earlier Pi 2 cuts that missed the window and what they did and did not measure.

Amendment 2026-06-09 (H11): registry is no longer a TCB member. It became a real userspace name service (register/lookup over IPC, holding only delegated caps and deriving copies). It owns no kernel-critical state, so its runtime death degrades name resolution temporarily rather than corrupting the system, and it is now restartable (see §6.2, §6.3). Its boot-time spawn must still succeed (the name service must come up to bootstrap), which remains fatal (§11.3). Worked example of invariant 11 - identity (the name) is stable; the registry instance/location is not.

Amendment 2026-06-21 (naming Phase 4 / Path C): the registry SERVICE is RETIRED. Moving name→endpoint resolution to the supervisor (Phases 0a-3c, docs/naming-design.md) left the registry's only remaining job - handing a client a cap to a service by name - to a minimal name → endpoint recovery directory inside the kernel (ipc::names + the gated AcquireSendCap). The supervisor wires every service from a name → cap map at boot and on restart; clients reacquire names through the directory. So the registry service is deleted outright (crate, config, spawn, §22 Test 11/1B moved off it). This is the bounded "one exception" of Path C (§3.7): a thin naming directory stays in the kernel - justified by §6.3 (the recovery anchor must be unkillable) - even though name policy lives in the supervisor. the registry's own design note was DELETED with the service (9a880b12, pre-public cleanup), so this amendment and docs/naming-design.md are the whole record. This line used to point at the file itself, which sent a reader after a document that no longer exists.

6.2 Failure Semantics

The only unkillable component is the kernel itself. Every userspace service - including the supervisor - is restartable; its death is recovered, never a reboot.

Amendment 2026-06-21 (Path C / Phase 6): the supervisor is restartable; its death is no longer a kernel panic. When the supervisor dies (a fault, or a deliberate kill - chaos kill-storm supervisor, the operator control channel), the kernel respawns it - the kernel is the last-resort recovery anchor, the one thing that cannot die (§3.7). The respawned supervisor re-registers its death-notification endpoint in the kernel directory (so notifications re-point to it) and reconciles: it adopts the still-running services (reacquiring each by name) rather than duplicating them, and respawns any that died. The non-restartable set is now {kernel} alone - §6.3's goal reached at its floor. (init removed, Phase 5; registry retired, Phase 4; block-driver + fs restartable, Phase D.) Pinned by §22 Test 15.

The respawn is unbounded - deliberately. The kernel respawns the supervisor unconditionally, forever; there is no cap-then-panic. A bound would re-introduce the very reboot this eliminates (deferred from 1 death to N) and hand an attacker a trivial denial-of-service (kill the supervisor N times to force a reboot). This is not unbounded-resource behaviour (§26.6): each respawn first reclaims the dead instance's frames/kstack/caps, then allocates fresh, so the footprint is constant and reclaimed every time - only a count grows, and a count is not a resource. Each respawn is loud (logged with a running count, §26.4/§26.7), so a sustained loop is visible and an operator can intervene, but the system stays alive rather than rebooting. "Assume the worst" → always recover.

block-driver, fs, and shell are restartable. Their runtime death is recovered by the supervisor's death-notification restart loop (the kernel notifies the supervisor, which respawns them); clients see a temporary EndpointDead / lookup miss, reacquire by name via the kernel directory, and retry (§14.3). fs re-mounts to a consistent state via its crash-consistency journal (Phase C; docs/persistence.md §6.8). The shell is the user's interface - a crash or a deliberate kill shell respawns a fresh prompt (the in-flight command is lost - a re-init, not a resume, §14.2/§25 - but the session recovers) instead of leaving a dead session; closing the one service that used to stay dead-forever if killed ("nothing escapes"; invariant 6). registry left the TCB via H11 (then the service was retired, Phase 4); block-driver + fs via the Phase D amendment (§6.1). Only their boot-time spawn failure is fatal (§11.3).

Amendment 2026-10-11 (ask): the death notice says HOW the task ended, because the kernel already decides it and was throwing it away. A task ends in exactly two ways: the kill syscall, or a CPU fault in user mode (a service that panics ends as the second - its panic handler reads address 0). The kernel takes that path every time, and then told the supervisor only the name, so "why did fs restart?" could be answered nowhere in the system: a deliberate kill and a crash looked the same.

The notice now carries it: the name, a NUL, then K and the name of the task that called kill, or F, a fault kind (memory, instruction fetch, illegal instruction, protection, alignment, or one the port does not classify) and the address the port has for it (task::death, the SDK's death). Each port's user-fault path names the kind; x86's shared exception stub does not know its vector and says so rather than guessing. The supervisor keeps the newest per name and answers supcmd::DEATH; its "died, restarting" line now ends - killed by shell or - memory fault at 0x0.

What this does NOT add, which is why it is an amendment and not a seventh responsibility. No syscall, no capability, no new authority and no record held by the kernel: the cause rides the notice the kernel already sends, to the one task that already receives it, for exactly the tasks it already reports. The kernel learns nothing about any service; it reports a fact about its own kill path. The record - bounded, newest per name, volatile across a supervisor restart - is the supervisor's, which already recorded THAT the service died. Mechanism, not policy (26.10): whether a kill was justified is the killer's to say, and nothing here tries to.

Verified in QEMU on all four ports (a kill named on each; a fault on AArch64 through net-stack's el0-fault-test); osdev test identity 24/0 and adv 15/0. docs/ask-design.md 6 recorded this as the missing contract.

Failure on any core that corrupts shared kernel state (capability table, routing table) results in kernel panic on all cores.

Silent recovery of TCB state risks undefined system state. Loud failure plus clean restart is the only safe v1 option.

6.3 Reducing TCB Over Time - goal reached, at the floor

The v2 goal was: only init, supervisor, and the kernel remain non-restartable. That goal is now met and exceeded - the non-restartable set is {kernel} alone. registry was dropped early via H11 (a restartable userspace name service, then retired entirely in naming Phase 4 / Path C); block-driver and fs were dropped via the Phase D amendment (§6.1, 2026-06-17) once the filesystem gained crash-consistent recovery (the redo-journal, docs/persistence.md §6.8) - so an fs restart re-mounts to a consistent state rather than corrupting it. init was removed outright (Path C / Phase 5): the kernel spawns the supervisor directly. And finally the supervisor itself was made restartable (Path C / Phase 6, §6.2): the kernel respawns it on death - unconditionally, forever - and it recovers from the kernel's name directory (adopting the live services). So the kernel is the only unkillable thing (docs/naming-design.md §3.7) - DMA drivers aside, and only on a machine without an IOMMU (§6.4). The floor is reached; there is nothing left to shrink.

6.4 DMA-Capable Drivers and the IOMMU (H1)

Amendment 2026-06-12 (H1). Adopts IOMMU DMA-confinement as the mechanism that brings DMA-capable userspace drivers into the least-privilege model (invariant 1).

DMA-capable userspace drivers (xhci, ehci) are not trusted root. Their trust status is machine-dependent, and which case holds is reported loudly at boot (invariant 12):

  • Without an IOMMU, a DMA-capable driver holds implicit kernel-equivalent power: it programs its controller's DMA engine with physical addresses and can therefore read or write anywhere in RAM, regardless of the capabilities it holds. Its compromise is unbounded, so it is trust-critical by necessity. (Boot reports iommu: no IVRS table ... drivers stay in TCB.)
  • With an IOMMU confining it (H1), the device can reach only the driver's granted DMA arena; a DMA outside it faults rather than corrupting memory. A compromise is then bounded to the arena the capability model already granted, so the driver is genuinely least-privilege and restartable - exactly as ordinary services are. (Boot reports iommu: ... confined BDF ...; out-of-arena DMA is pinned by §22 Test 12.)

This machine-dependent posture is deliberate: the same driver binary is least-privilege on a machine whose IOMMU confines it and trust-critical on one without, and the difference is a printed boot fact rather than a hidden assumption. Confinement is applied per driver and only where it fits the driver's DMA shape - xhci is confined to its arena; ehci (which legitimately reaches firmware/hub regions and a low-speed device only through a hub's transaction translator) runs in transparent IOMMU passthrough. See docs/iommu.md.

Operationally these drivers were already restartable (their death reclaims their IOMMU resources and the supervisor respawns them); this amendment makes the trust claim official, not the runtime behaviour.

Amendment 2026-09-12 (multi-architecture / DMA census): this section names its members as a LIST, and the list was short. The rule is restated by PROPERTY. §6.1's table and this section's opening sentence enumerate xhci, ehci and dwc2 as the DMA-capable drivers whose trust is machine-dependent. That enumeration is not the rule - the rule is the argument two paragraphs above, which turns only on a device being able to DMA: any service granted a DMA arena is trust-critical on a machine that cannot confine it. Two services meet that test and were never listed: block-driver (the AHCI command list / FIS / PRDT / data buffers) and nic-driver (the TX/RX descriptor rings and packet buffers on e1000, RTL8168, GENET and dwmac alike). Their absence understated the TCB rather than changing it - both were always in it by this section's own reasoning.

What confinement actually covers today, stated as fact rather than intent:

  • xhci is the only confined driver in the system. ehci and block-driver keep a stale firmware DMA pointer that confinement would fault, so both run in deliberate passthrough (kernel/src/task/mod.rs, the confine flag on HwClass::Pci - this cited a DeviceSpec type that has never existed, and it was introduced to replace a rotting line number on the grounds that a field name cannot drift); nic-driver is spawned confine=false.
  • AMD-Vi is x86-only. Every iommu:: entry point on arm, aarch64 and riscv64 is a stub and confine_device returns false. Three of the four shipping ports are therefore entirely in the "without an IOMMU" case, with no confined driver at all.

So the honest reading of this section is: the machine-dependent posture is real and the mechanism works, but it currently bounds exactly one device on one architecture. Everything else that can DMA is trust-critical. This is recorded rather than closed (§26.7) because the remaining cases are blocked on hardware (no SMMU is wired up on any non-x86 board) and on a firmware quirk (the stale pointer), not on effort. docs/networking.md and docs/ahci.md each carried the opposite claim for their own driver and are corrected in the same change.

Amendment 2026-10-03 (audio): there are TWO confined drivers now, and the kernel releases a confinement by the device, not by name. The amendment above says xhci is the only confined driver in the system. audio-driver (Intel High Definition Audio, docs/audio.md) is spawned confined as well: every DMA its controller makes - the command rings, the buffer descriptor list, the ring of sound - is inside its arena, so confinement refuses nothing it does legitimately. Verified in QEMU on q35 with amd-iommu: the confinement selftest passes, the tone plays through the confined domain, and a kill and restart releases and re-confines the device (build/audio_iommu_qemu.log). Its controller uses plain MSI, so the interrupt message is in configuration space, out of the driver's reach.

The kernel change it needed adds no responsibility. On a driver's death the kernel reverted the device's confinement only for xhci and ehci, named; a third confined driver would have leaked its I/O page table on every restart. It now releases for ANY task that holds a device, which is exact because the release does nothing for a device that was never confined. The bus-master clear beside it moved from a list of four names to the same per-task device record in the same change. Both are memory isolation the kernel already enforced (§4.3), now keyed on what a task holds.

What does not change: AMD-Vi is still x86-only, ehci, block-driver and nic-driver are still in passthrough, and on the T630 the audio driver does not yet use DMA at all (docs/audio.md, A6), so on real hardware xhci is still the one confined device. The mechanism now bounds two devices, on one architecture, one of them in QEMU only.

(Note 2026-10-08: the T630 confines the audio controller too - its log shows xhci and 00:01.1 each confined with an arena - so on real hardware there were two confined devices, and they shared one IOMMU domain ID. AMD-Vi caches translations by domain ID, so that sharing was a defect; each confined device now has its own (kernel/src/arch/x86_64/iommu.rs, domain_of). Found under an xhci that stopped completing commands in a chaos run, docs/wifi-usb.md 51.)

Amendment 2026-07-16 (SEC-2): a confined USB driver's least-privilege claim is bounded by the console it drives. A USB keyboard driver is, by function, the machine's input path: it delivers keystrokes to the shell via CONSOLE_PUSH, and keystrokes are commands. The kernel cannot distinguish a faithfully-decoded key-press from a synthesized one, so no code check can let a real keyboard type a privileged command while stopping a compromised driver from injecting the same bytes (both reach the shell through the same input ring). CONSOLE_PUSH holders are therefore inside the shell's trust perimeter - IOMMU confinement bounds the driver's DMA, not its console-injection authority. What the code can do is deny the driver any authority it does not need for that role: SEC-2 removed REBOOT from the USB drivers, so a compromised driver can no longer hard-reset the machine directly from any context - reboot lives only with the shell (its reboot command). The residual (a keyboard can type) is inherent to being a keyboard, and is recorded here rather than papered over. audits/security-audit.md SEC-2.

Amendment 2026-07-19 (SEC-2 follow-up): Ctrl+Alt+Del is restored, routed through the shell. The chord initially became ordinary keystrokes, which removed a familiar affordance. It now works again without giving the driver back anything: the USB driver only signals the chord on the console stream (hid::CTRL_ALT_DEL_SIGNAL, a byte outside ASCII that no typed key produces) and the shell - the principal that legitimately holds REBOOT - makes the decision. What SEC-2 actually won is intact: a driver still cannot issue the reboot syscall, in any context, even when no shell is listening. And it grants the driver no new authority, because a CONSOLE_PUSH holder could already type reboot - the residual named just above. Scope: the chord is handled at the shell prompt; while a full-screen app owns the console the shell is not reading, so quit it first.

Amendment 2026-08-09 (Pi 4 / AArch64): the USB stack is OUT of the kernel, and the in-kernel driver is DELETED. The amendment below records that on ARM the USB drivers are in-kernel TCB members "by construction", and calls the userspace split "future work awaiting device-IRQ-to-userspace routing". On AArch64 (Raspberry Pi 4) that work is done: kernel/src/arch/aarch64/xhci.rs (2742 lines of ring-0 code parsing descriptors supplied by whatever was plugged in) no longer exists. The xhci SERVICE drives the controller through an MMIO capability and a DMA arena granted at spawn, reaches the console through CONSOLE_PUSH, and serves storage over the block IPC protocol that block-driver already spoke. The kernel's entire remaining involvement with USB is to establish that the controller is present and reachable and to say so.

This was a feature flag first (xhci-userspace / usb-via-xhci) so both drivers could exist while the service was proven on hardware. The flags are now deleted too: a flag that selects between a compliant and a non-compliant kernel leaves the violation one build away, and it was a genuine footgun (it had to reach three crates; setting some of them gave two drivers on one controller, or none, with every half booting fine alone). There is no build of AArch64 GodspeedOS in which the kernel drives USB. Commandment I is closed on this port, not merely satisfiable.

What this does NOT buy: the driver is still a TCB member on this board (SEC-33). The Pi 4 has no IOMMU/SMMU wired up - every iommu:: entry point on aarch64 is a stub and confine_device returns false - and the service is granted its controller's BAR plus a DMA arena including its physical base. So it can point the controller's DMA at any address, which is kernel-equivalent reach by §6.4's own rule, exactly as an unconfined x86 driver would be. The honest statement is therefore: a buggy USB driver on the Pi 4 is now bounded; a compromised one is still kernel-equivalent.

That distinction is worth being precise about, because the milestone is real but narrower than "Commandment I closed" sounds. What the move genuinely bought is (a) the ACCIDENT surface - ring-0 descriptor parsing became a restartable service, which is the whole content of SEC-29, now closed on aarch64 - and (b) ambient authority: SEC-2's no-REBOOT win travels here (unlike arm32, where an in-kernel driver implicitly holds every kernel authority), and the service holds only CONSOLE_PUSH, its log, its endpoint, its BAR and its arena. The kernel-scope violation is closed; the TRUST posture is unchanged until an SMMU confines the device. Also unmet on this port: §6.4 requires the confinement case to be "reported loudly at boot", and nothing is printed either way (SEC-34).

ARM32 (Pi 2) has since followed - see the 2026-08-17 amendment below. When this was written its DWC2 stack was still in the kernel and still a TCB member; that is no longer true, and the sentence that said the two ports "differ in exactly this" is superseded. Verified: chaos max-carnage 100 rounds, selfcheck 349, hot-plug of keyboard and mass storage in both directions, all on the userspace driver.

Amendment 2026-08-17 (ARM32 / Pi 2): the DWC2 stack is OUT of the kernel too, and arch/arm/dwc2.rs is DELETED. The amendment below (SEC-29/SEC-30) records the in-kernel ARM USB stack as a machine-dependent posture that could not be fixed on this hardware, because ARM did not route device IRQs to userspace. It does now: services/dwc2 registers for USB_VECTOR (arch/arm/irq.rs) and is driven by the interrupt rather than the core-0 timer tick. So the two consequences that amendment draws no longer hold as stated: the memory-safety of arch/arm/dwc2.rs is not TCB-critical because that file does not exist, and SEC-2's win DOES now travel to ARM - the ARM USB driver is a service holding only the capabilities its contract grants, so it cannot call hardware_reset and does not sit inside the kernel trust perimeter.

What does NOT change, and is the same caveat the Pi 4 carries: the Pi 2 has no IOMMU/SMMU, so the driver's DMA is unconfined and a COMPROMISED driver is still kernel-equivalent by §6.4's own rule. A buggy one is now bounded and restartable; a malicious one is not. The ACCIDENT surface is what closed here, exactly as on aarch64. The residual that is inherent everywhere also stands: a keyboard's keystrokes are commands (SEC-2).

The same is now true of the DISPLAY on this port: kernel/src/fbcon (1,172 lines of terminal emulation) left the kernel for services/console, leaving only the §11.4 boot/panic blit (docs/console-service.md §9). Verified on hardware: chaos 50 rounds, 0 kernel panics, 0 liveness wedges, selfcheck 350/0.

[SUPERSEDED IN PART by the 2026-08-17 amendment above - read that first.] The in-kernel ARM USB stack this describes NO LONGER EXISTS: arch/arm/dwc2.rs is deleted and services/dwc2 drives the controller from userspace on USB_VECTOR. Kept because §1 makes an amendment ratified history - this records why ARM USB was ever a TCB member, which is what the later amendment is a reply to. What still holds is the DMA half: the Pi 2 has no IOMMU/SMMU (iommu::confine_device returns false on this arch), so a COMPROMISED driver remains kernel-equivalent by §6.4's rule. A buggy one is now bounded and restartable. This block is placed out of date order, after the amendment that supersedes it, so it is marked rather than left to be read as current.]

Amendment 2026-07-23 (SEC-29/SEC-30): on the ARM32 (Raspberry Pi 2) port the USB drivers are in-kernel TCB members - a machine/arch-dependent posture, the ARM analog of the no-IOMMU case above, and larger. The amendments above concern userspace USB drivers (xhci/ehci) confined - or not - by an IOMMU. The ARM port has no such split yet: the DWC2 USB host driver (keyboard, CDC-ECM/smsc95xx networking, mass-storage) runs in the kernel (arch/arm/dwc2.rs, driven from boot + the core-0 timer tick), because ARM does not yet route device IRQs to userspace (arch/arm/CLAUDE.md, "Drivers on ARM"). So on ARM the USB stack is ring-0 code that parses untrusted device-supplied descriptors and frames, and it is a TCB member by construction. This is strictly more than the no-IOMMU x86 case: there the driver is untrusted-but-userspace with only its DMA unconfined; here the driver is the kernel. It is not fixable on this hardware - the Pi 2 has no IOMMU/SMMU to confine USB DMA regardless, and the userspace-driver split awaits device-IRQ-to-userspace routing - so, per §26.7, it is recorded rather than closed. Two consequences follow: (a) the memory-safety of arch/arm/dwc2.rs is TCB-critical (audited: audits/unsafe-audit.md, audits/security-audit.md Audit 2, no UB/OOB/race); (b) SEC-2's win does not travel to ARM - "REBOOT lives only with the shell, not the USB driver" is an x86 property; an in-kernel ARM driver implicitly holds all kernel authority (it could call hardware_reset directly), so the ARM keyboard driver sits inside the kernel trust perimeter, not merely the shell's. The residual that IS shared with x86 is inherent: a keyboard's keystrokes are commands (SEC-2). audits/security-audit.md Audit 2 (SEC-29/30) is the full treatment.


7. Capability System

7.1 Concept

A capability is an unforgeable token granting specific rights to a specific resource at a specific generation. Holding a cap is necessary and sufficient authority for the actions it permits - provided its generation still matches the kernel's record.

7.2 Capability Structure

Capability = ResourceId + Rights + Generation
  • ResourceId - identifies the target resource (endpoint, memory region, MMIO range).
  • Rights - bitfield of permitted actions (READ, WRITE, SEND, RECV, GRANT, REVOKE).
  • Generation - monotonic counter assigned by the kernel when the resource is created or replaced.

7.3 Properties

  • Unforgeable. Only the kernel constructs valid capabilities.
  • Explicit. Authority comes from holding a cap, not from identity.
  • Non-escalating. Rights cannot be widened.
  • Scoped. Targets a specific resource, not a class.
  • Revocable. Kernel can invalidate at any time.
  • Transferable (with GRANT right).
  • Generationed. Stale caps fail their generation check on use.

7.4 Rights Model

RightMeaning
READRead from a resource
WRITEWrite to a resource
SENDSend messages to an IPC endpoint
RECVReceive messages from an IPC endpoint
GRANTTransfer this capability via IPC
REVOKERevoke this capability (held by supervisor only)

7.5 Generations

Every capability carries a generation number. Every resource in the kernel tracks its current generation.

Bumping rules:

  • Restartable / destroyable resources bump their generation when they are destroyed or replaced. This invalidates all outstanding caps targeting them.
  • Stable resources (kernel-owned, never reclaimed) keep generation 0 forever.

Validation on use:

syscall(cap, action, args):
    if cap.resource_id not in kernel_table:
        return CapNotHeld
    if cap.generation != kernel_table[cap.resource_id].generation:
        return CapRevoked or EndpointDead   # depending on cause
    if action not in cap.rights:
        return CapInsufficientRights
    perform action
  syscall(cap, action, args)
    │
    ├─ cap.resource_id in kernel table?
    │       No  ──▶  CapNotHeld
    │       Yes ↓
    ├─ cap.generation == table generation?
    │       No (destroyed)  ──▶  EndpointDead
    │       No (revoked)    ──▶  CapRevoked
    │       Yes ↓
    └─ action in cap.rights?
            No  ──▶  CapInsufficientRights
            Yes ──▶  perform action  ──▶  Ok

The generation check is one atomic comparison. It is the v1 mechanism for cross-core revocation: bumping the generation on one core makes every cap on every other core stale, with no synchronous notification required.

7.6 Lifecycle

  [kernel mints cap with current generation]
           │
        Created
           │  inserted into cap table
         Held ◀────── action complete ──────┐
           │  syscall + rights + gen check   │
          Used ────────────────────────────▶─┘
           │
           ├── send with GRANT ──▶  Transferred ──▶  Held (receiver's table)
           │
           └── kernel bumps resource gen ──▶  Stale
                                                 │
                                    next use: CapRevoked / EndpointDead

7.7 Error Codes

Both error codes are kept; they share a mechanism but communicate different intent:

ErrorMeaning
CapNotHeldCap not in calling task's table
CapInsufficientRightsCap held but lacks required right
CapNotGrantableSend embedded a cap without GRANT
CapWrongScopeCap targets a different resource than the action
CapRevokedAuthority explicitly invalidated
EndpointDeadEndpoint/service lifecycle terminated
ReplyDeadReplier died while the caller was blocked awaiting its reply (the reply-side twin of EndpointDead; §8.6)

Same generation-mismatch mechanism underlies CapRevoked and EndpointDead; the kernel returns the more specific code based on whether the resource was destroyed (endpoint dead) or had its cap explicitly revoked. ReplyDead shares that mechanism on the reply side: a caller blocked in a synchronous Call (§8.2) wakes with ReplyDead the instant its replier's endpoint dies (§8.6).

7.8 Capability Table Concurrency

Multiple cores may execute syscalls touching the same capability table simultaneously. v1 uses a locking discipline that guarantees:

  • Reads (cap lookup, generation check) and writes take the SAME exclusive lock; nothing here is wait-free or reader-parallel.
  • Writes (cap insertion on spawn, removal on death) are serialized.
  • A revocation in flight on one core is visible to a syscall in flight on another core within bounded time (next memory barrier).

The exact primitive is an implementation choice, not a spec choice.

v1 implementation note: A single global lock is acceptable for the v1 milestone. Syscall-path serialization is a known performance cost; sharded or RCU-based designs are explicit v2 work and require benchmarks before adoption.

Amendment 2026-09-12: it is a SpinLock, not an RwLock, and this section claimed a property the code never had. GLOBAL_RESOURCES is a SpinLock<GlobalResourceTable> (kernel/src/capability/table.rs); there is no RwLock type anywhere in the kernel. So "reads are wait-free under common cases" was never true: a cap lookup takes the same exclusive lock a spawn does and can spin behind it. The bullet above is corrected. This does not weaken any guarantee - §7.8's actual requirements (reads correct, writes serialized, revocation visible within bounded time) all hold under plain mutual exclusion, and it is strictly more conservative than the RwLock this described. What changes is the performance claim, which B7 measures.

7.9 Example

fn main(ctx: ServiceContext) -> Result<()> {
    let log       = ctx.capability("log_write")?;
    let pong_send = ctx.capability("ipc_send.pong")?;

    log.info("ping starting")?;
    pong_send.send(Message::text("hello"))?;
    Ok(())
}

7.10 Delegated Resource Capabilities (P2 - file-as-capability)

Amendment 2026-06-18 (P2). Extends the capability model so a resource's meaning can be owned by a service while the kernel still mints, validates, routes, and revokes its caps. This is the mechanism that makes "a file is a capability" literally true (docs/persistence.md §7.2).

The kernel mints unforgeable caps (§7.3) but has no concept of a file (§4.4). Delegated resource capabilities bridge the two: a service owns a band of ResourceIds whose meaning only it knows, and the kernel treats each as an opaque resource - identical machinery to an endpoint cap.

  • Mint. A service asks the kernel (resource_mint) to allocate a fresh ResourceId in its band, register it (generation 0, Alive), record the service's endpoint as its owner, and mint a cap with chosen Rights (READ/WRITE/GRANT). The service hands a (narrowed) copy to a client by the existing embedded-cap transfer (§8.5). fs does this on Open, recording ResourceId → file.
  • Use = send. A holder uses the cap by sending on it. The kernel validates it (generation + required right) and routes the message to the owning service's endpoint, badged with the ResourceId - so the owner knows which resource without the kernel knowing what it means. A read/write to a file is then a first-class capability operation: validated, denied, or revoked by the same code path as any cap.
  • Revoke = generation bump (§7.5). The owner revokes a resource it owns (resource_revoke); every outstanding cap to it goes stale and the next use returns CapRevoked. fs revokes on delete/close.
  • Minting is gated (§3.1). resource_mint requires a RESOURCE_MINT capability, granted only to services that legitimately issue resources (e.g. fs). Delegated minting is explicit authority, never ambient.

Every §7.3 property holds for a delegated resource cap exactly as for an endpoint cap: unforgeable, non-escalating (rights narrow on transfer), scoped (to one ResourceId), revocable, generationed. The kernel learns nothing about files; it routes opaque resources. Pinned by §22 Test 14.


8. IPC

8.1 Model

Synchronous message passing with bounded per-endpoint queues. Endpoints are owned by services; services are pinned to cores; sending across cores goes through the kernel routing table.

8.2 Syscalls

#![allow(unused)]
fn main() {
send(endpoint_cap, message)     -> Result<(), IpcError>   // blocks if full
recv(endpoint_cap)              -> Result<Message, IpcError>  // blocks until msg
try_send(endpoint_cap, message) -> Result<(), IpcError>   // non-blocking
call(target_cap, reply_cap, recv_cap, message)
                                -> Result<Message, IpcError>  // send request + block for reply
call_deadline(target_cap, reply_cap, recv_cap, message, secs)
                                -> Result<Option<Message>, IpcError>  // `call`, bounded
}

call is the synchronous request/reply primitive (syscall 41). It sends the request to target carrying a one-shot reply cap (the caller's own endpoint), then blocks awaiting the reply. It wakes with the reply, or - if the replier dies before replying - with ReplyDead (§8.6), so it never hangs. This is mechanism, not policy (§26.10): the kernel learns only about a reply cap and its death semantics, never "request/reply" or "RPC". It is the primitive behind the SDK's request_with_reply; the reply-side twin of the blocked-sender EndpointDead wake.

Amendment 2026-08-21 (CallDeadline, syscall 50): call gains a BOUND, because the unbounded one was being worked around in a way that lost messages. call is the only primitive that dequeues the REPLY specifically (matched to the reply cap), leaving every other message queued - but it blocks forever, so the SDK hand-rolled a bounded variant out of send + a plain endpoint recv. A plain recv takes whatever is next, and a service that SERVES clients on the endpoint it awaits replies on would therefore consume an unrelated client request, fail to match it, and drop it: the request lost outright, and the real reply arriving later as an orphan that desynced every following exchange. Observed on both the Pi 2 and the Pi 4 as fs clients hanging and the block protocol "losing step".

The fix is not a workaround at the caller. It is the correct primitive made usable: same reply-cap semantics, same call_dequeue, plus the deadline machinery RecvTimeout already uses. This is mechanism, not policy (§26.10) - the kernel learns a deadline, not what the caller is waiting for

  • and it is what §26.6 asks of every wait in the first place. Ok(None) means the deadline passed; the reply cap is the caller's to reclaim, exactly as on any other failure (§8.5).

A new syscall is a new kernel responsibility and the surface is pinned (Commandment I), so this is recorded here rather than merely added: it is call with a bound, not a new capability, and the enforcement layer refused the change until this amendment existed.

8.3 Routing

The kernel maintains a routing table:

EndpointId → (CoreId, Generation, Liveness)

On every send, the kernel:

  1. Validates the cap (rights + generation).
  2. Looks up the target endpoint in the routing table.
  3. Enqueues into the target endpoint's queue (which lives on the target's core).
  4. If the receiver is blocked on recv, sends an IPI to its core to wake it.

8.4 Send Flow

  Service A (Core 0)           Kernel                  Service B (Core 1)
       │                          │                            │
       │── send(endpoint_cap, ───▶│                            │
       │        message)          │ validate cap               │
       │                          │ (rights + generation)      │
       │◀── CapNotHeld ───────────│  cap not in table          │
       │◀── CapRevoked ───────────│  gen mismatch (revoked)    │
       │◀── EndpointDead ─────────│  gen mismatch (dead)       │
       │                          │                            │
       │   [queue full]           │                            │
       │   A blocked ─────────────│────────────────────────────│
       │   in routing table       │  (waiting for queue space) │
       │                          │                            │
       │   [queue has space]      │                            │
       │                          │── copy msg into B's queue ─│
       │◀── Ok ───────────────────│                            │
       │                          │── IPI Core 1 ─────────────▶│
       │                          │   wake B if blocked        │

8.5 Message and Queue Format

  • Maximum message size: 4 KiB (one page).
  • Queue depth: 16 messages per endpoint, fixed in v1. Worst-case 64 KiB per endpoint queue.
  • Copy semantics: kernel copies sender → receiver. Zero-copy is permanently rejected (§2.5).
  • Embedded capabilities: caps inside a message are transferred (with GRANT) and removed from sender's table.

Queue depth is not configurable per endpoint in v1. Per-endpoint depth is a v2 concern.

Transferring a capability - the three checks. Embedding a cap in a message moves authority. The kernel enforces the rules below but cannot warn you at compile time, so an author (and a reviewer) must verify all three every time a cap is sent:

  1. Grantable? The cap being sent must hold the GRANT right, or the transfer is rejected with CapNotGrantable (§7.7). A copy made by derive_cap carries only the rights of its source, so a send-only cap yields a send-only copy that cannot be transferred. Do not assume a transfer succeeded; a send that embeds an un-grantable cap fails, and the receiver gets nothing (§7.4, §7.6).
  2. Moved, not kept. A successful transfer removes the cap from the sender's table (§7.6): authority moves, it does not duplicate. Reusing that handle afterward is a use of a cap you no longer hold. And if the transfer failed, the cap stayed - reclaim it (so a table slot does not leak, §26.6). Either way, the outcome of the send decides what you may do next: never ignore it.
  3. Narrowed to need. Grant the least rights the receiver needs (§7.3). In particular, do not pass GRANT onward unless the receiver is meant to re-delegate - a cap handed out with GRANT lets its holder transfer it again. Rights only narrow on transfer; widening is impossible, so the floor you set is the floor forever.

8.6 Failure Semantics

EventEffect
Service diesEndpoint generation bumped; queue drained
send to closed endpointReturns EndpointDead
recv on closed endpointReturns EndpointDead
Sender already blocked when endpoint closesWakes with EndpointDead (cross-core IPI)
Send-during-restart raceGeneration check catches it; returns EndpointDead
Replier dies while the caller is blocked awaiting its replyCaller wakes with ReplyDead (cross-core IPI)

Amendment 2026-06-30 (reply-side death-wake): a caller blocked awaiting a reply wakes with ReplyDead, completing the sender side of this table. The rows above already wake a blocked sender when its target endpoint closes (EndpointDead); the reply side was the missing twin. A synchronous request/reply caller (the SDK request_with_reply, on which fs's block_rpc rides) sends a request carrying a one-shot reply cap and then blocks awaiting the reply on its own endpoint. If the replier died after receiving the request but before replying, that blocked recv hung forever - a reply that will never come. The kernel now tracks, for a caller blocked in a synchronous CALL, the endpoint it awaits; the endpoint-death path finds any such caller (its outstanding reply cap's holder has died) and wakes it with the new ReplyDead code, mirroring the blocked-sender wake exactly (same generation/liveness mechanism, same cross-core IPI). This is mechanism, not policy (§26.10): the kernel learns only about a reply cap and its death semantics - never "request/reply" or "RPC". It is bounded (a blocked task has at most one in-flight call, so one tracking slot per task). It is the executable form of Commandment VIII for interdependent services: a dependent waits on its dependency's reply (truth), and a dependency that dies wakes it loudly rather than hanging it. Pinned by osdev test reply-dead (the reply-side twin of §22 Test 4).

No delivery guarantee. A successful send means the message was queued, not processed. Protocols requiring acknowledgment must build it explicitly.

8.7 Send-During-Restart Race

Without explicit handling, this race is real:

  • Core 0: A reads cap to B (generation 4, alive).
  • Core 1: B is killed, generation bumped to 5.
  • Core 0: A's send syscall executes.

Resolution: the kernel checks A's cap generation against the routing table's current generation atomically inside the send syscall. The generation has been bumped to 5; A's cap is generation 4; send returns EndpointDead. The race is invisible to the developer.

8.8 Cross-Core IPC Cost

Cross-core sends pay one IPI to wake a blocked receiver, one memory barrier on enqueue, and cache-line bouncing on the queue's head/tail indices. These are real costs but they are bounded and predictable. v1 does not optimize cross-core IPC beyond getting it correct.

8.9 Deadlock and Mutual-Blocking Avoidance

The kernel does not detect or break deadlocks.

Design rule: In any protocol where A and B both send to each other, at least one direction MUST use try_send. Mutual blocking sends are an anti-pattern the kernel will not detect and will not recover from. The supervisor's quantum-starvation watchdog is a last resort, not a primary mitigation.

The classic deadlock - A and B both call send to a full queue - is the developer's responsibility to prevent at the protocol level. Use try_send, structure as request/response, or apply explicit timeouts.


9. Scheduler and SMP

9.1 Model

  • Multi-core in v1. Number of cores discovered at boot.
  • Per-core run queues. Each core runs round-robin over its own queue.
  • Static placement. Services are pinned at spawn; they never migrate.
  • 10 ms preemption quantum, per core, enforced by each core's local timer.
  spawn ──▶  Ready
               │  scheduler selects
               ▼
            Running ◀──────────────────────────────────────────┐
               │                                                │
               ├── timer preemption (10 ms) / yield() ─────────┘
               │
               ├── recv() on empty queue ──▶  BlockedOnRecv
               │                                   │
               │                   message arrives │
               │                   (IPI wake) ─────┘
               │
               ├── send() on full queue ──▶  BlockedOnSend
               │                                   │
               │               queue drains (IPI) ─┤
               │               or EndpointDead ─────┘
               │
               └── page fault / supervisor kill ──▶  Dead
                                                        │
                                            frames reclaimed, generation bumped

9.2 Placement (Strict)

When the supervisor spawns a service:

1. If the contract specifies [placement] core = N:
   - If core N exists and is ready, spawn on N.
   - Otherwise, spawn rejected with PlacementInvalid.
     The supervisor logs the rejection and skips the service.
     The system continues with the services that did start.
2. If no placement specified:
   - Round-robin across ready cores.
3. Once placed, the service stays on that core for its lifetime.

Rationale (strict): Contracts are enforced, not interpreted (§3.7). A contract that names a core means the developer expressed deployment intent. Silently rerouting to a different core would be exactly the kind of reinterpretation a capability-based system is designed to forbid. Contracted placement is deployment-coupled by design.

On restart: the placement decision is re-evaluated from scratch using the rules above. The supervisor does not remember the previous core.

  • If the contract specified a core, that core is required again. If it is unavailable, the restart fails with PlacementInvalid - the same strict rule as initial spawn.
  • If the contract did not specify a core, round-robin selects a fresh core, which may differ from the previous one.

This is consistent with invariant 11 (identity is stable; location is not). Sticky placement would make location stable across restart, contradicting the principle. Mid-execution migration remains forbidden.

9.3 Yield

#![allow(unused)]
fn main() {
yield() -> ()
}

Yield is advisory. Preemption remains enforced regardless. The scheduler does not rely on cooperative behavior for correctness or fairness.

9.4 Cross-Core Wakeups

When a syscall on core 0 needs to wake a task on core 1 (e.g., send woke a blocked recv), the kernel sends an IPI to core 1. The receiving core's IPI handler re-enters the scheduler.

9.5 What v1 Does Not Have

  • Work stealing
  • Load balancing
  • Priority scheduling
  • Service migration
  • Core hotplug (cores discovered at boot are fixed for system lifetime)

10. Memory Model

10.1 Isolation

Each service has a separate virtual address space established at spawn time via per-task page tables.

10.2 Limits

[resources.memory]
request = "32MiB"   # minimum needed to start
limit   = "64MiB"   # maximum permitted

10.3 Enforcement

  Service memory action
    │
    ├── alloc within limit ─────────────▶  Memory returned  ──▶  Continue
    │
    ├── alloc beyond limit ─────────────▶  AllocDenied  ──▶  Service continues (may degrade)
    │
    └── access outside mapped region ──▶  Page fault  ──▶  Service killed
                                                                  │
                                                    Supervisor decides restart

10.4 Two Failure Modes

AllocDenied is recoverable. The service knows it asked for too much and can degrade.

A protection violation is unrecoverable. The service is in undefined state and the only safe response is termination.

10.5 TLB Coherence

When a page is unmapped (service killed, memory reclaimed), the kernel issues a TLB shootdown via IPI to every core. Operations resume only after acknowledgment from all cores. v1 minimizes unmap frequency by reclaiming memory only at service death.

Amendment 2026-10-10 (backlog/fixed/80 K20): this section names the guarantee, not a mechanism, and the mechanism it named had not run for some time. The paragraph above says the kernel "issues a TLB shootdown via IPI to every core" and waits for acknowledgments. It does not. The broadcast that did that had no caller, and has been deleted. What the kernel guarantees, and has guaranteed all along, is this:

No core can use a translation into a frame after that frame has been freed.

  • Reclamation waits for quiescence. A dead task's stack and page tables are not freed until no core is running it or still leaving it (core_still_using, CORE_LEAVING in task/scheduler.rs). Nothing is freed while a task can still execute.
  • Every switch invalidates on the core that switches. x86 writes CR3 (no PCID), ARMv7 issues TLBIALL when TTBR0 changes, RISC-V issues sfence.vma after writing satp, and AArch64 flushes on every switch. A core that ran the dead task has dropped its entries before it stops counting as "leaving" it.
  • Where an idle core keeps a dead root installed, the free invalidates everywhere. On AArch64 an idle core keeps the last task's root in TTBR0, and the CPU may walk it speculatively, so free_page_table_root issues an inner-shareable invalidate (tlbi vmalle1is) before the root goes back to the allocator. The other ports switch an idle core to the kernel's own root.
  • Nothing changes a mapping under a running task. User mappings are only added while a task lives; the kernel-stack guard pages are unmapped on the boot core before any other core starts; the Pi 4 framebuffer remap is boot-only and invalidates inner-shareable anyway.
  • No address-space identifier is in use on any port (no PCID, ASID 0 throughout). A port that adopts one must not reuse it before the invalidation that makes the reuse safe.

What this does NOT change. Translation coherence is still a kernel responsibility, under memory isolation and SMP routing (4.3); no responsibility is added or removed, and no guarantee is weakened. Removing the broadcast removed no local invalidation. If a future feature changes a live mapping - a remap, a permission downgrade, an unmap a running task can observe - it brings the cross-core invalidation it needs with it, and is recorded here; until then that machinery would be speculative code (26.2).

Two residuals, recorded rather than closed (26.7). Both concern speculation only; the kernel makes no access through either window. (a) On every port a task that kills ITSELF still has its root installed while its lower tables are freed (the root itself is deferred, CORE_PENDING_PML4). (b) An AArch64 idle core walks a root whose lower tables have been freed, until free_page_table_root's invalidate. Whether wrong-path speculation through a recycled table frame can have a visible effect - setting an accessed bit, or a speculative read of a device register through a garbage descriptor - is not checked. The smallest hardening for both, should it ever be needed, is to switch the core to the kernel root before reclaim.

Pinned by P7, which no longer stands in for this property with a generation count. A task stamps a pattern into fresh pages and is killed - from another core on even cycles, by its own fault on odd ones - and a checker given the same addresses, on the same core and on another, must read only the zeroes the kernel hands out (no_stale_translation_after_death, 32 cycles). Shown to fail when the checker is broken and when the kill path skips both its wait and its wake. Its limit, recorded: a kill path that skips ONLY the wait still passes, because the wake stops the victim before its frames are reused. The per-port proof is recorded in backlog/fixed/80 K20.


11. Bootstrap Sequence

11.1 Sequence

  Limine (Bootloader)
    ── loads kernel image, hands off to BSP
    ── supplies: physical memory map, framebuffer, SMP topology, HHDM

  Kernel BSP
    ── paging, IDT, GDT
    ── frame allocator, capability subsystem
    ── start APs (real-mode trampoline → long mode)
         APs: enter idle scheduler loop
    ── mark all available cores ready
    ── spawn supervisor on Core 0   (the kernel's ONE direct spawn - no init)

  supervisor (Core 0)
    ── spawn events on Core 0
    ── read boot manifest
    ── spawn services per placement policy (§9.2),
       wiring each from its name→cap map (no kernel name resolution)

  ── System reaches multi-core steady state ──

Amendment 2026-06-21 (naming Phases 3b-5): the kernel spawns the supervisor directly; init is removed; the registry service is retired. Moving name→endpoint resolution out of the kernel (docs/naming-design.md, Path C §3.7) makes the supervisor the name authority. Phase 3b moved the name service under the supervisor; Phase 4 then retired the registry service entirely (the kernel's minimal name → endpoint recovery directory - ipc::names + the gated AcquireSendCap - is the namer now; the supervisor wires every service from its name → cap map and clients reacquire by name through the directory). Phase 5 removes init: the kernel's one direct spawn is the supervisor (task::spawn_supervisor, using the gated SUPERVISOR_ELF), and the supervisor - not init - spawns the logger. The supervisor's boot-time spawn failure is still fatal (the kernel panics, "supervisor spawn failed", §11.3, §6.2) - now from the kernel's direct spawn rather than an init abort. The non-restartable set is now just supervisor + kernel.

Amendment 2026-09-12 (multi-architecture): the sequence above is the x86-64 one, and THERE IS MORE THAN ONE ENTRY PATH. kernel_main is called only by x86-64's _start. The ARM, AArch64 and RISC-V ports bring the machine up themselves (arm_boot_main, aarch64_boot_main, and the RISC-V S-mode entry) and then join the same neutral kernel. The consequence is a real trap rather than a pedantic one: anything added to the neutral boot path runs on x86 alone, silently, and a port that never reaches it shows no error at all. Limine, the HHDM and APIC-supplied SMP topology are x86-64 facts; the others discover memory and harts from a device tree and start secondary cores through their own mechanism (PSCI on ARM, SBI HSM on RISC-V). Everything §11 says about WHAT must be true at the end of boot holds on every port; only the route differs.

The bootloader is Limine, accessed via the Limine Boot Protocol. Limine is responsible for loading the kernel image, supplying the physical memory map, the framebuffer descriptor, kernel relocation info, and the SMP topology (APIC IDs of all available cores). See Appendix A for the bootloader rationale and installation story.

11.2 BSP and APs

  • BSP (Bootstrap Processor) - the first core to execute kernel code. Responsible for kernel init and bringing APs online.
  • APs (Application Processors) - secondary cores. Brought up via real-mode trampoline (arch/x86_64/ap_boot.rs), then jump to long-mode kernel code, then enter idle.

Because Limine supplies APIC IDs directly, the kernel does not need to probe ACPI/MADT for SMP topology. This removes a non-trivial subsystem from arch/x86_64/ap_boot.rs.

11.3 Failure During Bootstrap

Failing componentEffect
BootloaderHardware reset
Kernel BSP initKernel panic, halt
AP startupKernel logs warning, continues with available cores; if zero APs come up, system runs as single-core
supervisor spawn (by the kernel - the one direct spawn, no init)Kernel panic, halt (TCB) - "supervisor spawn failed"
events spawn (by the supervisor)Supervisor logs to kernel ring buffer, retry once
Application svcSupervisor logs, may retry per policy
Service contracted to unavailable coreSpawn rejected with PlacementInvalid; supervisor logs and skips; system runs without that service

11.4 Logging Before Any Service Exists

The kernel maintains a 16 KiB ring buffer (a single shared sink). Anything logged writes to the ring buffer and the serial console - always, not only before a service is up. Nothing drains it, deliberately: drain_to_events has zero callers and kernel/src/log.rs says so in its first line. (This body previously said "per-core view" - there is one static RING - and that events "drains the accumulated buffer once, at its own startup". It never has; backlog/fixed/06 is open, and the 2026-09-04 amendment below establishes non-drainage as the design.)

Amendment 2026-09-04 (logger -> events): the service is renamed, and this section's title was wrong in a second way that matters more than the name. The title read "Logging Before Logger Exists", which implies logging is something the logger service does once it is up, and that the kernel ring is a stand-in until then. It is not, and never was. ctx.log() is syscall 5, gated by the log_write capability, writing the kernel ring and serial DIRECTLY - always, for every service, for the life of the machine. No log line has ever been sent to that service; its contract declares only ipc_receive, and its endpoint is drained with unrecognised messages dropped.

The consequence is a property worth naming, because it is easy to destroy by tidying: logging does not depend on the service being up. A chaos storm that kills it loses no log output, and ctx.log() never blocks on it and never returns EndpointDead from it. So logs must NOT be re-pointed at events as it grows - that would make observing a failure depend on a service that can fail, which is the same argument §15 makes about storage, one layer up. Traces and metrics ride IPC to the service; logs stay on the floor.

The rename itself is the name catching up with the job. The service has held the 192-event IPC trace ring since trace shipped, and has never held a log. events rather than observability: observe already exists as the live view, house style is one short word, and a service named after a property of a system becomes the dumping ground §4.4 and §26.2 exist to prevent. It is cheap because the capability model works - ctx.log() resolves through log_write_slot and the trace path through a single SINK_NAME constant, so no service names its own sink.

What is NOT renamed, deliberately: log_write, log_write_slot, ctx.log(), syscall 5 and kernel/src/log.rs. Those are this section's floor, which is a separate path the service was never on. §22 Test 2's assertions are renamed log_received / log_did_not_receive for the same reason - they check that a log reached the floor, which is what they always checked.

No kernel responsibility is added or removed. The kernel was edited (hard-coded service-name lists in task/scheduler.rs and build.rs), which is re-verification rather than growth. Design note and evidence: docs/observability.md §1 and §9.

Where the machine's only output device is a display, the floor also includes a minimal framebuffer blit (kernel/src/bootcon): plain ASCII, escape sequences discarded, no character grid, no cursor, no scrollback. It is not a terminal - the terminal is the console service (docs/console-service.md §9).

Amendment 2026-08-17 (console service): the log floor covers a minimal framebuffer blit, because §11.4 was x86-shaped. This section said "serial console" because on a PC serial is always there. On a Raspberry Pi wired to a TV it may not be, and a kernel that can only speak to a serial port it does not have is mute - a boot that fails before any service exists, or a panic, produces a frozen screen and no reason on it, which is exactly the silent failure invariant 12 forbids.

The blit earns ring-0 residency by impossibility, which is the only thing that does (the bar the control channel failed to clear and the supervisor spawn clears): a panic halts every core, including the console service, so it cannot ask a service to report it. Boot output has the same shape - it precedes every service, including the one that would render it. Both are this section's existing ring-buffer argument applied to a machine with no serial port, which is why the answer is the same one: a bounded floor the kernel owns.

The amendment is deliberately narrow, and the narrowness is the point. What left the kernel in the same change was 1,172 lines of terminal emulation - the ANSI/CSI state machine, the UTF-8 decoder, the shadow grid, the cursor, scrolling, reverse video - because rendering a shell prompt is policy (§26.10) and a display driver is §4.4 anti-scope by name. What the floor keeps cannot format, cannot position, and cannot scroll: reaching the bottom of the screen clears it and starts again at the top, since serial holds the full history either way. Ownership of the framebuffer transfers to the service when the kernel GRANTS it, at spawn - not when the service first renders - and returns to the kernel if that service dies, or on a panic.

§22 has no new test: the property this pins is a negative one on a machine we cannot fail on demand in QEMU. It is pinned instead by scripts/commandments.py, which counts the kernel's modules against §4.3 and for which fbcon was the last standing violation.

Amendment 2026-10-08 (events log boot): the floor keeps a fixed copy of the BOOT, and it is readable. The ring above wraps, and nothing reads it - so on a busy machine the boot was gone from the kernel within seconds, and the only record of what a machine found as it came up was a serial cable that is often not attached. The kernel already held every one of those bytes and had no way to give them back.

Beside the ring, log.rs now keeps the first 32 KiB ever logged, which fills once and then never changes. InspectKernel query 27 copies a range of it out, INTROSPECT-gated, because the boot log names every service and device on the machine; the shell shows it as events log boot. A full record says so, and that later lines are on serial.

What this does NOT change is the point of the amendment above. The record is read by COPY, never drained, so no log line depends on a reader being up, and ctx.log() is exactly what it was. It is the floor this section already defines, made readable - not a log service in the kernel. The kernel does not parse a line, keep a line count, or know which lines matter: it keeps bytes in order and stops when the array is full. No responsibility is added (it is 4.3's memory and the existing 11.4 ring); the new query is pinned in COMMANDMENTS.baseline.toml because a query is surface. The cost is 32 KiB of kernel memory on every port.


12. Drivers and Interrupts

12.1 Model

Kernel routes interrupts. Drivers are user-space services. block-driver is restartable like any other service (§6.1, Phase D amendment 2026-06-17) - it was "trusted in v1" until fs gained crash-consistent recovery.

12.2 Routing

  Hardware IRQ N (arrives on some core)
    │
    ▼
  Kernel IDT handles interrupt
    │
    ▼
  Interrupt Router dispatches by IRQ number
    │
    ▼  IPC message to driver's interrupt endpoint
  Driver Service
    ── recv() returns interrupt event
    ── handles device via MMIO capability

If the driver runs on a different core than the one receiving the IRQ, routing crosses cores via the same IPC mechanism as any other cross-core send.

12.3 Driver Capabilities

[capabilities]
hw_interrupt = [12]                       # IRQ line
hw_mmio      = ["0xfee00000+0x1000"]      # MMIO region

The kernel validates these at spawn time and grants caps only for the specified resources.

Amendment 2026-10-01 (DevicePower, syscall 54): the device grant is RENEWABLE - a service granted a device's window may ask the kernel to cut and restore that device's power. This is not a seventh responsibility. The grant above already includes power: on the Pi 4 the kernel powers the SD domain through the firmware mailbox at boot BEFORE it can hand wifi-driver the radio's window, because a window to an unpowered device is not a grant. What the kernel could not do was renew it, and the CYW43455 made that matter: six host-side resets - I/O reset, core halt, 802.11 reset, chipcommon and PMU watchdogs, a RAM clear - each left a chip whose ROM will not boot a new firmware (docs/wifi.md 45). Only cutting its power is a power-on, and every reference driver's recovery path cuts power.

What the kernel learns, and what it does not. It mints DEVICE_POWER at spawn to the one service it hands a fixed peripheral window to, where the arch layer can power the device behind it (arch::imp::device_power_control) - derived from the grant, not from a privilege bit the spawner passes, and never to anyone else, because a window has exactly one holder. DevicePower(on) checks that holding and drives the pin the arch layer names for the CALLER'S device (device_power): on the Pi 4, WL_ON on the firmware's GPIO expander, through the mailbox SET_GPIO_STATE tag - the pin Linux's mmc-pwrseq-simple toggles for the same chip. The kernel does not know what the device is, what firmware it runs, whether it is alive, or when its power should be cut: those are the driver's (§26.10), and the hold-off and settle times are the device's and live in the driver with it. Every other port answers the seam with false, and the syscall reports that honestly rather than pretending. (Note 2026-10-08: the VisionFive 2 Lite answers it too - its AIC8800's power pin, by device kind WIFI_SDIO, result by read-back, arch/riscv64. Every port but those two answers false.)

Why it is recorded. A new syscall and a new resource widen the surface Commandment I pins, and the gate refused this change until this paragraph existed - which is the gate working. The service that uses it does so as its LAST resort, after adopting the firmware it finds running (docs/wifi.md 46); the power cycle is for a firmware that has stopped, and it is what turns "reboot the machine" into "the driver recovers in a few seconds" (docs/wifi.md 47). It is also the first brick of a shutdown: the grants run in reverse, with power the last thing taken.

Amendment 2026-10-01 (later still): the paragraph two above overstates what was shown. It says six host-side resets "each left a chip whose ROM will not boot a new firmware" and that "only cutting its power is a power-on". Every one of those resets, and every warm power-up after them, ran with the Arm cores at their minimum clock - the Pi firmware drops them a minute after boot when no cpufreq sets one - and with the cores held at turbo (force_turbo=1, docs/wifi.md 55) every power cycle comes up cold. So the power cut is shown to WORK, not shown to be the only thing that would; whether the resets alone recover a fast host's chip is open (docs/wifi.md 56). The grant itself stands on its own: cutting a device's power is still the one recovery that cannot depend on the device's cooperation.

Amendment 2026-10-01 (CpuClock, syscall 55): the kernel sets the Arm clock to one of the firmware's two ends, and a SERVICE decides which. The Pi firmware owns the Arm clock and, with no OS asking it for a rate, drops the cores to their minimum a minute after boot and leaves them there. The CYW43455's firmware traps when it is uploaded that slowly (docs/wifi.md 55), so something has to be able to ask for speed. The only way to ask is the firmware mailbox, which the kernel already owns - the SD power, the GPIO expander and the radio's power cut all go through it - and a channel with one owner cannot be handed to a service as well.

What the kernel learns, and what it does not. CpuClock(max) sets the clock to the firmware's own GET_MIN_CLOCK_RATE or GET_MAX_CLOCK_RATE and returns what it reads back. The caller cannot name a rate. It is gated by CPU_CLOCK (resource 18), a privilege bit the supervisor may delegate and grants to ONE service, power, because the clock is one machine-wide setting and a second holder would silently overwrite the first. Who may be fast, for how long, and when to go back are power's: a LEASE of at most 30 s, the clock at its maximum while any is open and its minimum otherwise, and a lease nobody releases expiring on its own, so a holder that dies cannot pin the machine fast (docs/power.md 15, 26.10). Every port but the Pi 4 answers the seam with None and the syscall says so.

Why it is recorded. A new syscall, a new resource and a new privilege bit widen the surface Commandment I pins. This is mechanism on a channel the kernel already owns, not a seventh responsibility

  • the same argument as DevicePower above - and the power service is where the policy, and any future power policy, lives.

Amendment 2026-10-01 (later): the result follows the pin's READ-BACK. DevicePower used to report success whenever the firmware accepted the SET_GPIO_STATE request, which it does whether or not the pin moved - and in QEMU, where the expander is not emulated, a cut that never happened was reported as done. It now returns success only when WL_ON reads back at the level asked for. The spawn also logs BT_ON, the same chip's Bluetooth enable: it reads 0 on this board, so WL_ON really is the whole of the radio's power and nothing else needs cutting (docs/wifi.md 53). No syscall, resource or authority changes.

Amendment 2026-10-03 (audio on the Pis): a grant may include routing the device's pins and starting its clock, and the Pis' DMA page is granted whole. The Pis' 3.5 mm jack is two PWM channels fed by the SoC's DMA engine (docs/audio.md, "The Pis"). Two of the steps that make it usable live in SHARED blocks: every pin's function in the GPIO page, every clock in the clock manager's. So the kernel does them as part of the grant, at spawn, exactly as it powers the SD domain before granting the radio's window - routes the jack's two pins to the PWM and starts the PWM clock at a fixed rate - and the driver (pwm-audio) is granted the PWM page and the DMA engine's page, mapped side by side, and a DMA arena. The device is a new kind (HwClass::AudioPwm), present where the arch says so (fixed_device_present, renamed from audio_pwm_present by the amendment below).

What this is not. No syscall, no privilege bit, no runtime role: the kernel acts once at spawn and holds no policy - the sample rate (the PWM's range), the volume and when to play are the driver's (26.10). Not a seventh responsibility: it is the grant made usable, the reasoning of the DevicePower amendment above.

What it costs, recorded rather than hidden (26.7). The DMA engine's page holds all fifteen channels and their shared status, so the driver is granted more than the one channel it uses. It is no more REACH than it already has - on these boards no IOMMU confines any DMA-capable driver (6.4), so a driver that can point one channel anywhere can already reach all of memory - but it is more than the grant names. The alternative, a kernel-mediated "start this channel" syscall, would grow the kernel to narrow a grant that confers nothing new, and was declined for that reason. The kernel also learns one more service name (pwm-audio, in the Pis' fixed-window table and the two restart lists), the existing name-keyed practice that step D's classes are replacing. [Superseded the same day by the amendment below: the kernel learns no service name for audio, or for any other driver.]

Amendment 2026-10-03 (later): the kernel knows ONE service by name, the supervisor, and nothing it grants or reports is keyed on a name any more. The amendment above recorded the kernel learning pwm-audio as "the existing practice". The operator asked whether that was normal, and it was not: it was debt, and docs/service-ownership.md had already said why it cannot be enforced - once the supervisor supplies the images, any image started under a matching name inherits whatever a name-keyed table grants that name. Six decisions moved off the name:

  • Fixed device windows (the Pis' SoC blocks) are granted by the device KIND the spawn request names (arch::imp::map_fixed_device(pt, kind), fixed_device_present(kind)), the same trust as a PCI class code. The Pi 4 radio gained the kind it lacked, WIFI_SDIO.
  • DevicePower reaches the pin of the kind the caller was GRANTED, not of the name it has.
  • Death notification and the restart count follow SPAWN_FLAG_WATCHED, set by the supervisor from its MANAGED roster. The kernel's two lists of nineteen names are gone, and with them the drift that once left time and control uncounted; V-managed-watched checks the chain that replaced them.
  • The display is reclaimed from, and console output delivered to, the task granted the FRAMEBUFFER kind, not whatever is called console.
  • Two grants were removed because nothing used them: the Pi 2 block-driver's EMMC window (its disk is USB; the EMMC is the boot card) and a name-matched console_push mint that matched nothing.

No responsibility moves and no authority widens: every change narrows who a grant reaches, from "anything with this name" to "the request that names this device". One spawn flag is added to a request the kernel already validates. The name the kernel still knows is supervisor, because the kernel spawns and respawns it (6.2). docs/audio.md, "No service names in the kernel".

Amendment 2026-10-14 (PowerOff, syscall 56): the kernel turns the machine off, now or at the ONE deadline it holds, and the OPERATOR decides when. reboot resets the machine through a channel the kernel owns on every port; turning it off uses the same channels - the Pis' power-management watchdog with the boot partition set to 63 (halt) instead of cleared, the RISC-V firmware's SRST call with the shutdown type instead of a reboot, and on x86 ACPI soft-off (S5), read from the FADT and the DSDT's \_S5 package in the tables the kernel already walks for the IOMMU. Each port answers one new seam member, power_off, which returns only when the hardware did not turn off, with the reason; then every core halts through halt_all_cores and the last line says it is safe to switch off. A halt asked for is not a crash and is not printed as one.

The deadline, and why the kernel holds it. PowerOff turns the machine off now, or sets the machine's one pending power-off up to 24 h ahead, or cancels it. The kernel holds that deadline on the scheduler's existing tick and fires it whether or not any service is alive, because a shutdown must not be lost with the services that asked for it or count it down (the operator ruled a lost shutdown unacceptable, docs/shutdown.md 4.4). It is the kernel's own scheduled action, not service state (15): no service's data is kept, nothing is keyed by name, and a reboot clears it. Earliest wins - a holder may bring a shutdown forward, never push one back - any holder may cancel, every change is logged with the caller, and the time is monotonic, never the wall clock.

Who holds it. POWER_OFF (resource 19) is a privilege bit the supervisor may delegate, granted to the SHELL alone, beside REBOOT: ending the session is the operator's, and no service has a reason to end it - a failing service is restarted, never the machine. power holds no shutdown authority; it reads the deadline, warns and does a bounded tidy-up three seconds before it. The Commandments baseline lists the shell as the one holder, so a second (a power button) is a recorded decision. A holder acts only on its own inputs and never offers shutdown-on-request to other services, which would reopen an anonymous path one level up; the keyboard residual of SEC-2 (6.4) applies, since anything that can type can type shutdown.

Visible, deliberately. InspectKernel query 28 (the seconds left, or -1) is UNGATED, deliberately, because a deadline that outlives every service must be seen by everything that might report it, and a shutdown planted by a compromised holder must never be hidden. It discloses no task's state and confers no authority.

What it does not do, recorded rather than hidden (26.7). ACPI asks the OS to run the firmware's \_PTS method before S5, which is bytecode this kernel does not interpret; a machine that gates S5 on it - the HP T630 - halts instead of powering off, and says so. The Pi 2 cannot cut its own 5 V and halts; the Pi 4's firmware cuts its rails only where its EEPROM sets POWER_OFF_ON_HALT=1; the VisionFive's firmware cannot reach its power chip and stops inside the call, so the kernel says what to do before making it. Hardware-verified on all five boards, 2026-10-14: the Wyse powers off and the rest halt, each saying which (docs/shutdown.md 5).

Why it is recorded. A new syscall with three operations, a query, a resource and a seam member widen the surface Commandment I pins. It is mechanism on channels the kernel already owns - reboot's sibling, and one more timed action - not a seventh responsibility, the argument the DevicePower and CpuClock amendments above make. MISCIS is unchanged.

Amendment 2026-10-15 (reboot on the deadline, S6): the one deadline also holds a REBOOT. The operator asked that reboot take shutdown's forms - reboot counting down from 3, reboot now, reboot 10m. A delayed reboot has exactly the problem a delayed shutdown had: held in a service, it dies with the service. So it rides the same deadline. PowerOff gains two ops, 3 (reboot in secs) and 4 (cancel it), gated by REBOOT rather than POWER_OFF - the right that already resets the machine, held by the shell alone (SEC-2) - and the deadline is one word holding the tick and which of the two falls due, so a set, a cancel and the tick each see both together. A reboot that falls due resets through hardware_reset, the path Reboot (18) already takes.

There is still ONE deadline, and it holds one action. While a shutdown is pending a reboot is refused, and the other way round, so neither can quietly replace the other and a shutdown can never be lost to a reboot that happened to be asked for sooner. Earliest wins within a kind, as before. Query 28 now answers secs * 2 + kind (1 for a reboot), still ungated, so what is pending and when are one answer that cannot disagree with itself.

What it does not add. No syscall, no resource, no privilege bit, no seam member: two ops on a syscall this section already records, gated by a right the shell already holds, firing a reset the kernel already performs on its existing tick. Not a seventh responsibility, for the reason the amendment above gives; MISCIS is unchanged. What grows is surface - two ops and a query's encoding - recorded here because Commandment I pins surface and the gate counts syscalls and queries, not ops, so it would not have refused this. reboot now and Ctrl+Alt+Del stay the immediate Reboot (18). QEMU-verified 2026-10-15 (osdev test shutdown, a reboot falling due and the machine back at a prompt); hardware-verified on all five boards the same day. The Pi 4's first card cut its last line on reset; the UART wait that fixed it is in arch/, and the Pi 2 and the Pi 4's re-card show the lines whole.


13. Service Contracts

13.1 Format

name    = "ping"
version = "0.1.0"

[resources.memory]
request = "32MiB"
limit   = "64MiB"

[capabilities]
ipc_send    = ["pong"]
ipc_receive = ["ping"]
log_write   = true

[placement]
core = 0    # optional; if omitted, supervisor uses round-robin

13.2 Placement Field (Strict)

  • Omitted → supervisor places via round-robin across available cores.
  • Specified → supervisor requires that exact core. If unavailable, spawn is rejected with PlacementInvalid.

Strict semantics: A contract that names a core is a deployment-intent statement. The supervisor will not silently reroute to a different core. If placement.core = 2 and core 2 didn't come up, the service does not start; the supervisor logs the rejection and continues. Contract authors should specify placement only when they have a real reason.

13.3 Request, Not Permission

The developer declares what the service needs. The OS decides whether to grant it.

13.4 Build-Time Validation

osdev validates contracts using JSON Schema applied to the TOML structure.

Guarantees: correct structure, valid capability names, valid resource declarations, valid core IDs (range check only), required fields present.

Non-guarantees: behavioral correctness, that the binary uses only declared caps, that limits are reasonable, that the requested core will be available at spawn time.

Build-time validation is structural, not behavioral. Behavioral enforcement is runtime-only in v1.

13.5 Schema

contracts/schema/service.schema.json. Versioned. Breaking changes require a major version bump and a documented migration.

13.6 Runtime Enforcement

Every syscall checks the calling task's capability table, populated at spawn time from the spawn request. The kernel does not consult the contract at runtime, and never has.

Amendment 2026-09-14: the capability table is populated from the SPAWN REQUEST, not from the contract, and this section said otherwise for long enough that it led a contributor into a change that compiles, validates, passes every checker, and does nothing.

What actually grants authority. The supervisor holds each service's image and sends the kernel a spawn request carrying its privilege word, send peers, memory limit, core and device class; the kernel mints exactly that, after refusing any privilege the supervisor does not itself hold (SUPERVISOR_DELEGATABLE, privileges_caller_lacks) - which is what keeps delegation from being ambient authority (§3.1). The kernel's own service_config table covers its ONE direct spawn, the supervisor (§11.1). Those two are the whole of it.

The kernel cannot read a contract. It is no_std and cannot parse TOML at spawn. That is not a new fact and not an implementation shortcut - scripts/contract_check.py has opened with it since it was written, and that script exists PRECISELY because the .toml is a second, human-facing declaration that can drift from the first.

So what is a contract? A build-time declaration: schema-validated by osdev validate (§13.4), and reconciled against what is really granted by contract_check.py (memory limit, placement, send peers) and by IV-contract-authority in scripts/commandments.py (the authority capabilities). It is the reviewable statement of what a service may do (§26.9). It is not the mechanism.

Why this was worth amending rather than leaving as a near-enough shorthand. A weak model was given "let recorder restart a service it sees stop responding", with no mention of any checker. It added service_control = true to recorder's contract and reported that the kernel would grant it at spawn - which is what this section and §14.1 both told it. The change compiled, osdev validate passed it, and all twelve checkers stayed silent. It does not work: recorder's privilege word is 0, every call is denied, and the denial is discarded twice (the SDK's restart swallows its own kill error and the caller wrote let _ = ...) while the log line printed BEFORE the call asserts a restart that never happened. A service that looks authorised on paper, cannot act, and says it did.

And it had been caught before. audits/userspace-audit.md flagged exactly this in 2026-07-12 - quoting this section's own words back at it, that "the contract stops being the authority's source of truth" - and marked it RESOLVED. The resolution changed the CODE and left the constitution saying the wrong thing, which is how a corrected defect goes on causing the error it was corrected for. Four other documents repeated the claim and are fixed in the same change.

That is this document leading someone into a silent failure, which is invariant 12 broken at the documentation layer rather than the kernel one. The gate that now catches it is IV-contract-authority; this amendment removes the reason it would be needed a second time.


14. Service Lifecycle

14.1 Spawn

  1. Supervisor takes the service's image and its row in the spawn table (the contract was validated at BUILD time, §13.4 - nothing parses TOML here).
  2. Determines core placement (round-robin or contract override per §9.2; rejected if contracted core unavailable).
  3. Asks the kernel to create a new task on that core, in a spawn request carrying the privilege word, send peers, memory limit and device class.
  4. Kernel refuses any privilege the supervisor does not itself hold (SUPERVISOR_DELEGATABLE), so a spawner passes on what it has and never mints.
  5. Kernel mints capabilities per the spawn request (each tagged with current resource generation).
  6. Kernel maps service binary into new address space.
  7. Service registers any owned endpoints in the kernel name directory (ipc::names).
  8. Service enters main loop on its assigned core.
  Supervisor ─── spawn(name, request) ───▶  Kernel
                                               │
                              resolve placement (§9.2):
                              contract core or round-robin
                                               │
                        Err(PlacementInvalid) ◀─┤  (core unavailable: stop here)
                                               │
                              allocate Task + CapTable from the request
                              map binary into new address space
                              enqueue task on target core run queue
                                               │
  Supervisor ◀── Ok(task_id) ────────────────┘
                                               │
  New Service:                      enters execution
    ── registers endpoint in the kernel name directory
    ── service_main() ── enters work loop

Amendment 2026-09-26 (step C): step 1 above is only true because the kernel now accepts an IMAGE from userspace, and that widening was never recorded here. When the supervisor took ownership of every service image (docs/service-ownership.md, step C), the kernel stopped holding the bytes it spawns. Its catalogue is supervisor alone. So a spawner can no longer ask for "the image you already have" - it must SUPPLY one, and SpawnImage (syscall 52) is the syscall that accepts it.

Starting arbitrary bytes is a different authority from starting a known service, and it is now a different capability. SPAWN and image-spawn were the same right until step C, which meant every SPAWN holder - the shell, chaos, control, every probe - could introduce NEW CODE under a real service's name in the window while that service was dead, and a client reacquiring that name (§14.3) would wire itself to it. SpawnImage therefore requires IMAGE_SPAWN on top of SPAWN, and the kernel refuses the call when a caller holds only the latter.

IMAGE_SPAWN is deliberately NOT delegatable. It is absent from SUPERVISOR_DELEGATABLE, so the supervisor cannot pass it on even to a service it trusts - a delegatable version would re-open the hole one grant later. The only two callers in the tree are the supervisor's own spawn_by_image and spawn_probe_row; everything else asks the supervisor over IPC. It stops the authority leaking to the dozen services that merely wanted to start something.

What the supervisor GAINED, said plainly, because this is a real widening and not a re-labelling. Before step C the kernel held every image, so a runtime-compromised supervisor could start only code the kernel already carried - it could start the wrong service, or the right one too often, but it could not introduce code that was not in the image. After step C it can. A compromised supervisor supplies the bytes, so it can run anything under any name, and nothing before step 2 prevents that - step 2 (a signature over each image, docs/service-ownership.md) exists precisely to close it. This does not move the TCB: the supervisor is the trusted root (§6.1) and its compromise was always systemic. It does mean the CONSEQUENCE of that compromise is strictly larger than it was, and per §26.7 a limitation that cannot be closed today is recorded rather than left for a reader to discover. The IRQ-routing half of the same concern IS closed: the kernel refuses raw vectors and raw MMIO outright, as the paragraph below describes.

What the kernel refuses, so that "supplies an image" is not "supplies a machine". The request is a fixed, versioned struct, and the kernel rejects a wrong version, a wrong size, an unreadable buffer, a name outside 1..=64 bytes, a dma_pages over its cap, an unknown device class, a BDF on a non-PCI class, any privilege the caller does not hold itself (privileges_caller_lacks), and - the two that matter most - raw MMIO addresses and raw interrupt vectors. A driver names a device CLASS and the kernel resolves the window, the arena, the BDF and the vector against its own bus scan (§12.3). A caller cannot hand the kernel an address and be given it.

Why this is recorded rather than merely implemented. It is a new kernel authority and a widened syscall surface, which Commandment I pins and §26.9 requires be traceable to the capability that granted it. The code says all of this at kernel/src/capability/mod.rs; the constitution said none of it, and docs/service-ownership.md has carried "the CLAUDE.md amendment for step C must state the widening" as an open item since. No invariant moves: authority is still explicit (§3.3) and the kernel still holds no policy - it validates a request and mints exactly what the request asks for and the spawner may give (§13.6, §26.10). The net effect on authority points BOTH ways and it is worth being exact about which: narrower for the dozen SPAWN holders, who lost the ability to introduce code at all, and wider for the supervisor alone, as the paragraph above records.

Amendment 2026-10-17 (install): the supervisor starts an image READ FROM THE DISK, for an installed package, and only after checking its signature. Step 1 above holds for every built-in service; an installed package (godspeed install, utilities/61_godspeed.md 4b) is the one exception, and it is the step-2 shape the amendment above names. The supervisor reads a fetched package from /system/packages/, checks it with the code godspeed verify runs - the publisher's signature over its manifest, the signing key's standing in this machine's trust record, the contract and this machine's binary against the manifest's digests - and hands the kernel the very bytes it hashed, through SpawnImage as for any other image (services/supervisor/src/installed.rs). It does so at install, at every boot and at every restart, with nothing cached between.

What an installed package is given, and why it is safe to give: no privilege at all - none is asked for and none can be granted - and send peers only where its signed contract requests them AND the operator approved them, never supervisor. It runs under its package name, publisher/name, which no built-in service can hold, so it can never stand in for one when a client reacquires a name (14.3). Restarts are bounded: five a boot, then it is left down, loudly.

What is NOT closed, recorded rather than hidden (26.7). The signature is checked against the trust record and the grant read from the package's folder, and both are on /system, which every fs holder can write: something that can write there can replace the trust record or widen a grant, up to what the signed contract requests. The narrowing to the contract is what keeps a forged grant from reaching further. IMAGE_SPAWN stays the supervisor's alone and undelegated; godspeed-pm asks over IPC (supcmd::INSTALL). No kernel change, no new syscall, and no responsibility added: the supervisor already starts images and already decides with what.

Amendment 2026-10-17, later (apps): one thing beyond peers, the console, for an app and only with the operator's answer. An installed package of kind app is a utility that owns the screen and the keyboard while it runs (gs::app, docs/developer-services-design.md 13.2, approved by the operator the same day). The supervisor starts it with SPAWN_FLAG_REQ_CONSOLE - the console read capability chaos and the shell already hold - when its grant names console, which godspeed install asks about by name, and refuses to start an app whose grant does not. Still no privilege bit: the console is a spawn flag, and a service or a utility is never given it. The residual is SEC-2's (6.4): a holder of the keyboard can read what is typed while it runs, which is exactly what the operator was asked to approve. The kernel gives the console back when the app dies, so a crash cannot keep it.

Amendment 2026-10-23 (drivers): an installed driver is given ONE DEVICE, by what the bus says it is, and the authority that device carries - which on a machine with no IOMMU puts it inside the TCB (6.4). Not recorded when the first part shipped: v0.29.0 gave a driver package its device's register window and nothing else, v0.30.0 the same, and this section said nothing of it. What a driver package is given now (docs/developer-services-design.md 13.1, services/supervisor/src/installed.rs), each named in its signed contract and approved by the operator:

  • A PCI device, by class (hw_pci_class): the kernel resolves which device from its own bus scan, exactly as for an in-tree driver named by class (12.3), and grants its register window - plus its interrupt when the contract asks (hw_pci_irq; the kernel allocates the vector, the caller names none) and a DMA arena of the size it asks (hw_dma, at most 2 MiB, never an address). Confinement is requested; whether it happens is the kernel's, per device (6.4).
  • A device an in-tree driver drives is TAKEN OVER, not shared: the supervisor stops that driver and keeps it stopped while the package is installed, and starts it again when the package is uninstalled, ends, or is left down. One holder per device, which the kernel does not enforce for a PCI device and the supervisor does.
  • A USB device, by vendor and product (hw_usb): no hardware authority of its own at all. Its host (xhci, dwc2, and ehci since the same day) binds it and serves its transfers to the one driver given that host as a send peer (gs::driver::bus), so the package is the host's client, as wifi-usb always was.

The TCB consequence, stated as 6.4 requires. A driver package granted a DMA arena on a machine whose IOMMU does not confine its device - every Pi, the VisionFive, and any x86 device the kernel leaves in passthrough - can point the device at any address: kernel-equivalent reach, so it is trust-critical by necessity, exactly as an unconfined in-tree driver is. That is why the arena is approved by a SECOND question at install, and why the question is asked on every machine: nothing a service can ask says whether the kernel will confine this device, so the warning is given rather than guessed (a confined machine asks needlessly; recorded, 26.7). A driver package with registers only, or a USB driver, carries no such reach.

What does not change: no privilege bit (the device is a hardware class in the spawn request, and the supervisor already spawns by class), no kernel change and no new syscall, and IMAGE_SPAWN stays the supervisor's alone. MISCIS is unchanged.

14.2 Restart and Cap Rebinding (Possibly Cross-Core)

  Steady state: Service A (Core 0) holds cap to Service B gen=4 (Core 1)

  Step 1 - Supervisor kills B:
    Kernel closes B's endpoints, drains queues
    Kernel bumps B's generation: 4 → 5
    Kernel reclaims B's memory (TLB shootdown on all cores)

  Step 2 - A tries to send via stale cap:
    A ── send(gen=4 cap) ──▶ Kernel ── EndpointDead ──▶ A
                              (gen mismatch: held=4, current=5)

  Step 3 - Supervisor spawns B on Core 2:
    Kernel starts B with fresh cap table (gen=5)
    B re-registers its endpoint in the kernel name directory

  Step 4 - A reacquires:
    A ── AcquireSendCap("B") ──▶ fresh cap (gen=5, Core 2)   [kernel name directory]
    A ── send(gen=5 cap) ──▶ Ok  (routes to Core 2)

Key principle: the new instance may run on a different core than the old one. Clients never know - they see only EndpointDead, reacquire by name through the kernel directory, and resume.

14.3 Cascading Failure

Cascading failure is the client's responsibility. There is no implicit recovery. If A depends on B and B restarts, A's next syscall returns CapRevoked or EndpointDead, and A must retry, degrade, or fail.

Reacquiring the endpoint is necessary but not sufficient. A fresh cap to B's new instance does not refresh anything A obtained from B's previous incarnation. Every secondary thing A still holds that was derived from the dead instance - a socket or connection id, a resource or open-file cap, a generation number, or a cached copy of B's state - is stale: the new B never issued it and does not know it. A must discard or re-establish each of these against the new instance. In particular, A must not keep serving a cached view of B's state as authoritative once B has restarted: that view is a derived copy of B's truth (§26.4, Commandment III), and that truth was just replaced. Reacquire the endpoint, and re-derive everything that hung off the old one.

14.4 Supervisor API

#![allow(unused)]
fn main() {
supervisor.kill(service_name) -> Result<()>
supervisor.restart(service_name, placement_override?: CoreId) -> Result<()>
}

Required capability: service_control (held only by supervisor).

  • kill - immediate. The killed service does not get a chance to clean up. Clean shutdown is a separate codepath.
  • restart - kill followed by spawn. Placement is determined as follows:
    • If placement_override is provided, the supervisor requires that core (subject to the strict rules of §9.2; rejected if the core is unavailable).
    • If placement_override is omitted, the placement decision is re-evaluated from scratch per §9.2 - contract-specified placement re-applies (and may fail with PlacementInvalid if unavailable); unspecified placement gets a fresh round-robin choice.
    • The previous core is not remembered. A service that ran on core 1 before the restart has no implicit affinity for core 1 after.

14.5 Kill Semantics

Kill is for misbehaving services and for restart. It is not a graceful shutdown mechanism.

Amendment 2026-10-17 (Exit, syscall 57): a program may END ITSELF, with a status, and the death notice says so. Until now nothing ended a task but the kill syscall or a fault, so every program was a service that never returns, and a program that does one job - a utility - could not exist outside the shell (docs/stdlib-design.md 1, option B, chosen by the operator). Exit(status) ends the caller by the path a task that kills itself already takes (handle_kill's tail), with a third cause: the notice carries E and the status, beside K (killed by) and F (a fault) (6.2 amendment 2026-10-11).

It takes no capability, and that is deliberate, not an oversight against 21. The rule there - a syscall that does not validate a capability is rejected - exists so that no syscall reaches authority without one. Exit reaches nothing: the one task it can end is the caller, as Yield and Park act on the caller and take none. A program could already end itself by faulting; this makes the same end deliberate and says how.

What the kernel learns, and what it does not. A number, said in the notice. What an exit MEANS is the supervisor's (26.10): an installed package that ends itself is not restarted, because it finished; a service in MANAGED that exits is restarted as any death is, because it is meant to run. The kernel keeps no record. No responsibility is added: ending a task is the scheduler's already (4.3). Pinned in COMMANDMENTS.baseline.toml; one more SDK call site (18.5, exit).


15. State and Persistence

State belongs to services, not the kernel. Services that need to survive restart must persist externally and reconstruct on startup.

The filesystem service is the externalization mechanism for everyone else and cannot persist to itself. Resolution: the block driver holds a direct hardware capability and stores fs metadata. fs gives itself transactional metadata recovery - every mutation commits through a crash-consistent redo-journal and fs recovers to a consistent state on mount (docs/persistence.md §6.8). With that, block-driver and fs are restartable and no longer in the TCB (§6.1 Phase D amendment, 2026-06-17); their death is a supervisor restart, not a reboot. The recovery guarantee is backend-conditional - see the 2026-07-25 amendment in §6.1.

Stateless services (events in v1) restart trivially. Example application services (ping, pong) are also stateless and restart trivially - but they are demonstration services in examples/, not permanent architectural components.


16. Update Model

  Update package
    │
    ├── Signature valid?   No  ──▶  Reject
    │       Yes ↓
    ├── Contract valid?    No  ──▶  Reject
    │       Yes ↓
    └── Policy allows?     No  ──▶  Reject
            Yes ↓
    Supervisor restarts service with new binary

Push vs pull is irrelevant - verification is the security property. Live code update is permanently rejected (§2.5); the only update mechanism is restart-with-new-binary.


17. Developer Workflow

osdev build                         # build kernel + services
osdev run --smp <N>                 # boot in QEMU with N cores
osdev image                         # build bare-metal image → build/os-usb.img (UEFI GPT)
osdev restart <service> [--core N]  # restart in running OS; --core is dev-mode only
osdev logs <service>                # tail logs
osdev test identity                 # run §22 test suite
osdev validate                      # validate every service contract
osdev mkfs / script-disk            # build a filesystem image / a script disk

# NOT IMPLEMENTED - these are `todo!()` stubs that panic if invoked:
#   osdev new <service-name>        # scaffold        (copy examples/00-hello instead)
#   osdev publish                   # package + serve
#   osdev status <service>          # state + core     (use the shell's `status`)
#   osdev caps <service>            # held caps        (use the shell's `caps`)

osdev restart --core N is the CLI surface for the supervisor's placement_override (§14.4). It is rejected outside dev mode and subject to the same strict placement rules as a contract-specified core.

Iteration loop: edit → build → restart → logs. Only the changed service restarts. (publish is in the not-implemented list above, so the loop does not pass through it; §26.2's "not implemented; will be implemented when a test requires it" is the honest state of all four.)


18. Unsafe Policy

18.1 Permitted

kernel/src/arch/, kernel/src/memory/, kernel/src/capability/, kernel/src/smp/.

Plus the SDK's audited hardware/ABI layer: the syscall ABI (raw_syscall, inline asm!) and the MMIO/DMA accessor modules (sdk/rust/src/mmio.rs and sdk/rust/src/dma.rs). Userspace drivers (§12) cannot touch device registers or DMA memory without unsafe; isolating it to these designated SDK modules - each block carrying a // SAFETY: comment - keeps the driver services themselves unsafe-free behind safe wrappers (Mmio::read32, Dma::write32, etc.), exactly as the kernel isolates its unsafe to the four layers above. This is a recognition of what the syscall ABI already required, extended to the hardware access §12 needs.

Amendment 2026-10-17 (install): the SDK's memory.rs is a fourth permitted module, for a task's OWN memory. AllocMem (syscall 6) returns an address, and an address becomes something a service can read and write only through unsafe. Until now nothing outside the tests needed that - every working set fit a stack (26.6.1) - and the one case that did, P7's stamped pages, lives in adversarial.rs. The install loader is the first production case: the supervisor reads a package's image from fs, checks its digest and signature, and hands the kernel those same bytes, and an image is larger than any stack. Region is two blocks (bytes, bytes_mut) over exactly the pages a successful AllocMem mapped, and the supervisor stays unsafe-free behind it, as a driver does behind Mmio.

What it is not: an allocator. There is no free - the kernel reclaims a task's memory at its death (10.5) - so a region is asked for once, its size is the bound, and it is reused. No syscall and no kernel change; the rule that new unsafe lives in a permitted layer (18.5) is met by naming this one.

Plus the SDK's audited adversarial test module (sdk/rust/src/adversarial.rs). The §22 red-team / fuzz / chaos suite must reach the raw ABI with fuzzed syscall numbers and arguments, and must execute deliberate ring-3 faults (a null read, a non-canonical read, a divide-by-zero) to prove the kernel kills the faulting task rather than wedging the machine (invariant 12; A14 / A15 / C2) - things no safe wrapper can otherwise express. Isolating that unsafe to this one designated, SAFETY-commented module (fuzz_syscall, fault_null_read, fault_noncanonical_read, fault_divide_by_zero) keeps the test services themselves (probe) unsafe-free, exactly as the hardware/ABI layer keeps the driver services unsafe-free. This is a recognition of what adversarial testing already required: probe used to hold this unsafe directly - an untracked §18.2 violation the userspace audit (M8) closed. The module is test-only; no production service calls it (a fault primitive just kills the caller, which the kernel handles). scripts/unsafe_check.py now scans services/ and fails on any service unsafe, mechanically enforcing the isolation.

18.2 Forbidden

All userspace services, osdev/, and all of sdk/ except the audited hardware/ABI layer named in §18.1 - and all kernel code outside the four permitted layers. A driver service that writes unsafe directly (rather than going through the SDK's safe Mmio/Dma wrappers) is rejected.

Amendment 2026-09-13: this is enforced by the COMPILER now, not only by a grep. Every crate under services/, examples/ and osdev/ carries #![deny(unsafe_code)], so rustc refuses the crate rather than a script noticing afterwards. That closes what a text scan cannot see - unsafe produced by a macro expansion, or spelled across lines - and it fails at the author's keyboard instead of at the next CI run.

deny rather than forbid, for exactly one reason, stated so nobody "tightens" it and breaks the build: a #[no_mangle] declaration is itself covered by the unsafe_code lint (an exported symbol can collide, which is a soundness hole), and every service needs #[no_mangle] service_main because build.rs links with --entry=service_main. forbid cannot be relaxed even for that. So the crates carry deny plus ONE #[allow(unsafe_code)] on the entry symbol, and scripts/unsafe_check.py asserts both halves: that the attribute is present, and that the only #[allow] sits on #[no_mangle]. An #[allow] anywhere else fails the check - the exception cannot become a door.

No policy changes here. §18.2 already forbade this; what changed is who enforces it.

18.3 Documentation

Every unsafe block carries a SAFETY comment:

#![allow(unused)]
fn main() {
// SAFETY: <argument for why this is sound>
unsafe { ... }
}

A PR with an unsafe block lacking a SAFETY comment is rejected without review.

18.4 Audit Trail

audits/unsafe-audit.md lists every unsafe block. CI checks the file matches source.

18.5 Grandfathered Floors

unsafe outside the four permitted layers (§18.1) is tolerated only as grandfathered lines in task/, syscall/, interrupt/, loader.rs and main.rs, and in the two SDK files named below, frozen at the counts in audits/unsafe-audit.md. Those counts may decrease freely but may increase only by an amendment recorded here and in the audit, with a written safety + necessity rationale.

Amendment 2026-09-12: the SDK's service_context.rs (82) and ipc.rs (8) are recorded as grandfathered floors, and this list gains loader.rs and main.rs, which it had always omitted. Neither is new unsafe - both are the state of the tree being written down for the first time. scripts/unsafe_check.py scanned only kernel/src/ and services/, so the SDK's ~125 unsafe lines were audited by nothing at all while §18.4 claimed CI checks the audit against source (backlog/fixed/18). The scan now covers sdk/, which is what makes these floors enforceable rather than merely stated.

They are one design consequence, not 90 violations. 86 of the 90 are unsafe { raw_syscall(..) } call sites: raw_syscall is an unsafe fn because it issues the trap instruction, so every caller opens a block - and these two files ARE the wrapper layer §18.1 describes, the one that exists so that driver and application services need no unsafe of their own. That worked: services/ is at ZERO, mechanically enforced. The isolation stopped one layer short of itself.

The fix that would actually close this is a SAFE raw_syscall wrapper - the kernel validates every user pointer, so passing integers to a validating callee is sound - which collapses ~86 of them and leaves the SDK at roughly the 35 lines §18.1 sanctions. That is an SDK redesign on every service's call path, so per §26.7 it is recorded here as the known route rather than half-done. Until then the floors may only fall.

New unsafe that a feature or hardening needs must first try to live in a permitted layer (arch/, memory/, capability/, smp/) rather than grow a grandfathered file. A precondition that is merely boot-ordering (violating it wedges boot - a liveness bug, not UB) does not justify an unsafe fn; make it a safe fn with a documented contract, like memory::init / smp::init. Worked example: the H4 kstack-guard / W^X hardening (2026-06-08) was structured so its page-table unsafe lives in arch/ and the boot call sites are safe fns - main.rs and task/mod.rs stayed at their floors with no amendment needed. Four amendments to the grandfathered floors stand; the service_context.rs floor fell to 83 after the first two (2026-10-04, a dead call site removed) and the boot record took it back to 84; it fell to 80 on 2026-10-10, when the four spawn-by-name syscalls (7, 19, 38, 39) were retired with their SDK wrappers (backlog/fixed/80 K14), and to 77 on 2026-10-11, when the three uncalled NET_DEVICE wrappers went (backlog/21); it rose to 79 on 2026-10-14 and to 80 on 2026-10-17 by the amendments below. A fall needs no amendment, so this line only keeps the figure current (audits/unsafe-audit.md):

Amendment 2026-10-17 (Exit): sdk/rust/src/service_context.rs 79 -> 80. One call site, exit: one unsafe { raw_syscall(57, ..) } behind the safe ServiceContext::exit and gs::program::exit, for the 14.5 amendment of the same date. Necessity: a program cannot reach a syscall any other way. Safety: one integer and no pointer, and the syscall does not return.

Amendment 2026-10-14 (PowerOff): sdk/rust/src/service_context.rs 77 -> 79. Two call sites behind the §12.3 amendment of the same date: power_off_call, one unsafe { raw_syscall(56, ..) } behind the three safe wrappers power_off, power_off_in and power_off_cancel; and power_off_pending, one unsafe { raw_syscall(13, 28, ..) }. The same single design consequence as the CpuClock amendment below. Necessity: a service cannot reach a syscall any other way. Safety: integers only, and the kernel checks the capability before acting; query 28 takes no pointer.

Amendment 2026-10-08 (the boot record): sdk/rust/src/service_context.rs 83 -> 84. One more unsafe { raw_syscall(13, 27, ..) } call site, boot_record_query, behind the safe boot_record_size and boot_record_read of the 11.4 amendment of the same date - one block for both, the same single design consequence as the two below. Necessity: a service cannot reach a syscall any other way. Safety: the read's buffer is typed at the kernel's chunk size, and the kernel validates the range before writing into it.

Amendment 2026-10-01 (CpuClock): sdk/rust/src/service_context.rs 83 -> 84. One more unsafe { raw_syscall(55, ..) } call site, the wrapper cpu_clock behind the §12.3 amendment of the same date - the same single design consequence as the DevicePower amendment below. Necessity: a service cannot reach a syscall any other way. Safety: one integer to a kernel that checks the capability before asking the firmware anything.

Amendment 2026-10-01 (DevicePower): sdk/rust/src/service_context.rs 82 -> 83. One more unsafe { raw_syscall(54, ..) } call site, the wrapper device_power behind the §12.3 amendment of the same date. It is the same single design consequence the 2026-09-12 amendment describes - the SDK IS the wrapper layer, so a new syscall is one more block here and zero in any service - and the safe raw_syscall that would collapse all of them remains the recorded route. Necessity: a service cannot reach a syscall any other way. Safety: the kernel validates the capability before touching any pin, and the call passes two integers.


19. Debugging Model

Bugs are classified as one of: kernel, arch, memory, ipc, capability, smp, service, cli.

Bug reports include: logs (kernel ring buffer + service), contract(s), QEMU repro steps including -smp N value, expected behavior, actual behavior, suspected classification.

Kernel panics print the reason (KERNEL PANIC: {info}) to the serial console and the kernel log ring buffer. Panics on any core halt the system: the panic path broadcasts an NMI so every core halts, not only the panicking one (§6.2). A reserved-page mechanism to persist the panic reason across reboot was once described here but was never implemented (and init, which would have re-read it, is removed - Phase 5); the panic reason lives on the serial console only.


20. Performance Philosophy

Ranked: correctness, observability, performance.

The IPC fast path is the exception. In a microkernel, every meaningful operation crosses an IPC boundary. v1 IPC uses simple predictable structures: one memcpy, constant-time cap + generation check, one IPI on cross-core wake.

No vibes-based optimization. Perf claims require benchmarks in tests/qemu/perf/.


21. Contribution Rules

A PR is rejected without further review if it:

  • Introduces global mutable state outside a single owning service.
  • Introduces ambient authority.
  • Bypasses the capability system or generation check.
  • Adds service migration or work stealing.
  • Adds zero-copy IPC.
  • Adds live code update.
  • Introduces silent fallbacks at the kernel boundary.
  • Breaks restartability of a non-TCB service.
  • Adds unsafe code without a SAFETY comment, or outside permitted layers.
  • Adds a syscall that does not validate a capability.
  • Changes the IPC fast path without a benchmark.
  • Edits CLAUDE.md without a rationale in the commit message.
  • Introduces an em-dash (U+2014) or en-dash (U+2013) anywhere - in prose, code, comments, string literals, commit messages, or docs. Only the plain ASCII hyphen (-) is permitted as a dash; box-drawing characters are fine. This is a house writing convention, enforced repo-wide for consistency.
  • Weakens a gate rather than the rule it enforces. Deleting a checker or removing one from EXTRA_CHECKS, widening a path exemption, adding a baseline entry instead of fixing what it found, using a conform-ok marker to go quiet rather than to record an exception, or making a diagnostic vaguer (22.7). The enforcement layer is open to contribution like everything else and a rule may be repealed - by amending this document with a written rationale, so that the law and the gate change together. What is rejected is the version where the law still says one thing and the gate has stopped noticing. CONTRIBUTING.md carries the long form.

Reviewers ask: does this respect the constitution, leave the kernel small, present a convincing unsafe argument, include a test, make the system more or less understandable?

A grounded catalog of these violations - each paired with the correct pattern and the section it breaks - is docs/anti-patterns.md, the field guide to constitutional violations. Scan the categories your change touches before opening a PR.


22. Identity Test Suite

22.1 Purpose

The identity tests are the minimum set of tests that, if any one fails, the system is no longer the system this document describes.

They are the executable form of the constitution. If you change the spec in a way that invalidates one of these tests, you have changed system identity. That requires a CLAUDE.md amendment.

22.2 Categorization

The test suite is layered. Each layer answers a different question about kernel correctness. A pass on one layer is necessary but not sufficient. Battle-hardening requires passing all layers.

CategoryPurposeBar (what failure means)Status
IdentityPin constitutional decisions; existence proofConstitutional invariant violated§22 (Test 11 added, H11)
PropertyUniversal invariants under random inputsA claim the spec makes does not hold§22 (P1-P10, 10/10)
FuzzCrash resistance under adversarial inputsKernel panics on user-controllable inputActive
StressSurvival under sustained loadDrift, leaks, or corruption appear over timeActive
PerformanceLatency / throughput benchmarksA measured number regressed§22 (10/10)
AdversarialCapability isolation under attackAn attack succeeds where the spec says it failsActive
ChaosGraceful degradation under partial failuresA defined failure mode is not handled cleanlyActive
  ┌─────────────────────────────────────────────────────────────────────┐
  │  Foundation - must pass before anything else                        │
  │  Identity (§22) - Tests 1-15 ✅ - Constitutional invariants        │
  └──────────────────────┬──────────────────────────────────────────────┘
          ┌──────────────┴─────────────────┐
          ▼                                ▼
  ┌───────────────────┐          ┌───────────────────┐
  │  Property         │          │  Fuzz             │
  │  Universal invs.  │          │  Crash resistance │
  │  under random     │          │  on adversarial   │
  │  inputs           │          │  inputs           │
  └───────┬───────────┘          └──────────┬────────┘
          ▼                                 ▼
  ┌───────────────────┐          ┌───────────────────┐
  │  Stress           │          │  Adversarial      │
  │  No drift, leak,  │          │  Cap isolation    │
  │  or corruption    │          │  under attack     │
  └───────┬───────────┘          └──────────┬────────┘
          └─────────────┬────────────────────┘
                ┌───────┴────────┐
                ▼                ▼
      ┌──────────────────┐  ┌──────────────────┐
      │  Performance     │  │  Chaos           │
      │  Latency /       │  │  Graceful        │
      │  throughput      │  │  degradation     │
      │  baselines       │  │  under failures  │
      └──────────────────┘  └──────────────────┘

Tests live under tests/qemu/, organized by category:

tests/qemu/
  identity/      # §22.5-§22.6 (complete)
  property/      # see Property Tests below
  fuzz/          # see Fuzz Tests below
  stress/        # see Stress Tests below
  perf/          # see Performance Benchmarks below
  adversarial/   # see Adversarial / Red-Team Tests below
  chaos/         # see Chaos Tests below
  harness/       # shared infrastructure

The base harness (§22.3) boots the OS with a configurable -smp N value; multi-core tests assert on N ≥ 2. The battle-hardening categories extend it with longer timeouts (stress, perf), metric collection (perf), and fault injection (chaos).

The bar across every category is the same as identity: no FAIL, no BLOCKED with a vague reason. A failure means a real bug - fix it, add a regression test to the appropriate suite, then move on.


Identity (§22) - Complete

All passing (Tests 1-15; harness reports 24 cases incl. A/B + IR + Test 11 + Test 15). No regressions allowed. Any failure here is a constitutional violation that requires either a kernel fix or a CLAUDE.md amendment. Test 11 (added by H11) is now name-resolves-after-restart-via-the-kernel-directory - the registry service was retired in naming Phase 4 / Path C (docs/naming-design.md §3.7), so Test 11 pins the directory's restart property instead of a registry service. Test 1B now corrupts the supervisor (the registry it used to corrupt is gone). Test 15 (Path C / Phase 6) pins that the supervisor itself survives its own restart - the kernel respawns it, so the unkillable set is {kernel} alone.


Property Tests

Property tests assert universal claims over randomized inputs. Identity tests prove the system can satisfy each invariant; property tests prove it always does. Each test runs thousands of iterations with QuickCheck-style generators.

IDPropertyPins
P1Random bytes → CapNotHeld or CapInvalid; never accepted as a cap§7.3 (unforgeable)
P2Generation per service is strictly monotonic across its lifetime§7.5
P3Cap rights never widen during transfer§7.3 (non-escalating)
P4∑ current_task_alloc_bytes ≡ pages mapped, after any sequence of alloc/free§10.3
P5Every live endpoint has exactly one owning task§8.3
P6Queue head ≤ tail ≤ head + 16; count consistent with both§8.5
P7After unmap + TLB shootdown, the page is unreadable from every core§10.5
P8After restart, name resolves to a task with the same name and higher generation§14.2
P9Generation bump invalidates ALL holders, not just some§7.5
P10Every send returns exactly one of {Ok, defined error} - never both, never neither§8.6

Bar: any property failure is a logic bug. Kernel must be fixed before any other work proceeds.


Fuzz Tests

Fuzz tests find the inputs that crash the kernel that no one would write by hand. The bar is binary and absolute: the kernel must never panic on user-controllable input.

IDSurfaceGenerator
F1Syscall args (each × 1M iters)Random u64 in a0/a1/a2; including kernel addresses, unmapped pointers, misaligned values
F2Syscall numbersRandom u64 as nr; must return UnknownSyscall, never crash
F3ELF binariesBit-flip mutations of known-good ELFs handed to the spawner
F4Service contractsMalformed TOML; JSON Schema-invalid structures
F5IPC message bodiesRandom bytes, random sizes up to 4 KiB
F6Embedded caps in messagesMessages claiming to carry caps with random structure
F7Cap generation fieldRandom u64 as generation in cap usage; must return CapRevoked or EndpointDead
F8Memory request valuesRandom sizes including > total RAM, 0, and u64::MAX

Any panic discovered by F1-F8 is a kernel bug. The fix is mandatory and includes a regression test added to the relevant identity or property suite.


Stress Tests

Stress tests find the bugs that only appear under sustained load. Identity tests prove correctness for individual operations; stress tests prove the system does not drift, leak, or corrupt over hours of operation.

IDScenarioDuration
S1IPC saturation: sustained try_send on a full queue1 hour
S2Restart storm: 100k kill/respawn cycles of one serviceuntil complete
S3Cross-core thrash: 4 cores × all-to-all IPC10 min
S4Cap table churn: 100k random create/destroyuntil complete
S5Generation overflow: force counter to wrap, observe wraparound semanticsuntil wrap + 1k operations
S6Long-running stability: ping/pong + introspection24 hours
S7Memory pressure: alloc-to-limit + free, 10k cyclesuntil complete
S8Idle stability: boot, no workload, observe24 hours
S9Interrupt storm: high-frequency timer + IPI cross-fire1 hour
S10Cascading revocation: kill a service held by many; observe propagationuntil propagated

Bar: at the end of each test, the kernel has not panicked, memory accounting is consistent, all services are in a defined state, and no resource is leaked.


Performance Benchmarks

Performance benchmarks lock in numbers so regressions are detected commit-to-commit. Absolute values matter less than the deltas.

IDMetric
B1IPC same-core round-trip latency: p50, p99, p99.9
B2IPC cross-core round-trip latency: p50, p99, p99.9
B3Syscall floor: yield round trip
B4Cap validation cost: one cap + generation check
B5Spawn cost: supervisor.spawn → service "ready"
B6Restart cost: kill + spawn
B7Cap table contention: throughput at 1, 2, 4 cores
B8Allocator throughput: pages/sec under contention
B9Message copy cost: 4 KiB upper-bound copy
B10Scheduler decision cost: time to pick next task

Results are committed to tests/qemu/perf/baseline.json. Comparison is MANUAL. collect_perf_baseline only WRITES the baseline; the regression_threshold_pct: 10 it emits is an inert field nothing reads, and no workflow runs the perf suite at all (every test workflow is workflow_dispatch-only; the release workflow builds images, it does not benchmark). This said "CI compares each run against baseline and flags regressions >= 10%", which described a gate that has never existed. The §7.8 single global lock will surface most visibly in B7 - record the number now so the v2 sharded/RCU migration has a target.


Adversarial / Red-Team Tests

Adversarial tests verify capability isolation holds under direct attack. The system claims a capability model with no ambient authority; these tests run services that try to break that claim.

IDAttack
A1Service crafts random u64 values and tries to use them as caps
A2Service brute-forces endpoint IDs across the u32 space
A3Service attempts to allocate beyond its contract memory limit through every syscall path
A4Service receives a cap with limited rights and attempts to use rights it lacks
A5TOCTOU: service races a syscall with revocation of the cap it is about to use
A6Service tries to fill the cap table to denial-of-service the kernel
A7Service tries to detect IPC partner identity via timing
A8Service tries to monopolize a core via a tight loop without yielding
A9Service tries to spawn another service directly, bypassing the supervisor
A10Service passes kernel addresses as syscall arguments
A11Service invokes InspectKernel system queries without an INTROSPECT cap
A12Service invokes Reboot or PowerOff without the capability
A13Service invokes AcquireSendCap without the capability
A14Ring-3 CPU exception (null read, non-canonical read, divide-by-zero) kills the task, not the kernel
A15A bad user pointer kills the caller, not the kernel

Bar: every attack returns a defined error. Any attack that succeeds is a security hole; any attack that panics the kernel is a kernel bug. Both are mandatory fixes.


Chaos Tests

Chaos tests verify graceful degradation when something the kernel depends on fails partially. Total failures (kernel panic, TCB death) are covered by §6.2 and Test 1B. Chaos tests cover the between cases.

IDFailure injected
C1One or more APs fail to come up during boot
C2A service in the boot manifest has a corrupted ELF (non-TCB)
C3Allocator forced to return AllocFailed at random syscall entry points
C4Bootloader provides degraded environment (minimal RAM, no framebuffer)
C5Kernel stack approaches exhaustion under deeply nested syscall
C6One core's timer interrupt is dropped for an extended period
C7TLB shootdown IPI delivery is delayed across cores

Bar: the system either continues correctly with degraded capacity, or panics loudly with a defined reason. Silent corruption is never acceptable. Per invariant 12 (§3): failures are loud, never silent.


22.3 Test Harness

  osdev test identity
    │
    ├── build: identity-only supervisor + kernel
    ├── create disk image, install bootloader
    │
    └── for each test:
           │
           ├── boot QEMU (-smp N; + -enable-kvm when /dev/kvm available)
           ├── test service runs scenario
           ├── harness reads serial console line by line
           │
           ├── expected output matched?  ──▶  PASS
           ├── fail_on string seen?      ──▶  FAIL
           ├── timeout fires?            ──▶  FAIL
           │
           └── 500 ms isolation pause (OS reclaims QEMU pages)

Where these tests actually live (spec -> implementation). The pseudocode in this section pins each test; the executable form lives in osdev/, not as .rs files under tests/qemu/. The identity cases (Tests 1-11 and 15, each A/B, plus the interrupt-routing IR1A/IR1B) are data-driven TestSpec entries in osdev/src/validator.rs, run together by osdev test identity - 24 cases. Tests 12, 13, and 14 are heavier bare-metal scenarios with their own subcommands - osdev test iommu (Test 12), osdev test fs-restart (Test 13), osdev test file-cap (Test 14) - in osdev/src/main.rs and osdev/src/shell_test.rs. The per-directory guide tests/qemu/identity/CLAUDE.md maps every spec Test to its entry, and the other categories (Property, Fuzz, Stress, Perf, Adversarial, Chaos, and their brutal variants) are TestSpec tables in the same validator.rs.

22.4 Sequencing Note

"Tests before code" is aspirational. You cannot run any test until the kernel boots and IPC works. Honest sequence: write test specs (this section); build minimum kernel + harness; see tests fail for the right reasons; implement until they pass.

A test failing for the wrong reason (compile error, harness bug, missing cap not declared in test contract) is a failure of the test, not the kernel.

22.5 Conventions

  • Each test has a positive case (the system permits what it should) and a negative case (the system refuses what it shouldn't).
  • Pseudocode is illustrative, not literal Rust.
  • Each test names the spec section it pins.
  • Multi-core tests run with -smp 4 minimum unless otherwise stated.

Test 1: Bootstrap to Steady State

Pins: §11 (Bootstrap), §6 (TCB).

1.A - Positive: Healthy Multi-Core Boot

test bootstrap_steady_state_positive:
    # Phase 5: no init - the kernel spawns the supervisor directly; the supervisor spawns events.
    image = build_kernel(boot_manifest=[supervisor, events])
    qemu  = boot(image, smp=4)

    assert serial_contains("supervisor: ready", within=5s)
    assert serial_contains("events: ready",     within=5s)
    assert serial_contains("smp: 4 cores ready", within=5s)
    assert kernel_did_not_panic()

1.B - Negative: TCB Failure Panics

test bootstrap_tcb_failure_panics:
    # Path C / Phase 5: init is removed, so the KERNEL spawns the supervisor directly. A corrupt
    # supervisor ELF fails that spawn → the kernel panics (no init abort, so no "reason:" prefix).
    image = build_kernel(boot_manifest=[corrupt_binary("supervisor"), events])
    qemu = boot(image, smp=4)

    assert serial_contains("KERNEL PANIC", within=5s)
    assert serial_contains("supervisor spawn failed")
    assert qemu_state == "halted"
    assert no_app_services_started()

Test 2: Capability Enforcement

Pins: §3.1 (no ambient authority), §7 (use validation).

2.A - Positive

test cap_enforcement_positive:
    s = spawn_test_service(contract="[capabilities] log_write = true")
    assert s.invoke(Log("hello")) == Ok
    assert log_received("hello")

2.B - Negative

test cap_enforcement_negative:
    s = spawn_test_service(contract="[capabilities] # no log_write")
    assert s.invoke(Log("hello")) == Err(CapNotHeld)
    assert log_did_not_receive("hello")
    assert s.is_alive()

Test 3: IPC Send / Receive (Same Core)

Pins: §8 (IPC), §7.4 (SEND right).

3.A - Positive

test ipc_send_recv_positive:
    a = spawn(contract="ipc_send=['b'], placement.core=0")
    b = spawn(contract="ipc_receive=['b'], placement.core=0")

    a.send(target="b", payload="ping")
    msg = b.recv()
    assert msg.payload == "ping"

3.B - Negative

test ipc_send_without_send_right:
    cap_no_send = mint_endpoint_cap(target=b, rights=[])
    a.install_cap("b_endpoint", cap_no_send)
    assert a.try_send(target="b", payload="ping") == Err(CapInsufficientRights)
    assert b.queue_depth() == 0

Test 4: Endpoint Death Semantics

Pins: §8.6 (failure semantics), §14.2 (restart drops endpoints), §8.5 (queue depth = 16).

4.A - Positive: Send-After-Death Returns EndpointDead

test endpoint_death_send_returns_dead:
    b = spawn_simple_recv_service()
    a = spawn_with_cap_to(b.endpoint)

    supervisor.kill(b)
    wait_for_revocation()

    assert a.try_send(b.endpoint, "after death") == Err(EndpointDead)

4.B - Negative: Blocked Sender Wakes With EndpointDead

The queue depth is 16 (§8.5), so this test fills it deterministically.

test blocked_sender_wakes_on_endpoint_death:
    b = spawn(non_consuming_receiver)            # never calls recv
    a = spawn_with_cap_to(b.endpoint)

    for i in 0..16:
        a.send("fill", index=i)                  # fills the 16-deep queue

    handle = a.send_async("blocked")             # 17th: must block
    assert handle.is_pending()

    supervisor.kill(b)
    wait_for_revocation()

    assert handle.poll(timeout=1s) == Err(EndpointDead)
    assert a.is_alive()

Test 5: Capability Transfer Requires GRANT

Pins: §7.6 (transfer rule), §7.4 (GRANT right).

5.A - Positive

test grant_positive:
    cap = mint_cap(target=resource_X, rights=[READ, GRANT])
    sender.install_cap("X", cap)

    msg = Message::with_cap(cap)
    assert sender.send(receiver.endpoint, msg) == Ok

    received = receiver.recv()
    assert received.embedded_cap.use(READ) == Ok
    assert sender.has_cap("X") == false

5.B - Negative

test grant_negative:
    cap = mint_cap(target=resource_X, rights=[READ])     # no GRANT
    sender.install_cap("X", cap)

    msg = Message::with_cap(cap)
    assert sender.send(receiver.endpoint, msg) == Err(CapNotGrantable)
    assert sender.has_cap("X") == true
    assert receiver.queue_depth() == 0

Test 6: Supervisor Restart and Cap Rebinding

Pins: §14.2 (restart flow), §14.3 (cascading failure).

6.A - Positive

test supervisor_restart_positive:
    pong         = spawn("pong")
    original_pid = pong.pid

    supervisor.restart("pong")
    wait_for_serial("pong: ready", within=5s)

    assert pong.pid != original_pid
    assert kernel_did_not_panic()
    assert all_other_services_alive()

6.B - Negative

test stale_cap_revoked_after_restart:
    ping = spawn("ping")
    pong = spawn("pong")

    supervisor.restart("pong")
    wait_for_serial("pong: ready")

    assert ping.send_via_stale_cap("hello") in [Err(CapRevoked), Err(EndpointDead)]

    fresh = ping.reacquire_by_name("pong")
    assert ping.send_via(fresh, "hello") == Ok

Test 7: Memory Limit Enforcement

Pins: §10.3 (enforcement), §10.4 (two failure modes).

7.A - Positive

test memory_alloc_within_limit:
    s = spawn(contract="memory.limit = 64MiB")
    assert s.alloc(32 * MiB) == Ok
    assert s.alloc(20 * MiB) == Ok
    assert s.is_alive()

7.B - Negative (Two Modes)

test memory_alloc_beyond_limit_recoverable:
    s = spawn(contract="memory.limit = 64MiB")
    s.alloc(60 * MiB)
    assert s.alloc(20 * MiB) == Err(AllocDenied)
    assert s.is_alive()
    assert s.alloc(2 * MiB) == Ok

test memory_protection_violation_kills_service:
    s = spawn(contract="memory.limit = 64MiB")
    s.alloc(32 * MiB)
    s.write_to_unmapped_address(0xdead_0000)
    wait_for_kill(within=1s)
    assert s.is_dead()
    assert kernel_did_not_panic()

Test 8: Preemption of Non-Yielding Service

Pins: §3.6 (no service can monopolize), §9.1 (10 ms quantum), §9.3 (yield is advisory).

8.A - Positive

test yield_advisory_works:
    yielder = spawn_yielding_service(core=0)
    other   = spawn_logging_service(core=0)

    run_for(1s)
    assert other.log_count    >= 90
    assert yielder.tick_count >= 50

8.B - Negative

test non_yielding_service_is_preempted:
    hog   = spawn_tight_loop_service(core=0)
    other = spawn_logging_service(core=0)

    run_for(1s)
    assert other.log_count >= 1     # minimum bar
    assert other.log_count >= 40    # near fair share
    assert hog.is_alive()
    assert kernel_did_not_panic()

Test 9: Cross-Core IPC

Pins: §8.3 (routing), §8.4 (send flow), §9 (per-core scheduler).

9.A - Positive

test cross_core_ipc_positive:
    a = spawn(contract="ipc_send=['b'], placement.core=0")
    b = spawn(contract="ipc_receive=['b'], placement.core=1")

    assert a.assigned_core == 0
    assert b.assigned_core == 1

    a.send(target="b", payload="hello from core 0")
    msg = b.recv()
    assert msg.payload == "hello from core 0"

9.B - Negative

test cross_core_no_authority_leak:
    a = spawn(contract="placement.core=0")          # no caps to b
    b = spawn(contract="ipc_receive=['b'], placement.core=1")

    fake_cap = a.try_construct_cap(target=b.endpoint, rights=[SEND])
    assert fake_cap == Err(CapForgeryAttempted)
    assert b.queue_depth() == 0

Test 10: Restart With Core Reassignment

Pins: §9.2 (placement, strict), §14.2 (restart can change core), §14.4 (placement override), §11 (identity stable; location not).

10.A - Positive

test restart_changes_core_transparently:
    pong = spawn(contract="placement.core=1")
    assert pong.assigned_core == 1
    pong_gen_old = pong.generation

    supervisor.restart("pong", placement_override=2)
    wait_for_serial("pong: ready on core 2", within=5s)

    assert pong.assigned_core == 2
    assert pong.generation     > pong_gen_old

10.B - Negative

test client_reacquires_after_core_change:
    ping = spawn(contract="placement.core=0")
    pong = spawn(contract="placement.core=1")

    supervisor.restart("pong", placement_override=2)
    wait_for_serial("pong: ready on core 2")

    assert ping.send_via_stale_cap("hello") == Err(EndpointDead)

    fresh = ping.reacquire_by_name("pong")
    assert fresh.target_core == 2
    assert ping.send_via(fresh, "hello") == Ok
    assert pong.received("hello")

Test 11: Name Resolves After Restart, via the Kernel Directory (Path C / Phase 4)

Pins: §6.1/§6.2 (a restartable service's death is a supervisor restart, not a panic), §14 (supervisor restart authority), §3.11 (identity over location), §4.4/§26.10 (naming = the kernel's minimal recovery directory, not a service).

Amendment 2026-06-21 (naming Phase 4): the registry service is retired (docs/naming-design.md §3.7, "Path C"). Name→endpoint resolution is a minimal directory inside the kernel (ipc::names

  • the gated AcquireSendCap); the supervisor wires services from a name → cap map and clients reacquire names via the directory. There is no registry service to kill. This test was "registry survives its own restart" (H11); it now pins the directory's restart property - a restartable service killed and respawned must re-establish without a kernel panic, and its name must resolve to the new instance (the directory re-records it on respawn). block-driver is the disk-free restartable target (it idles with no disk in the identity build).
test name_resolves_after_restart_via_directory:
    wait_for_serial("supervisor: ready")
    control.kill("block-driver")                   # a restartable service (Phase D)

    assert serial_contains("supervisor: block-driver died, restarting")
    assert serial_contains("supervisor: block-driver restarted")
    assert serial_contains("name-map + block-driver")  # re-recorded → directory resolves the new instance
    assert kernel_did_not_panic()
    # Clients reacquire "block-driver" by name via the kernel directory (§14.3) - pinned end-to-end
    # for fs←block-driver and shell←fs by the files-test double-storm regression.

Test 12: Confined Driver Cannot DMA Outside Its Arena (H1)

Pins: §3.1 / invariant 1 (no ambient authority - the DMA gap), §6.4 (DMA-capable drivers are least-privilege when IOMMU-confined), §12 (drivers).

The point of H1: a confined DMA-capable driver's device can reach only its granted arena. A DMA outside it must be blocked at the IOMMU (a logged IO_PAGE_FAULT on real hardware), never silently read/write other memory. This is the executable form of the §6.4 trust claim - without it, "confined" is a word, not a guarantee.

Runs only where an IOMMU is present (osdev test iommu launches QEMU with -device amd-iommu); on a machine with no IVRS the driver is trusted (§6.4) and the test is not applicable.

Verification is structural, not a live fault, and the reason is a QEMU limitation. QEMU's emulated amd-iommu installs the device tables and page tables but does not enforce translation faults on unmapped I/O addresses - a device DMA to an unmapped page is silently allowed through. So a live IO_PAGE_FAULT cannot be observed under QEMU. The harness therefore asserts the property QEMU can be made to prove: the kernel's confinement selftest - a CPU-side walk of the device's I/O page table - confirms the arena translates identity and the page one past it is unmapped (so a DMA there has no translation and would fault on conforming silicon). That selftest is exactly the structure an IO_PAGE_FAULT is raised from; pinning it pins the guarantee. (The live fault itself is hardware-verified on the T630 and reproducible anywhere via the iommu-fault-test build feature, which confines the driver to an empty domain so its normal init DMA lands out-of-arena.)

test confined_driver_dma_faults:           # osdev test iommu
    boot(smp=2, iommu=on)                                     # q35 + amd-iommu + qemu-xhci + usb-kbd
    assert serial_contains("iommu: ... confined BDF")         # driver is confined to its arena

    # The kernel's confinement selftest walks the device's I/O page table: the arena
    # maps identity, and the page one past arena_end has NO mapping - the structural
    # form of "out-of-arena DMA would fault" (QEMU can't raise the fault itself).
    assert serial_contains("iommu: selftest PASS")
    assert serial_contains("(outside) unmapped")

    assert serial_contains("keyboard found")                  # driver still operates THROUGH the domain
    assert kernel_did_not_panic()                             # confinement is not fatal to a well-behaved driver

# Live-fault form (hardware / `--features iommu-fault-test`, not QEMU's lenient model):
#   confine the driver to an EMPTY domain → its first init DMA is out-of-arena →
#   IOMMU raises IO_PAGE_FAULT, kernel logs it, memory outside the arena is unchanged.

Test 13: Filesystem Survives Its Own Restart (Phase D)

Pins: §6.1/§6.2 (fs + block-driver are restartable, not trusted root), §6.3 (TCB-shrink goal reached), §15 (transactional recovery), §14 (supervisor restart authority), §3.11.

fs and block-driver are restartable userspace services (Phase D amendment 2026-06-17), made safe by fs's crash-consistent recovery (Phase C). Killing fs must NOT panic the kernel; the supervisor must observe its death and respawn it; fs must re-mount to a consistent state (persisted data intact) and re-register; and a client must reacquire it by name and keep working.

test fs_survives_own_restart:                     # osdev test fs-restart
    boot(bare-metal + AHCI disk; shell on COM1, control on COM2)
    shell("drives flash data" → y)                # format
    shell("write /t.txt survives-restart")
    assert shell("read /t.txt") contains "survives-restart"

    control.kill("fs")                            # was a panic+reboot pre-Phase-D

    assert serial_contains("supervisor: fs died, restarting")
    assert serial_contains("supervisor: fs restarted")
    assert serial_contains("fs: serving file API")   # re-mounted + re-registered

    # The shell reacquires a fresh fs cap by name via the kernel directory (§14.3); the file persisted.
    assert shell("read /t.txt") contains "survives-restart"
    assert kernel_did_not_panic()

Test 14: File Is a Capability (P2)

Pins: §7.3 (cap properties), §7.10 (delegated resource capabilities), §3.1 (no ambient authority), §3.3 (authority by capability, not identity).

The P2 amendment (2026-06-18) makes a file a real, kernel-minted capability via delegated resource caps. This test pins that the file cap is a genuine capability - not a service-level token - by exercising the three properties that distinguish one: unforgeable, revocable, non-escalating.

test file_is_a_capability:
    # fs hands out a real cap on open (delegated resource cap, §7.10)
    rw = fs.open("/doc.txt", rights=[READ, WRITE])     # returns a file capability
    assert rw.write("hello") == Ok
    assert rw.read()         == "hello"

    # Unforgeable (§7.3): a fabricated handle is not a cap
    assert use_random_handle_as_file_cap() in [Err(CapNotHeld), Err(CapInvalid)]

    # Non-escalating (§7.3): a READ-only file cap cannot write
    ro = fs.open("/doc.txt", rights=[READ])
    assert ro.read()          == "hello"
    assert ro.write("nope")   == Err(CapInsufficientRights)

    # Revocable (§7.5/§7.10): deleting the file revokes every cap to it
    fs.delete("/doc.txt")                               # fs revokes the resource (gen bump)
    assert rw.read()  == Err(CapRevoked)
    assert ro.read()  == Err(CapRevoked)
    assert kernel_did_not_panic()

Implemented (2026-06-18) as osdev test file-cap (9/9 ✅). The shell fcap <file> command opens a file as a real kernel capability and exercises every property above end-to-end: read/write through the cap; non-escalation at both layers (the kernel rejects a READ-only cap's WRITE invocation with CapInsufficientRights, and fs refuses a write op carried under a read-validated badge - op ≤ right); a fabricated handle is rejected (unforgeable); and the cap is CapRevoked after close/delete (revocable). The badge that carries the validated (resource_id, right) to fs is an unforgeable kernel-set Message field (LastRecvBadge syscall) - a client cannot fake a file-cap invocation over its ordinary fs send cap.


Test 15: Supervisor Survives Its Own Restart (Path C / Phase 6)

Pins: §6.2 (the supervisor is restartable - its death is a kernel respawn, not a reboot), §6.3 (TCB-shrink reached its floor: unkillable = {kernel}), §3.7/§4.4 (the kernel is the recovery anchor), §14 (restart authority), §3.11 (identity over location).

Amendment 2026-06-21 (Path C / Phase 6): the supervisor is restartable. Killing it (a fault, or a deliberate chaos kill-storm supervisor / the operator control channel) must not panic the kernel. The kernel respawns it - unconditionally, forever (no bound: a bound would re-introduce the reboot and be a DoS, §6.2) - and the respawned supervisor reconciles: it adopts the still-running services (reacquiring each by name from the kernel directory) instead of duplicating them. The running services (pong/ping cross-core IPC, the shell) keep going throughout - the shell survives killing its own spawner. This is the executable proof that the unkillable set is {kernel} alone.

test supervisor_survives_own_restart:           # osdev test identity (also: chaos kill-storm supervisor)
    wait_for_serial("supervisor: ready")
    control.kill("supervisor")                    # was a kernel panic pre-Phase-6 (it was trusted root)

    assert serial_contains("kernel: supervisor died - respawning")   # the kernel is the recovery anchor
    assert serial_contains("supervisor: adopted running block-driver") # reconciled, not duplicated
    assert kernel_did_not_panic()
    # The shell-test storms it 4× (chaos kill-storm supervisor 4 → recovered 4/4, kernel alive, no
    # bound) and the prompt still answers afterward - the system stayed up across every respawn.

22.6 Test Coverage Matrix

TestSpec sections pinnedConstitutional invariant
1. Bootstrap§11, §6TCB integrity
2. Capability enforcement§3.1, §7No ambient authority
3. IPC send/receive (same core)§8, §7.4Authority is explicit
4. Endpoint death§8.5, §8.6, §14.2Restartability
5. Capability transfer§7.6, §7.4Authority is explicit
6. Supervisor restart§14.2, §14.3Restartability
7. Memory limits§10.3, §10.4Isolation
8. Preemption§3.6, §9.1, §9.3No service monopoly
9. Cross-core IPC§8.3, §8.4, §9Identity over location
10. Restart with core change§9.2, §14.2, §14.4, §11Identity over location
11. Name resolves after restart (directory)§6.1, §6.2, §14, §3.11, §4.4Restartability / naming-out-of-kernel
12. Confined driver DMA faults§3.1, §6.4, §12No ambient authority (DMA)
13. fs survives own restart§6.1, §6.2, §6.3, §15, §14Restartability / TCB shrink
14. File is a capability§7.3, §7.10, §3.1, §3.3Authority is explicit (files)
15. Supervisor survives own restart§6.2, §6.3, §3.7, §14, §3.11Restartability / unkillable = {kernel}

If any cell becomes obsolete, the corresponding spec section is being changed and the change requires a CLAUDE.md amendment.


22.7 The Stranger Test - is the PUBLIC interface doing the teaching?

Every test above this line asks whether the kernel is correct. This one asks whether the system can be USED, and it is the only test here whose subject is the developer rather than the machine.

The bar: give the least capable model available a fresh context, the PUBLIC developer documentation only, and one ordinary application requirement. No architectural coaching. If it produces a correct Godspeed program under those conditions, the public interface is doing a substantial amount of the teaching. If it can only succeed after someone explains MISCIS, the interface is not finished, whatever the kernel does.

Why the weakest model rather than the best. A sophisticated model can infer the architecture from the source and would pass by being clever, which measures the model. A weak one cannot, so what it produces measures the DOCUMENTATION AND THE API. The point is not to find out whether Godspeed can be understood; it is to find out whether it has to be understood before it can be used.

Let the repository teach, and do not grade on a curve. The temptation is to explain the rules and then be pleased when they are followed. The rules are already mechanised, so the test is whether the gates catch a stranger's mistakes and whether the resulting error says enough to recover from: #![deny(unsafe_code)] refuses unsafe at the compiler, arch_boundary_check refuses an ISA-conditional workaround, commandments.py refuses ambient authority and an unrecoverable peer, and Error::retry_is_safe answers the one question a stranger is most likely to get wrong. A gate that fires with an unhelpful message is a finding, not a pass.

It is the inversion of the grokability probe in audits/documentation-audit.md, which asks whether a weak model can REGENERATE a document. That one tests whether Godspeed can be understood; this one tests whether it can be used without being understood. Both use the same instrument and neither substitutes for the other.

This is not an identity test and does not pretend to be. It has no pass/fail assertion, cannot be run in CI, and failing it does not mean the system stopped being the system §22.1 describes. It means v1's stated intent - "Godspeed is simple to program for because its own architecture has a small, coherent and honest developer interface" - is not yet met. It is recorded here rather than in docs/ alone because that intent is a commitment, and a commitment nobody measures is a preference.

The correct path must be easier than cheating. That is the whole design rule this test exists to check, and the honest form of the success condition: a stranger should fall into the supported model because it is the path of least resistance, not because they were told to.

docs/stranger-test.md has the protocol, what to measure, and the difficulty ladder.


23. First Milestone ✅ Complete

23.1 Goal

Boot the OS multi-core.
Start two services (ping on core 0, pong on core 1).
Send a message between them via cross-core IPC.
Kill pong.
The supervisor restarts it (possibly on a different core).
The system continues running.

23.2 Acceptance Criteria ✅

  1. osdev run --smp 4 boots the OS with 4 cores; supervisor, events, ping, and pong reach steady state. ✅ (As recorded in 2026-05 this read "init, supervisor, registry, logger"; init was removed in Phase 5, the registry service retired in Phase 4, and logger renamed events in 2026-09.)
  2. ping placed on core 0; pong placed on core 1. ✅
  3. osdev logs ping shows ping sending a message every second. ✅
  4. osdev logs pong shows pong receiving each message (cross-core IPC). ✅
  5. osdev restart pong --core 2 kills pong on core 1 and respawns it on core 2. ✅
  6. ping observes EndpointDead and reacquires by name; the new cap routes to core 2. ✅
  7. After reacquisition, ping and pong continue communicating across the new core boundary. ✅
  8. The kernel does not panic on any core. ✅
  9. All identity tests in §22 pass. ✅ (ten at the time; fifteen tests / 24 cases today.)

23.3 Bare-Metal Achievement ✅

GodspeedOS has booted on real x86_64 hardware (4-core CPU, 4 GB RAM) via UEFI USB boot (2026-05-21).

  • osdev image produces a UEFI GPT disk image at build/os-usb.img. (This said build/os.img, which is the QEMU/BIOS image - osdev/src/disk_image.rs notes the two are named apart on purpose, and §17 has it right.)
  • Image written to USB with Cygwin dd; boots via BOOTX64.EFI (Limine 12.x).
  • All 4 cores come up; cross-core IPC (ping core 0 → pong core 1) runs continuously on hardware.
  • Null modem serial (115200 8N1, PuTTY) confirms boot output; log appended to build/putty_serial_output.log.
  • bare-metal supervisor feature excludes harness-driven probe services that require QEMU's control port.

Persistence + file-as-capability hardware-proven on the HP T630 (2026-06-18). Booted the bare-metal os.img on the T630 (AMD GX-420GI, real AHCI SSD) and ran the shell selfcheck suite: ran 163, failed 0. That run includes the full persistence stack (GSFS0008 format, journal, fsck/drives check, read-only drives scrub) and the file-as-capability self-check (fcap, §22 Test 14): a file opened as a real kernel capability, read/written through the cap, non-escalation enforced at both the kernel and fs layers, a forged handle rejected, and the cap revoked on close and on rename - all green on real hardware, no panic. The §7 "north star" (a file is a capability, true not approximate) is hardware- validated, not just QEMU. Serial in build/putty_serial_output.log.

Hardware performance data (perf-brutal-only build, ~3 GHz CPU, 2026-05-21):

BenchmarkResult
BP1 IPC same-core p5055,320 cycles (~18.4 µs)
BP3 yield floor39,903 cycles (~13.3 µs)
BP4 cap validation495 cycles (~165 ns)
BP5 spawn cost8,121,378 cycles (~2.7 ms)
BP6 restart cost14,462,309 cycles (~4.8 ms)
BP7 cap table1,168 cycles (~389 ns)
BP8 allocator616 cycles/4KiB page (~205 ns)
BP9 message copy 4KiB20,073 cycles (~6.7 µs)
BP10 scheduler decision2,323 cycles (~774 ns)
BP2 IPC cross-coreNot measured on J5005 (Goldmont+ stalled cross-core under load) - measured on the T630, see table below

HP T630 (AMD GX-420GI, Jaguar/Puma+, ~2 GHz) - per-probe isolated measurements (one benchmark alone: no ping/pong, no competing probes; 2026-05-31, build ed8a151). µs/ms at ~2 GHz:

BenchmarkT630 isolatedJ5005 (above, perf-brutal)
BP1 IPC same-core p50~102,600 cycles (~51 µs) †55,320
BP2 IPC cross-core p501,433,087 cycles (~0.72 ms); p99/p999 16,409,799not measured
BP3 yield floor19,281 cycles (~9.6 µs)39,903
BP4 cap validation~1,258 cycles (~0.63 µs) †495
BP5 spawn cost45,406,292 cycles (~22.7 ms)8,121,378
BP6 restart cost54,231,139 cycles (~27.1 ms)14,462,309
BP7 cap table2,932 cycles (~1.5 µs)1,168
BP8 allocator~1,472 cycles/4KiB (~0.74 µs) †616
BP9 message copy 4KiB21,796 cycles (~10.9 µs)20,073
BP10 scheduler decision20,726 cycles (~10.4 µs)2,323

All cycle counts. † = perf-brutal in-suite p50 (already low-variance); the rest are single-probe isolation builds (osdev image --mode iso-bp{3,5,7,9,10}; bp5 covers BP5+BP6). The two columns are not a clean head-to-head: J5005 was perf-brutal (contended), the T630 column is isolated (uncontended), so they compare fairly only where both were clean - BP1/BP4/BP8 at ~1.9-2.5×, genuine Jaguar-vs-Goldmont IPC. Where the T630 number is lower (BP3) the J5005 figure was itself contention-inflated. BP5/BP6 (spawn/restart) are memory-bandwidth-bound - the low-power thin client lags most there. The full investigation behind BP2 (the COM2 timer-ISR wedge, fixed a306fd3) is in bugs/1_FINDINGS_AP_TO_BSP_IPI.md.

23.4 Out of Scope for v1

Filesystem persistence beyond the trusted block driver, network stack, work-stealing scheduler, service migration, zero-copy IPC, live code updates, restartable block driver / fs, update model in production mode, core hotplug, per-endpoint queue depth in contract.


24. Glossary

  • AP - Application Processor. Any core other than the BSP.
  • BSP - Bootstrap Processor. The first core to execute kernel code.
  • Call - The synchronous request/reply IPC syscall (§8.2, syscall 41): send a request carrying a one-shot reply cap, then block awaiting the reply; wakes with the reply or with ReplyDead if the replier dies first, so it never hangs. Mechanism, not policy - the kernel learns a reply cap, not "RPC". The primitive behind the SDK request_with_reply.
  • Capability - Unforgeable token: ResourceId + Rights + Generation.
  • Delegated resource capability - A capability for a resource whose meaning is defined by a service (e.g. a file owned by fs), not the kernel. The owning service mints and revokes it (resource_mint/resource_revoke, gated by a RESOURCE_MINT cap); the kernel validates and routes it as for any cap, badging a send with the opaque ResourceId so the owner knows which resource. The mechanism behind file-as-capability (§7.10, P2).
  • Endpoint - IPC destination owned by a service. Bounded queue, depth 16 in v1.
  • Generation - Monotonic counter on resources; mismatch on cap use indicates the resource was destroyed or replaced.
  • Grant - The right to transfer a capability via IPC.
  • Identity test - A test that pins a constitutional decision (§22).
  • IPI - Inter-Processor Interrupt. Mechanism for a core to wake another core.
  • Limine - The bootloader used in v1. See Appendix A.
  • Placement - Strict assignment of a service to a specific core at spawn time. Re-evaluated from scratch on every spawn, including post-restart spawns; the previous core is not remembered.
  • PlacementInvalid - Error returned when a contracted core is unavailable; spawn rejected, supervisor logs and skips.
  • Quantum - The 10 ms time slice after which the per-core scheduler preempts.
  • Reply cap - A one-shot capability to the caller's own endpoint, sent inside a Call request so the replier can reply to it. The kernel tracks, per blocked caller, the target endpoint it awaits; if that endpoint dies, the caller is woken with ReplyDead. The reply-side twin of the blocked-sender record (§8.2, §8.6).
  • ReplyDead - Error returned when the replier's endpoint dies while the caller is blocked in a Call awaiting its reply (value -12; §7.7, §8.6). The reply-side twin of EndpointDead, on the same generation/liveness mechanism.
  • Routing table - Kernel structure mapping EndpointId → (CoreId, Generation, Liveness).
  • TCB - Trusted Computing Base. Kernel + arch + smp + supervisor. (Not init - it was removed in Path C / Phase 5; the kernel spawns the supervisor directly. And the supervisor is trusted but RESTARTABLE, Phase 6, so the only unkillable component is the kernel.) registry left the TCB via H11 (and the registry service was then retired entirely - naming Phase 4 / Path C, docs/naming-design.md §3.7); block-driver + fs left via the Phase D amendment (§6.1, once fs gained crash-consistent recovery). DMA drivers (xhci/ehci, and ARM's dwc2) are in the TCB only on a machine without an IOMMU to confine them (§6.4); all three are userspace services now.
  • Trusted root - supervisor (sole; init was removed in Path C / Phase 5 - the kernel spawns the supervisor directly). It is trusted but restartable (Path C / Phase 6, §6.2): the kernel respawns it on death - unconditionally, forever - so its failure is recovered, not a reboot. The only unkillable component is the kernel itself. (block-driver + fs are restartable storage services.)
  • Name directory - the kernel's minimal name → EndpointId map (ipc::names) + a gated "mint a SEND cap by name" (AcquireSendCap). The bounded recovery anchor that replaced the registry service (naming Phase 4 / Path C, §3.7): the supervisor wires services from a name → cap map and clients reacquire names through the directory. (The retired registry service's design note was deleted with the service; docs/naming-design.md §3.7 is the record.)
  • Service - Userspace component with a contract, capability table, and isolated address space.
  • Contract - a service's contracts/<name>.toml (e.g. examples/ping/contracts/ping.toml) declaring resource, capability, and placement requirements.
  • Supervisor - User-space service holding restart authority over other services.
  • osdev - Host-side CLI for building, publishing, and controlling the OS in QEMU.

25. Final Principles

Undefined behavior in spec becomes bugs in system.

If it violates the model, it does not belong.

If an identity test fails, the system is no longer this system.

Identity is stable. Location is not. Movement is invisible. Death is visible.

Failures are loud, never silent.

A failing Godspeed that preserves its invariants is preferable to a working Godspeed that violates them.

The last one ranks the others. Every principle above can be traded against a deadline, a benchmark or a stubborn bug, and this says which way that trade goes: a system that fails while keeping its model intact is still this system, and can be fixed. A system that works by breaking its model is a different system that happens to run today, and what it has really lost is the reason to trust any of the rest of this document.

This is not a preference for failure. It is a statement about what a fix is: making the system work WITHIN the model, and never widening the model to admit what is easier. Where the two genuinely cannot be reconciled, the honest move is to record the gap loudly and leave it open (§26.7) rather than close it with a violation.



Appendix A: Bootloader Choice (Limine)

Status: v1 commitment. This appendix documents and justifies a concrete decision; it is part of the v1 spec, not aspirational.

A.1 Decision

The v1 bootloader is Limine, accessed by the kernel via the Limine Boot Protocol.

A.2 What Limine Provides to the Kernel

At handoff, Limine supplies the kernel with:

  • Physical memory map - usable, reserved, ACPI, framebuffer regions all classified.
  • Framebuffer descriptor - base address, dimensions, pixel format. Avoids ever needing VGA text mode.
  • Kernel relocation info - physical and virtual base addresses of where the kernel was loaded.
  • SMP topology - APIC IDs of all available processors, used by kernel/smp and arch/x86_64/ap_boot.rs. This removes the need to probe ACPI/MADT in v1.
  • Higher-half direct map - physical memory pre-mapped at a known high-half virtual address, available immediately.
  • Boot-time module list - ability to load additional binaries (e.g., the initial service manifest) alongside the kernel.

A.3 Why Limine for v1

ReasonDetail
Tight Rust integrationThe limine crate (crates.io) provides type-safe bindings to the boot protocol. Request structures are declared as Rust statics; responses are read back with strong typing. No hand-rolled parsing of bootloader-supplied tables.
SMP topology suppliedAPIC IDs come pre-discovered. arch/x86_64/ap_boot.rs does not need an ACPI parser to enumerate cores.
BIOS + UEFI from one bootloaderLimine supports both firmware modes with the same protocol. The kernel does not need to care which firmware booted it.
Good QEMU supportCritical for osdev run (§17). The whole identity test suite runs under QEMU; the bootloader must work there reliably.
Active maintenanceLimine is currently maintained and the protocol is stable but evolving. Pin a specific protocol version in Cargo.toml and treat updates as deliberate.

A.4 Installation Story

GodspeedOS ships with Limine. Users do not install or pre-install Limine separately.

UEFI systems:

  • The OS image places BOOTX64.EFI (the Limine UEFI loader) at /EFI/BOOT/BOOTX64.EFI on the EFI System Partition.
  • The kernel binary is placed where limine.conf references it.
  • Firmware → BOOTX64.EFI → Limine → kernel.

BIOS systems:

  • The installer runs limine bios-install to write Limine's stage-1 to the MBR (or VBR).
  • Stages 2 and 3 of Limine live on disk alongside the kernel; loaded by the stage-1.
  • Firmware → MBR → Limine → kernel.

In both cases the Limine binaries are part of the OS image. The end user installs the OS; Limine arrives with it.

A.5 Dual-Boot Support

Out of scope for v1. If pursued later, Limine supports chainloading other bootloaders, which is the cleanest path. v1 assumes a dedicated machine or VM.

A.6 Alternatives Considered

OptionWhy Not
GRUB / Multiboot2Older protocol, clunkier Rust integration, no built-in SMP topology surface, much larger surface area to depend on.
Custom bootloaderA project unto itself. Distracts from the actual goal (a capability microkernel).
Bootboot, stivale (predecessor to Limine)Stivale is deprecated in favor of Limine Boot Protocol. Bootboot is reasonable but has a smaller community and worse Rust tooling than Limine today.

A.7 Implementation Notes

  • The integration code lives in kernel/src/arch/x86_64/boot.rs.
  • The Limine protocol version used is pinned in the workspace Cargo.toml. Protocol version bumps are PR-reviewed.
  • The boot handoff (Limine to kernel main) is §11 itself. A separate docs/bootstrap.md was listed here and in §5 but was never written; §11 is the walkthrough, and pointing at it is both truthful and shorter than writing a second file to repeat it.

Appendix B: Userspace Posture

Status: Non-normative. Records the v1 stance on userspace languages and the design intent for early services. Does not amend the constitution.

B.1 Primary Language: Rust

All v1 userspace services are written in Rust against sdk/rust/.

Reasons:

  • The kernel is Rust; sharing a toolchain dramatically simplifies the build and CI pipeline.
  • Capability handles in sdk/rust/capability.rs are typed Rust structures. C or other-language equivalents would need parallel SDKs.
  • Contract validation in osdev is Rust; reusing the same JSON Schema crate end-to-end avoids drift.
  • Borrow-check discipline meshes naturally with the no-shared-mutable-state invariant.

B.2 Other Languages

The syscall ABI follows System V AMD64. Any language that can produce a freestanding ELF binary and call via that ABI can run as a service.

Plausible second-fits:

  • C - would need a sdk/c/ of thin syscall wrappers. Capability handles become opaque integers; the type-safety of the Rust SDK is lost.
  • Zig - bare-metal-capable, no runtime, comparable ergonomics to C with better safety. A sdk/zig/ is a future possibility.

Out of scope:

  • Languages with substantial runtimes (Go, Python, JavaScript, Java) - would require porting their runtime as a GodspeedOS service. Each is a multi-month project on its own.

B.3 Shell ≠ Unix Shell

The traditional Unix shell relies on fork, exec, file-descriptor inheritance, ambient stdin/stdout/stderr, environment-variable inheritance, and anonymous pipe sharing. None of those primitives exist in GodspeedOS.

A "shell" in this system is a capability-broker service. It:

  • Holds a capability to a console service (keyboard input + display output).
  • Holds the authority to ask the supervisor to spawn other services (via a cap to invoke supervisor.spawn).
  • Reads commands and constructs spawn requests, including the explicit caps each child should receive.

This means there is no stdin, only an IPC endpoint to a console service. There is no fork, only an authenticated request to the supervisor. There is no inherited environment, only the caps the parent explicitly granted.

Note (2026-08-17): a console service now exists, and it owns OUTPUT only. This appendix anticipated one service holding "keyboard input + display output"; what was built holds the display - the terminal model, the grid, the cursor and the rendering (docs/console-service.md §9). Keyboard INPUT is still brokered by the shell through the kernel's single-reader console ring, for the reason recorded in docs/console-service.md §5a: there is one input ring and one reader slot, so a service that owned the keyboard would block in ConsoleRead and could not also field take/release messages. The split is deliberate, not partial. This appendix stays non-normative; it is the intent, and the difference is recorded here rather than left to read as a description of what shipped.

The full mechanics of how Unix-style scripting maps onto this model - pipes as capability-mediated endpoints, redirection as cap minting, etc. - are explored in Appendix D.

B.4 Open Question for Later

When real userspace work begins, the first user-facing design decision is how Unix-flavored the interface should feel - Genode-style superficial familiarity (the Unix word works, even though it is a capability-bearing service) versus a fully fresh vocabulary. Either is defensible. Picking deliberately matters because retrofitting later is painful.

Answered 2026-09-23, and by a third option neither of those two is. The vocabulary is fresh - dir, read, delete, copy, match, count - and the shell keeps a table of the Unix and DOS words (FOREIGN_HINTS) purely so that typing the reflex tells you the Godspeed word. It is a HINT and never an alias: ls does not run, it answers try \dir``. So familiarity is spent on DISCOVERY rather than on compatibility, and nobody ends up writing scripts against a second set of names that has to be supported forever.

Recorded here because this appendix is where the question was posed, and an open question that has been answered is worse than one that never was - the next reader takes it as still open. The mechanism is in services/shell; scripts/foreign_word_check.py reads that same table to make sure no document in this repository shows one of those words being used as a command, which is how the ls -> dir rename was found to have missed ten worked examples.

Note 2026-10-13: two Unix words are commands, cd and pwd, and each has a reason. cd was kept from the start because it is universal (utilities/17_cd.md). pwd joined it with the filesystem layout (docs/filesystem-layout.md 6): the prompt is a plain gsh> , so something has to say where you are, and pwd is cd's other half - anyone who knows one knows the other. It left FOREIGN_HINTS, where its hint had been cd ., which asked a reader to know a Unix convention (.) without naming anything. The rule above is unchanged: every other foreign word is still a hint and never an alias, and a new exception needs a reason of its own written beside it, as this one is.

Note 2026-10-14: and a third, shutdown, with its reason. It is the word Linux, Windows and macOS share, and it names the ACT - ending the session - rather than promising a mechanism: on the T630, the Pis and the VisionFive the machine halts without losing power, so poweroff would claim what does not happen (docs/shutdown.md). It left FOREIGN_HINTS, where its hint had been reboot - wrong advice, since reboot turns nothing off - and halt and poweroff stay hints that answer shutdown.


Appendix C: Forward-Looking Vision (Non-Normative)

Status: Non-normative. Records design intent for post-v1 directions. Items may be deferred indefinitely, redesigned, or rejected when their time comes. This appendix does not amend the constitution.

C.1 observe - Native Top/Htop Equivalent

Tentative timeline: v2 candidate.

A native introspection tool, distinct from any third-party utility, that surfaces what the system is doing.

The architecture makes this easier than the equivalent on Unix. Per-service state is already structured: caps held, assigned core, memory request/limit, current allocation, IPC queue depths, generation, liveness. The supervisor already knows about every service. The kernel already tracks per-task state.

observe would be a service holding capabilities to query the supervisor and a kernel-provided introspection endpoint, formatting the result for display. There is no need to parse a /proc-style text interface - the data is already structured.

C.2 Native Metrics Emission

Tentative timeline: v2 / v3 candidate.

The system should emit metrics by default, without requiring third-party agents.

Architectural intent: the kernel emits structured events (IPC volume, cap denials, scheduler statistics, restart events) on a known endpoint. This is a publication, not a feature baked into kernel logic - the kernel does not know what format consumers want. A separate metrics service holds the consuming capability and exports in whatever format is appropriate (Prometheus exposition, OpenTelemetry, statsd, custom).

Constraint: this must not pollute kernel scope. The kernel publishes; the metrics service interprets.

Note (2026-08-31): the "known endpoint" above is the part to re-examine; the constraint under it is exactly right. This appendix predates the trace utility, which shipped with ZERO kernel growth - the ring lives in the logger service and the instrumentation in the SDK. The reasoning that kept it out of ring 0 applies here: a kernel event queue needs a bound, a bound needs a drop policy, and deciding what to discard is a judgement (§26.10). A kernel-held endpoint to a RESTARTABLE consumer is worse still - stale, with nobody above it to recover it (§14.3).

(Update 2026-09-04: that service is now called events - see the §11.4 amendment. The note is left as written, including the old name, because it is dated; only this line is added, so a reader is not sent looking for a service that no longer answers to it.)

Kernel STATE is already published by pull (InspectKernel, 25 queries, and TaskStat, gated by INTROSPECT). Kernel EVENTS can ride the §11.4 shape: a bounded ring the kernel writes and a service DRAINS - no endpoint, no consumer identity, no policy in the kernel. docs/observability.md has the full argument and the three-part test any new mechanism must pass first. Non-normative, as is this appendix.

C.3 Cluster Mode (Single-System-Image)

Tentative timeline: multi-year research direction. Not a milestone commitment for any specific version.

The ambition: multiple GodspeedOS instances, possibly identified by a shared token, joining together to act as a single system. Conceptually similar to what k8s does for containers, but at the OS layer: services on any node, IPC across nodes, single namespace.

Why this is unusually feasible architecturally:

  • Invariant 11 ("identity is stable; location is not") is the design primitive distributed systems are built on. The routing table generalizes cleanly: EndpointId → CoreId becomes EndpointId → (NodeId, CoreId). SMP already separated identity from execution location; cluster mode extends "location" from a core to a (node, core) pair without changing the philosophy.
  • The generation-number mechanism for cross-core revocation extends to cross-node revocation.
  • The absence of POSIX, fork, shared memory, and ambient authority means none of the impossible-to-distribute primitives are baked in. Linux clustering attempts (OpenSSI, MOSIX) all foundered on mmap, signals, and inherited file descriptors.

Why it is still a multi-year project, not a v2 weekend:

  • Synchronous IPC does not survive network latency. Either remote IPC must be a different primitive (loses transparency), or "blocking send" must mean something different cross-node (semantic fork in the API).
  • Cluster membership, leader election, and consensus are real distributed-systems problems (Raft / Paxos territory).
  • Cryptographic capability transfer over a network - caps must remain unforgeable when crossing the wire.
  • Cross-node TCB definition: does the supervisor on node A have authority over services on node B? If yes, a compromised supervisor compromises the cluster. If no, what coordinates restarts?
  • Failure semantics expand: not just node crashes, but network partitions, split-brain, and partial failures.

Reference points:

  • Plan 9 from Bell Labs achieved network-transparent files and processes 30+ years ago.
  • Barrelfish (Microsoft Research) explored "the OS as a distributed system" via the multikernel model.
  • MOSIX, OpenSSI, LOCUS - Linux/Unix attempts at SSI; useful for understanding what didn't work and why.

The architectural primitives in this constitution are unusually well-suited for revisiting these ideas. Whether and when to attempt it is open.

C.4 Cluster Routing: Architecture and API

Full design notes, failure semantics, flow control, ordering, registry scope, transport options, and TCB authority model live in docs/cluster-design.md. This section records the headline conclusions.

The routing table generalizes from EndpointId → CoreId to EndpointId → (NodeId, CoreId). The invariant "identity is stable; location is not" already encodes this - cluster mode extends the definition of location without changing the philosophy. The generation mechanism handles cross-node service mobility identically to how it handles cross-core restarts today.

The remote IPC API uses a distinct call surface (send_remote with explicit timeout) rather than transparent routing. The existing constitution invariants settle this: a successful local send guarantees queue delivery on this machine; a successful remote send guarantees handoff to a transport. These are different contracts with different durability and failure obligations, and pretending otherwise is the architectural mistake transparent-clustering systems have historically made. The network boundary is visible at three layers: contract (ipc_send_remote), type system (RemoteSendCap vs LocalSendCap), and call site. Applications that never declare ipc_send_remote are entirely unaffected by cluster membership.

Three questions must be resolved before cluster mode can ship: the transport protocol (which shapes failure semantics and ordering guarantees), the registry consistency model (distributed name resolution is the largest single piece of work, comparable in scope to most of v1), and cross-node TCB authority (whether a supervisor on node A governs services on node B - the central security question for clustering).


Appendix D: Shell, Scripting, and Utility Ecosystem (Non-Normative, Far Future)

Status: Non-normative, far-future. Records the design intent for what shell scripting could look like on GodspeedOS, and what porting Unix-style utilities would actually require. This appendix does not amend the constitution.

D.1 Why This Is Possible

The capability constraints in the constitution do not forbid shell scripting. They reshape what scripting is under the hood.

The fundamental abstraction of a shell script is "compose a sequence of commands and feed data between them." Nothing in the constitution forbids that. What changes is how composition happens.

D.2 Walking Through Bash Primitives

Bash primitiveGodspeedOS mapping
Sequential (cmd1; cmd2)Shell calls supervisor.spawn(cmd1), waits, then spawns cmd2. Unchanged.
Variables, if, whileEntirely shell-internal; never touch the OS. Unchanged.
Pipes (cmd1 | cmd2)Capability-mediated. See D.3.
Redirection (cmd > file)Shell creates the file via FS, mints a WRITE cap, grants to cmd. Same UX, different mechanism.
Substitution (x=$(cmd))Shell sets up cmd with a SEND cap to a shell-owned endpoint, captures the output, binds the variable.
Background (cmd &)Spawn without waiting. Trivial.

D.3 The Pipe Pattern

The interesting case is the pipe, because Unix pipes rely on fork and inherited file descriptors - neither of which exists.

In GodspeedOS, cmd1 | cmd2 becomes:

  1. Shell creates a fresh IPC endpoint.
  2. Shell grants cmd1 a SEND cap to that endpoint, via its spawn contract.
  3. Shell grants cmd2 a RECV cap to the same endpoint, via its spawn contract.
  4. cmd1's "stdout" is "send to the cap I was granted."
  5. cmd2's "stdin" is "recv from the cap I was granted."

Pipes still exist. They are capability-mediated rather than fd-mediated.

The user-facing syntax can look exactly familiar:

ls /data | grep .txt > files.list

The shell does more capability brokerage than bash; the user sees the same thing.

D.4 The Security Upside

Capability-mediated scripting is meaningfully safer than POSIX shell by construction.

rm -rf / cannot exist on GodspeedOS unless the user (via the shell) explicitly grants the rm service a WRITE cap to /. The shell becomes the deliberate place where authority is decided. It can refuse dangerous compositions, ask for confirmation, or apply policy.

There is no equivalent of "ambient authority leaked through fork-exec and now the wrong process deleted everything."

D.5 Language Design - Open Question

The script language syntax does not fall out of the architecture. Three plausible directions:

DirectionTrade-offs
POSIX-flavoredFamiliar muscle memory; inherits 50 years of warts (word-splitting, quoting rules, [ vs [[).
Modern shell(fish, nushell, oil-inspired) Structured data, better composition, fewer footguns.
Embedded languageShell hosts a real language (Lua, Rhai, custom DSL); scripts are programs in that language.

Likely lean: modern-shell-inspired. POSIX-shell quirks exist for historical reasons that don't apply here.

D.6 What "Porting" GNU Utilities Would Actually Mean

A common assumption is that supporting Unix-style utilities means "port GNU coreutils." This is misleading.

It is not a port. It is a rewrite. GNU coreutils is hundreds of thousands of lines of POSIX-specific C. You cannot recompile it against a new libc and expect it to work, because the underlying primitives don't exist:

  • No fork / exec.
  • No errno.
  • No POSIX signals (no SIGPIPE, SIGINT, SIGTERM handlers).
  • No inherited file descriptors.
  • No environment-variable inheritance.
  • No process groups, sessions, or terminal control as POSIX defines them.

Each utility has to be rewritten in Rust (or whatever) against the GodspeedOS SDK. Contract per utility. Capabilities for everything it touches. IPC for what it produces and consumes.

The rewrite is genuinely simpler per-utility, however, because the OS handles the plumbing that POSIX utilities have to handle themselves:

GNU cat worries aboutGodspeedOS equivalent
errno translationTyped Result<> from the SDK
Signal handlersNone needed
Permission checksEither you have the cap or you don't
Symlinks, special filesFS service handles; utility doesn't see them
getopt edge casesRust argument-parsing crate
Cross-platform abstraction layersSingle platform
Memory safetyFree with Rust

A cat-equivalent in GodspeedOS is on the order of 30 lines of Rust. GNU cat is hundreds of lines, mostly handling POSIX corner cases that GodspeedOS doesn't have.

D.7 Realistic Expectations

The GNU community is heavily invested in POSIX. Existing GNU code is unlikely to be ported by its existing maintainers; the architectural mismatch is too large.

A more realistic scenario is a fresh community building a coreutils-equivalent for GodspeedOS from scratch in Rust, motivated by the architecture rather than by carrying forward existing code. The friendliness this appendix describes is friendliness to the rewrite, not friendliness to existing C source.

Bash compatibility is explicitly not on offer. A shell language that resembles bash superficially but is its own thing underneath is fine; pretending bash scripts will run unmodified would be a lie that bites users later.

D.8 Why This Belongs in Far-Future Scope

None of this is v1, v2, or v3 work. It is what becomes possible once:

  • The kernel and TCB are stable.
  • A real userspace exists (events, fs, network).
  • A supervisor API for service-initiated spawn (with cap delegation) exists - currently supervisor.spawn is supervisor-internal.
  • The userspace community has formed enough to have opinions on what shell language to design.

This appendix exists so that, when that time comes, the design intent is on record and the architectural reasoning is preserved.


26. Architectural Discipline

The architecture survives only if the discipline survives.

The greatest long-term risk to this system is not memory corruption, race conditions, or hardware failure.

The greatest risk is gradual architectural erosion:

  • hidden complexity;
  • convenience abstractions;
  • implicit behavior;
  • speculative extensibility;
  • silent fallback paths;
  • and features that weaken the model in exchange for short-term ergonomics.

This section exists to preserve the architectural mindset that produced the system.


26.1 The Model Is The Product

GodspeedOS is not merely a collection of kernel features.

The value of the system is the coherence of the model:

  • explicit authority;
  • bounded behavior;
  • visible failure;
  • typed capabilities;
  • restartability;
  • and identity separated from execution location.

A feature that weakens the model weakens the system, even if the feature appears useful.

The architecture itself is the product.


26.2 Features Must Be Pulled Into Existence

Features are added because:

  • a constitutional invariant requires them;
  • an identity test requires them;
  • a real operational problem requires them;
  • or a demonstrated implementation limitation requires them.

Features are not added because:

  • they may be useful later;
  • other systems have them;
  • they feel incomplete without them;
  • or they might support hypothetical future flexibility.

Speculative abstraction is architectural debt.

The preferred state of an unneeded feature is:

not implemented; will be implemented when a test requires it

Deferral is a design decision, not a weakness.


26.3 Identity Tests Define System Identity

Identity tests are not implementation tests.

They are executable constitutional invariants.

A passing identity test means:

  • the implementation still matches the model;
  • the guarantees in this document still hold;
  • the architectural invariants still compose correctly;
  • and the system is still the system this document describes.

A failing identity test is evidence of architectural drift.

If an identity test fails:

  • either the implementation is wrong;
  • or the constitution has changed.

There is no third category.


26.4 No Silent Complexity

Complexity must remain visible.

Reject:

  • hidden retries;
  • invisible caching layers;
  • implicit authority escalation;
  • silent transport substitution;
  • transparent distributed semantics;
  • automatic fallback behavior;
  • convenience APIs that weaken guarantees;
  • or abstractions that obscure ownership or failure.

The system should always make it possible to answer:

  • where authority came from;
  • where state lives;
  • who owns the state;
  • what failed;
  • what guarantees exist;
  • what the timeout boundary is;
  • and what recovery obligations now exist.

If those questions become difficult to answer, the abstraction is too opaque.

Visible caching is not silent complexity. The rejection above is of invisible caches: ones that can drift and lie with no way to tell. A derived view (a cache, index, or precomputed count) is legitimate when it reduces to a single irreducible source, is reconciled when it drifts (a repair path rebuilds it from that source), and is subordinate (the source wins; the view is never authoritative). The free bitmap and free count of fs are exactly such views of the file tree: stored, but not a second truth. The line this section draws is between a visible, reconcilable derived view (allowed) and a hidden, unreconcilable one (rejected). This is Commandment III (COMMANDMENTS.md); see also §3.8 and §3.9 / Invariant 9.


26.5 Explicitness Over Magic

The system intentionally prefers explicit operations over implicit convenience.

Examples:

  • explicit capability passing instead of ambient authority;
  • explicit restart instead of live mutation;
  • explicit remote IPC instead of transparent clustering;
  • explicit reacquisition instead of silent rebinding;
  • explicit queue limits instead of elastic growth;
  • explicit contracts instead of inferred permissions.

This is not accidental minimalism.

It is how the system preserves mechanical honesty.


26.6 Bounded Behavior Over Optimistic Behavior

Every subsystem should have:

  • bounded memory usage;
  • bounded queue growth;
  • bounded retry behavior;
  • bounded authority;
  • bounded execution scope;
  • and bounded failure semantics.

Unbounded behavior eventually becomes undefined behavior under load.

If a subsystem cannot explain:

  • its limits;
  • saturation behavior;
  • failure mode;
  • and recovery strategy;

then the subsystem is incomplete.

26.6.1 Bounded memory means stack and arenas, not heap (settled direction)

The default mechanism for bounded memory is no heap. State lives in fixed stack arrays, in bounded reusable arenas (a fixed region + a bump pointer, reset between operations), or in immutable rodata - never behind a general allocator. The heap reflex is resisted by default.

This is not asceticism; it is how the bound stays visible. A fixed footprint can be read off the source - the maximum a subsystem can use is right there. There is no allocator in the trusted base to fragment, to fail in the middle of an operation, or to turn memory use into a runtime mystery. And overflow fails the GodspeedOS way: loud, into a guard page, killing one service - never a silent slide into thrashing. A heap erases each of those properties, which is why it is the exception (declared, scoped) and not the default.

When a working set feels too big for the stack, the move is not to reach for a heap - it is to change the representation so the working set is small:

  • stream in fixed chunks instead of buffering the whole thing (the edit piece table holds one window of an arbitrarily large file, never the file; read/write/copy stream in IO_CHUNK pieces);
  • refer to data by (offset, len) spans instead of copying it (piece spans; interned string ids in the records Table);
  • give a subsystem its own named, bounded arena instead of a shared allocator (the records arena), reset between uses;
  • iterate with an explicit bounded stack instead of call-stack recursion (tree's walk).

format_args! is already bounded - it does not allocate. The no-heap rule forbids a growable buffer, not lazy formatting. Building output with format_args! - and logging it with the SDK's log_fmt, which renders through a fixed 256-byte stack buffer - IS the bounded discipline, not a violation of it. Do not hand-roll digit-by-digit number formatting to "avoid the heap" that format_args! never touches; reach for log_fmt (or write! into a fixed-size array) instead. Over- applying this section - refusing the sanctioned bounded tool and reinventing it - is itself the mistake.

A hard ceiling reached loudly is therefore a feature, not a missing heap: it says "rethink the working set." (The selfcheck | write stack overflow was exactly this lesson - the fix was to forbid the unbounded nesting, not to add a heap.)

This is simplicity as much as fault tolerance (§2.3). The bounded representation is usually also the clearer one, because the constraint forces you to name the working set instead of hand-waving it onto a heap. Per §26.13 the bar is simple-and-bounded, not clever-and-cramped: a stack-only design that is harder to read than a heap one is a regression, not a win. The goal is the right data shape - a piece table, a ring buffer, an arena - over which boring code is small by construction.


26.7 Loud Failure Over Hidden Recovery

The system must prefer visible failure over ambiguous state.

A loud crash is preferable to silent corruption.

A rejected operation is preferable to undefined semantics.

A restart is preferable to hidden mutation.

A visible timeout is preferable to indefinite waiting.

A reported failed recovery is preferable to a hidden one.

The developer must never be forced to guess whether:

  • an operation succeeded;
  • a message was delivered;
  • authority still exists;
  • or the system silently degraded behavior.

The kernel boundary must remain semantically honest.

A recovery that itself fails is still a failure. Retrying, reacquiring, or falling back is the right response to a failure - but when the recovery step also fails, that outcome must remain as visible as the original failure. Proceeding as though a failed recovery had succeeded - discarding its result and continuing - converts a loud failure into a silent one and hides exactly the state an operator needs to see. Every failure path, including a retry that ultimately fails, is reported, never swallowed. This holds in any language: an ignored error return is the same silent fallback as a suppressed exception.

A limitation that cannot be closed is RECORDED, never papered over. The rule above governs what the system does at runtime; this governs what the project says about itself, and it is the same refusal to hide. Some gaps cannot be closed today - the hardware has no such unit, the fix is real work and not a constant, the honest guarantee is narrower than the one already claimed. The temptation is to leave the broader claim standing because it is nearly true. Write the gap down instead: state plainly what does NOT hold, where, and why, at the place a reader would otherwise rely on the claim.

A recorded limitation is a thing the next person can fix and can plan around. An unrecorded one is discovered by whoever trusts the document, usually at the worst moment, and it costs them the right to trust the rest of it. This is why a narrowed guarantee is amended into the text rather than quietly left; §6.1's backend-conditional crash-recovery claim and its ARM DMA posture are both here for exactly this reason.


26.8 Identity Over Location

Services are identities, not placements.

Core assignment is an execution detail.

Node assignment is an execution detail.

The location of execution may change across restart boundaries without changing system identity.

The architecture must continue to separate:

  • who a service is;
  • from where a service currently executes.

Location is mutable. Identity is stable.


26.9 Authority Must Remain Visible

Authority should always be inspectable and traceable.

A reviewer should be able to determine:

  • what a service can do;
  • where the authority came from;
  • which capability granted it;
  • and whether the authority can be revoked.

No subsystem may gain authority through:

  • process ancestry;
  • ambient inheritance;
  • hidden globals;
  • implicit trust;
  • or side effects.

Authority is granted deliberately or not at all.


26.10 The Kernel Is Mechanism, Not Policy

The kernel exists to enforce invariants, not interpret intent.

The kernel provides:

  • isolation;
  • scheduling;
  • capability enforcement;
  • IPC routing;
  • memory protection;
  • interrupt routing;
  • and bounded primitives.

The kernel does not:

  • guess developer intent;
  • silently reinterpret contracts;
  • optimize correctness away;
  • or make policy decisions on behalf of services.

Policy belongs in services.

Mechanism belongs in the kernel.


26.11 Understandability Is A Hard Requirement

The system must remain understandable by a single engineer.

The "30-minute whiteboard rule" is mandatory:

  • a contributor should be able to explain the architecture,
  • the capability flow,
  • the restart model,
  • the IPC semantics,
  • and the failure model

on a whiteboard in roughly 30 minutes.

If understanding requires:

  • tribal knowledge;
  • hidden runtime behavior;
  • undocumented interactions;
  • or layered abstractions across dozens of components;

then the system has exceeded its acceptable complexity budget.


26.12 Correctness Before Performance

Performance matters.

Correctness matters more.

Predictability matters more than peak throughput.

The system intentionally accepts:

  • extra copies;
  • extra validation;
  • explicit synchronization;
  • and visible restart boundaries

when they preserve architectural clarity and bounded correctness.

Optimization is permitted only when:

  • invariants remain visible;
  • failure semantics remain unchanged;
  • observability is preserved;
  • and the resulting system is no harder to reason about.

26.13 Discipline Over Cleverness

Clever code is not an achievement if it weakens the model.

The preferred implementation is:

  • boring;
  • explicit;
  • inspectable;
  • testable;
  • restartable;
  • and mechanically honest.

The system should resist:

  • abstraction for its own sake;
  • framework-style indirection;
  • meta-programming layers;
  • hidden runtime behavior;
  • and architecture driven by novelty.

A smaller coherent system is preferred over a larger impressive one.


26.14 Borrow The Mechanism, Never The Model

Reference implementations are how this project learns hardware. Linux, BSD and u-boot are read as executable datasheets: which register the silicon needs written, in what order, with what value, and what the device does when you get it wrong. That reading is encouraged and has repeatedly been the difference between a working driver and a week of guessing.

What is borrowed is the SILICON'S requirement. What is not borrowed is the other system's model.

Those two are easy to confuse, because they arrive in the same file. A register write sequence is a fact about the hardware and belongs here unchanged. An ambient global, an unbounded buffer, a heap allocation on a hot path, a driver living in the kernel because that is where the reference put it - those are that system's answers to that system's constraints, and they are not facts about anything. Importing them because "Linux does it" is how a model rots while every individual change looks justified.

The test is one question: is this a property of the device, or a property of their design? Ordering, timing, magic values, error semantics, what a bit means - the device. Where the state lives, who owns it, how it is allocated, what happens when it fails - ours to decide, under the constitution.

Divergence from a reference is therefore normal and does not need apologising for; it needs recording. When this system deliberately does something differently, say so at the point of difference and say why, so the next reader knows it was a decision and not an oversight. Silent divergence is how a port acquires bugs nobody can explain; unexplained convergence is how it acquires violations nobody noticed.

26.15 Preserve The Invariants

Every contributor is responsible for preserving:

  • identity over location;
  • explicit authority;
  • bounded behavior;
  • typed capabilities;
  • failure visibility;
  • restartability;
  • and no silent fallback.

If a proposal weakens those principles, the burden of proof is on the proposal.

The default answer to architectural uncertainty is:

  • simplify;
  • reduce scope;
  • make the behavior explicit;
  • and preserve the invariants.

26.16 Final Reminder

The system is allowed to evolve.

The model is not allowed to rot.

Every feature, abstraction, optimization, and subsystem must justify its existence against the constitution.

If the implementation becomes easier to extend but harder to reason about, the system has regressed.

Correctness is not enough.

The architecture must remain coherent.

The Ten Commandments of Godspeed

These Commandments define the architectural boundaries of Godspeed. They are not coding guidelines or stylistic preferences. They exist to preserve a system that remains simple, recoverable, and understandable as it grows.

They are the human-readable distillation of the constitution in CLAUDE.md - each is grounded in the invariants and sections it enforces. Where a Commandment and the code disagree, the code is wrong (CLAUDE.md §1). Where a Commandment and the constitution disagree, the constitution is the law and this document is amended to match.

For a catalog of concrete violations of these Commandments - each paired with the correct pattern and grounded in the section it breaks - see docs/anti-patterns.md, the field guide to constitutional violations.


Respect Godspeed

I. Thou shalt not expand the responsibilities of the kernel. It is complete. Use a service.

The kernel's responsibilities are complete: memory isolation, scheduling, IPC, capability enforcement, interrupt routing, and cross-core routing - and nothing else. New hardware support, new CPU architectures, and bug fixes are welcome. New responsibilities are not.

Before proposing a kernel change, ask: "Why isn't this a service?" If the answer is "because it is more convenient," it belongs outside the kernel.

Why the kernel stays tiny - and why that boundary is a reward, not a restriction.

The kernel is audited regularly and exhaustively against these Commandments (the living record is audits/kernel-audit.md).

The cost of that audit does not grow linearly with the kernel - it grows with the interactions between its responsibilities. A real audit never examines each responsibility in isolation; it cross-checks every responsibility against every other, looking for shared state, lock ordering, fault propagation, hidden assumptions, and the cracks between subsystems where the most dangerous bugs emerge.

Every new responsibility therefore creates more than additional code. It creates additional interactions that must be understood, verified, and trusted. In practice, N responsibilities produce roughly N² relationships to reason about.

A tiny kernel can be audited end-to-end. Its guiding promise - that nothing above it may panic or wedge it - can be continuously verified rather than merely hoped for. Add one more responsibility, and the increase in trust required is far greater than the increase in code.

A bounded kernel is therefore not a limitation imposed on the architecture. It is the property that makes the architecture continuously auditable, and therefore trustworthy. Every responsibility refused permanently reduces future audit effort, shrinks the privileged attack and failure surface, and keeps the kernel's guarantees affordable to verify.

Refuse responsibility, and be rewarded.

Grounded in: §4.3-§4.4 (kernel scope and anti-scope), §26.10 (the kernel is mechanism, not policy), §26.11 (understandability is a hard requirement - the 30-minute whiteboard rule), §22 (the identity/audit suites that keep the guarantee executable), Invariant 4 (the kernel remains tiny).


II. Thou shalt love Chaos and trust in it. Thy service shall pass through Maximum Carnage.

Passing unit tests does not prove correctness. Every service shall eventually face failure, memory pressure, restart storms, spawn storms, queue floods, and whatever new forms Chaos invents.

If Chaos finds a bug, the bug already existed. Treat Chaos as your teacher, not your enemy.

Grounded in: §22 (the identity, stress, adversarial, and chaos suites - and chaos max-carnage), §2.3 (execution over theory), §26.7 (loud failure over hidden recovery).


III. Thou shalt not duplicate truth. Store irreducible facts. Derive the rest.

There must be exactly one irreducible source of truth - the minimal state from which everything else can be reconstructed, and which cannot itself be reduced to anything smaller. Everything else is a derived view of it.

This is not a ban on caches, indexes, or precomputed counts. A derived view may be stored, and may even drift, provided it is reconstructible from the source, reconciled when it drifts (a repair path rebuilds it from the source), and subordinate - on any disagreement the source wins; the view is never authoritative. A filesystem's free bitmap is a stored index of its file tree (fsck rebuilds it from the tree); the free count is a stored index of the bitmap. Neither is a second truth - both reduce to the tree.

What is forbidden is a second irreducible truth: two stored facts that can diverge with no way to decide which is right, or trusting a derived view as if it were the source. The test is not "is it stored?" nor "can it drift?" - it is "does it reduce to one source, and does that source win?"

Grounded in: §3.8 (state explicit and owned), §3.9 / Invariant 9 (no unowned global mutable state), §26.4 (caching must be visible and reconcilable, never hidden).


IV. Thou shalt honor service contracts.

Services communicate through explicit contracts. Do not bypass a contract. Do not invent hidden communication paths. If a service cannot express its needs through its declared contract, redesign the contract - not the architecture.

Grounded in: §13 (service contracts), §3.7 / Invariant 7 (contracts are enforced, not interpreted), §13.6 (runtime enforcement from the SPAWN REQUEST at spawn; the contract is the build-time declaration reconciled against it, never the runtime source - amended 2026-09-14).


V. Thou shalt not assume thy service is special. Only the kernel is special.

Every service must be prepared to fail. Every service must be prepared to restart. No service is exempt. If the Supervisor itself must survive its own death - and it must, for the kernel respawns it

  • so must yours.

The only unkillable component is the kernel. Everything above it is identity, not location, and identity survives restart.

Being unexceptional cuts both ways. Just as no service is too important to restart, none is too important to admit when it could not recover. When a service tries to recover from a failure - a retry, a reacquire, a fallback - and that recovery itself fails, the failure is still a failure: surface it. A recovery whose own failure is discarded becomes a silent success, and the service then proceeds on a state it never actually reached - the very hidden failure this system forbids. Recover if you can; when you cannot, say so plainly.

Grounded in: §6.2-§6.3 (the supervisor is restartable; the unkillable set is {kernel} alone), Invariant 6 (services must be restartable), Invariant 11 (identity is stable; location is not), §26.7 (loud failure over hidden recovery), Invariant 12 (failures are loud, never silent).


Corrupt Not Godspeed

VI. Thou shalt not introduce shared mutable state.

Services communicate through IPC. Shared mutable state creates invisible coupling, destroys isolation, complicates recovery, and makes failure unpredictable. If multiple services require the same information, expose it through a service - not through shared memory.

Grounded in: Invariant 2 (no shared mutable memory by default), Invariant 9 (no unowned global mutable state), §2.5 (zero-copy IPC is permanently rejected for this reason).


VII. Thou shalt not introduce ambient authority.

Authority must always be explicit. A service may perform only the actions its capabilities grant. Hidden privilege eventually becomes both a security vulnerability and an architectural dependency.

There is no authority by identity, ancestry, or inheritance - only by capability.

Grounded in: Invariant 1 (no ambient authority), Invariant 3 (all authority is explicit), §7 (the capability system), §26.9 (authority must remain visible).


VIII. Thou shalt not rely upon timing for correctness. Wait for truth, not time. But ensure the truth includes failure.

Time does not prove correctness. Sleeping longer does not make a race disappear. Correctness must come from observable truth:

  • acknowledgements
  • state transitions
  • contracts
  • events
  • capabilities (and their generations)

Time may conserve CPU. It must never determine correctness.

And the truth must include failure. Waiting on the truth of success alone - the reply, the acknowledgement - is only half the discipline. If the thing you wait for dies or never comes, that truth never arrives and a pure success-wait hangs forever: a wait that cannot observe failure has quietly become an infinite wait on time. So failure is a first-class truth - a peer's death, a dead endpoint, a broken contract, a revoked capability - and you must wake on it exactly as you would on success. A caller blocked awaiting a reply must wake the instant its replier dies (ReplyDead), never hang. Only a truth that includes failure makes "wait on truth" complete.

Grounded in: §8.6 (acknowledgement is explicit - a successful send means queued, not processed; and failure is explicit - a caller blocked awaiting a reply wakes with ReplyDead when its replier dies), §7.5 (the generation check, not a delay, settles a restart race), §9.3 (yield is advisory), §22.4 (a test waits for observable output, never a fixed sleep).


IX. Thou shalt always plan for recovery, for thy service shall fail.

Failure is not exceptional. It is expected. Every service should recover cleanly without assuming a perfect world. A client whose dependency restarts must reacquire and retry, not crash.

And reacquiring is not just the endpoint. Anything the dependency's previous incarnation handed you - a connection or socket id, a handle, a cached value, a generation - is stale once it restarts, and must be re-established with the new instance or discarded. A fresh connection does not refresh what the dead one gave you; a cached view of a restarted peer's state is no longer the truth.

If recovery cannot be tested, it does not exist.

Grounded in: §14 (service lifecycle, restart, and cap rebinding), §14.3 (cascading failure is the client's responsibility), §6.2 (death is recovered, not a reboot), §22 (recovery is pinned by the chaos and identity suites).


X. Thou shalt place complexity where it belongs.

Complexity is sometimes necessary. Hidden complexity never is. Do not move complexity into the kernel because it is convenient. Do not push complexity onto users because it is easier. Place it in the layer that naturally owns it.

Good architecture is not the absence of complexity. It is complexity in the correct place.

Grounded in: §26.10 (the kernel is mechanism, not policy), §26.11 (the 30-minute whiteboard rule), §26.13 (discipline over cleverness), §26.4 (no silent complexity).


Final Admonition

These Commandments are not independent. Violate one and you eventually violate all.

Architectural corruption rarely begins with catastrophe. It begins with a single compromise:

"Just this once."

Over time, that compromise spreads in subtle and unpredictable ways until the architecture itself becomes difficult to reason about.

These Commandments were not written for style, tradition, or amusement. They were written in response to real bugs, real wedges, real failures, and real lessons learned while building Godspeed.

Treat them very seriously.

And when a compromise offers itself because the system does not yet work, know which way the trade goes:

A failing Godspeed that preserves its invariants is preferable to a working Godspeed that violates them.

This is not a love of failure. It is what a fix IS. A system that fails with its model intact is still Godspeed, and can be repaired. A system that works by breaking its model is another system wearing the name, and it has spent the only thing that made the rest of this document worth obeying.

So make it work WITHIN the model. Never widen the model to admit what is easier. And where the two truly cannot be reconciled, say so plainly and leave the gap open, for a recorded failure can be fixed by whoever comes next, and a quiet violation cannot even be found.

Grounded in: CLAUDE.md §25 (the principle that ranks the others) and §26.7 (a failure is reported, never swallowed - so a limitation that cannot be closed today is RECORDED, not papered over).

Of Borrowed Wisdom

Thou shalt read the works of others, for they have met this silicon before thee. Linux, BSD and u-boot are read here as executable datasheets, and that reading has many times been the difference between a working driver and a week of guessing.

But take from them the requirement of the DEVICE, and never the shape of THEIR HOUSE.

Both arrive in the same file, and only one of them is a fact:

  • Take the order of writes, the timing, the magic values, the error semantics, the meaning of a bit. These are properties of the hardware and are true wherever they are read.
  • Leave the ambient global, the unbounded buffer, the allocation on the hot path, the driver that lives in the kernel because that is where they put it. These are their answers to their constraints, and they are facts about nothing.

When in doubt, ask one question:

Is this a property of the device, or a property of their design?

To copy their design because they are respected is how a model rots while every single change looks justified. Where thou dost diverge from them, do not apologise - RECORD it, at the point of difference, with the reason. Silent divergence breeds bugs no one can explain; unexplained convergence breeds violations no one noticed.

Grounded in: CLAUDE.md §26.14. See also §26.6.1 - when their answer is a heap and thine cannot be, change the representation rather than the rule.

Tried by Fire

A contribution is not considered proven until it has passed through the fire.

The fire of Godspeed consists of seven complementary trials:

  1. Identity - Does it remain itself?
  2. Property - Does it preserve its invariants?
  3. Fuzz - Does unexpected input reveal hidden assumptions?
  4. Stress - Does pressure change correctness?
  5. Performance - Does it remain efficient under sustained load?
  6. Adversarial - Can deliberate attack break its guarantees?
  7. Chaos - Can it recover when the unexpected becomes reality?

Each trial has increasingly brutal variants. Passing one trial does not excuse failing another.

Grounded in: §22 - these seven are the categories of the test suite: the Identity tests and the six battle-hardening layers, each with a harsher "brutal" variant. A green check from one trial is necessary; it is never sufficient. This is Commandment II made into a gauntlet.

Blessings

Godspeed on thy journey.

  • May thy architecture remain uncorrupted.
  • May thy contracts remain true.
  • May thy services endure failure.
  • May truth be uncovered through fire.

Bankole Ogundero.

GLOSSARY.md

Abbreviations and Acronyms of GodspeedOS

This file expands the abbreviations and acronyms that appear across the GodspeedOS codebase, specification, and docs, with a one-line meaning grounded in how the project actually uses each one.

It is deliberately scoped to abbreviations and short forms. For architectural terms (capability, endpoint, generation, placement, routing table, supervisor, name directory, delegated resource capability, TCB, and so on) the authority is the Glossary in CLAUDE.md section 24 - read that for the prose definitions of the model itself. Where this file and CLAUDE.md disagree, CLAUDE.md wins.

Only abbreviations the repository genuinely uses are listed. Each was verified by grep before inclusion.

The table below is also read by a program: ask "what is MISCIS?" answers from it, read when ask is built (services/ask/build.rs), so a row's shape - three cells, the abbreviation first - is load-bearing, and a definition has one copy, this one.


Alphabetical list

AbbreviationExpansionMeaning in GodspeedOS
ABIApplication Binary InterfaceThe syscall ABI follows System V AMD64; it is part of the SDK's audited unsafe boundary (section 18.1) and any freestanding-ELF language can call it (Appendix B.2).
ACPIAdvanced Configuration and Power InterfaceFirmware tables; the kernel walks RSDP then RSDT/XSDT to the IVRS for IOMMU detection (H1). The kernel does not parse ACPI/MADT for SMP topology - Limine supplies that.
AHCIAdvanced Host Controller InterfaceThe SATA block-driver backend used on real hardware (the T630 SSD is AHCI-only); MMIO plus DMA, with command list / FIS / PRDT (docs/ahci.md).
AMD-ViAMD Virtualization (AMD's IOMMU)The IOMMU implementation behind H1 DMA-confinement; a per-device translation domain confines each DMA-capable driver to its granted arena.
APApplication ProcessorAny core other than the BSP; brought up via a real-mode trampoline (arch/x86_64/ap_boot.rs), then jumps to long mode and enters the idle scheduler loop.
APIApplication Programming InterfaceGeneral term for a service or supervisor call surface (e.g. the supervisor restart API, section 14.4).
APICAdvanced Programmable Interrupt Controllerx86 interrupt controller; Limine supplies APIC IDs of all cores so the kernel needs no MADT probe.
ATAAT AttachmentLegacy disk interface; the ATA PIO (no-DMA, least-privilege) block backend works under QEMU's legacy IDE, superseded by AHCI for real hardware.
BARBase Address RegisterPCI register locating a device's MMIO region; read during controller enumeration.
BDFBus / Device / FunctionA PCI device's address; the IOMMU confines a specific BDF to its DMA arena (boot prints confined BDF ..., section 6.4).
BIOSBasic Input/Output SystemLegacy firmware boot path; Limine supports both BIOS (via MBR stage-1) and UEFI from one bootloader.
BOOTX64.EFI(UEFI boot executable name)The Limine UEFI loader binary placed at /EFI/BOOT/BOOTX64.EFI on the ESP; firmware loads it, it loads Limine, which loads the kernel (Appendix A.4).
BSPBootstrap ProcessorThe first core to execute kernel code; performs kernel init and brings the APs online.
CASCompare-And-SwapScheduler Running-to-Ready transitions use CAS so a cross-core kill's Dead state is preserved (not clobbered by a plain store).
CLICommand-Line Interfaceosdev, the host-side CLI that builds, runs, publishes, and controls the OS in QEMU (section 17).
CMOSComplementary Metal-Oxide-SemiconductorThe MC146818 CMOS RTC chip the kernel reads for wall-clock date/time (arch/x86_64/rtc.rs).
COM1 / COM2Serial communication port 1 / 2COM1 carries the interactive shell console; COM2 is the control/harness channel used by osdev tests.
CRC3232-bit Cyclic Redundancy CheckChecksum used for disk-image and GPT integrity (osdev/src/crc32.rs).
DMADirect Memory AccessDevice-driven memory access; the unstated exception to "no ambient authority" (invariant 1) because a DMA engine can reach any physical address. Closed by IOMMU confinement (H1, section 6.4).
DoSDenial of ServiceA failure mode the design guards against - e.g. the supervisor respawn is unbounded deliberately so killing it N times cannot force a reboot (section 6.2).
EHCIEnhanced Host Controller InterfaceThe USB 2.0 controller driver; runs in transparent IOMMU passthrough (it legitimately reaches firmware/hub regions, section 6.4).
ELFExecutable and Linkable FormatService binary format; the spawner maps an ELF into a new address space; bit-flip-mutated ELFs are a fuzz surface (F3).
EOTEnd Of TransmissionThe 0x04 marker that ends one stream in a capability-mediated pipe (docs/pipes.md, Appendix D.3).
ESPEFI System PartitionThe FAT boot partition holding BOOTX64.EFI and the kernel; the boot region of a bootable GodspeedOS drive.
FAT / FAT32File Allocation Table (32-bit)The filesystem of the ESP boot region (Limine can read FAT but not GSFS); also the size-class GSFS sits in today because Phase-1 chose 32-bit fields.
FISFrame Information StructureAn AHCI command/data frame in the SATA transport.
FSFFree Software FoundationSteward of the GPL; the kernel is GPL-2.0-only, "the same license as the Linux kernel" (LICENSE, docs/licensing.md).
GDTGlobal Descriptor Tablex86 segment descriptor table set up during BSP init.
GPLGNU General Public LicenseThe kernel license (GPL-2.0-only copyleft); the SDK is permissive Apache-2.0, with the capability/IPC boundary as the license boundary.
GPTGUID Partition TableThe partitioning scheme of build/os.img (a UEFI GPT disk image).
GSFSGodspeed File SystemThe project's own on-disk filesystem (magic GSFS); the kernel never learns what a file is, fs owns it. GSFS0008 is the 8th on-disk format version (superblock + backup, free bitmap, self-describing file-record tree, crash-consistent redo-journal).
gshGodspeed shell (language)The .gsh shell scripting language (design sketch, docs/scripting.md); no_std, no heap, fixed/bounded storage by design.
HHDMHigher-Half Direct MapLimine pre-maps physical memory at a known high virtual address; the kernel uses the HHDM to read ACPI tables and physical frames.
HIDHuman Interface DeviceThe USB keyboard/mouse device class; the xHCI and EHCI drivers share sdk/rust/src/hid.rs.
IDTInterrupt Descriptor Tablex86 interrupt-vector table installed at BSP init.
IFInterrupt Flagx86 flag enabling interrupts; the supervisor respawn must run in the scheduler loop (IF=1), not inside a timer ISR (IF=0), so it can ACK TLB-shootdown IPIs.
IOMMUInput/Output Memory Management UnitTranslates every device DMA through a per-device page table; a domain mapping only the driver's arena confines a compromised driver and lets it leave the TCB (H1, docs/iommu.md).
IPCInter-Process CommunicationSynchronous, bounded-queue message passing between services; the core kernel primitive (section 8).
IPIInter-Processor InterruptOne core signaling another - used to wake a blocked cross-core recv and to drive TLB shootdowns.
IRQInterrupt RequestA hardware interrupt line; the kernel routes IRQs to userspace driver endpoints via IPC (section 12).
ISRInterrupt Service RoutineA kernel interrupt handler (e.g. the per-core timer ISR that enforces the quantum).
IVRSI/O Virtualization Reporting StructureThe AMD ACPI table describing AMD-Vi; if there is no IVRS the machine has no IOMMU, so DMA drivers stay in the TCB (reported loudly at boot, section 6.4).
JSONJavaScript Object NotationThe schema format (contracts/schema/service.schema.json) osdev uses to validate service contracts.
KVMKernel-based Virtual MachineQEMU hardware acceleration, enabled by the harness when /dev/kvm is available.
LAPICLocal APICThe per-core APIC; its periodic timer drives the 10 ms preemption quantum.
LBALogical Block AddressA disk block index; the block-IPC LBA field is u64 (docs/persistence.md).
MADTMultiple APIC Description TableThe ACPI table that would enumerate cores; not parsed in v1 because Limine supplies APIC IDs directly.
MBRMaster Boot RecordBIOS boot sector; limine bios-install writes Limine's stage-1 there.
MISCISMemory isolation, IPC, Scheduling, Capabilities, Interrupts, SMP routingThe kernel's six responsibilities, said like "misses" (CLAUDE.md 4.3). "Do not expand MISCIS" is the short form of 4.4: a seventh responsibility is a change to what the kernel IS, and scripts/commandments.py derives the six from that list.
MMIOMemory-Mapped I/ODevice registers accessed as memory; granted to a driver by an hw_mmio capability and used only through the SDK's safe Mmio wrappers (section 18.1).
MSIMessage-Signaled InterruptPCI interrupt delivery via a memory write; the current USB drivers use pin-based IRQs, with MSI the basis of the interrupt-driven driver branch.
NICNetwork Interface ControllerThe network device in the (design-only) networking stack; an IOMMU-confined e1000 driver for QEMU/Intel (docs/networking.md).
PCIPeripheral Component InterconnectThe device bus enumerated to find controllers (arch/x86_64/pci.rs).
PFPage FaultA protection violation; an access outside a mapped region kills the service (section 10.4), and a guard-page PF catches stack overflow.
PIOProgrammed I/O (port I/O)The no-DMA ATA disk backend - least-privilege because it cannot reach arbitrary memory the way a DMA engine can.
PRDTPhysical Region Descriptor TableThe AHCI scatter-gather list describing DMA buffers for a command.
QEMUQuick EmulatorThe virtual machine osdev run / osdev test boots the OS in.
RCURead-Copy-UpdateA candidate v2 replacement for the v1 global capability-table RwLock (requires benchmarks first, section 7.8).
RTCReal-Time ClockThe CMOS battery-backed clock read for uptime and the date command; uptime uses rtc::now_epoch_monotonic to deglitch.
RwLockRead-Write LockThe single global lock guarding the capability table in v1 (a known, benchmarked perf cost; section 7.8, benchmark B7).
RX / TXReceive / TransmitSerial (and network) data directions (e.g. polling UART RX in the timer ISR).
SATASerial ATAThe disk interface driven by the AHCI block backend.
SDKSoftware Development Kitsdk/rust/ - the typed capability/IPC/MMIO/DMA wrappers services build against; the only place outside the kernel's four layers where unsafe is permitted (section 18.1).
SemVerSemantic VersioningThe versioning scheme for contracts and the contract schema (breaking changes need a major bump).
SMPSymmetric Multi-ProcessingMulti-core support with static placement and no migration - cores discovered at boot, services pinned for life (section 9).
SPDXSoftware Package Data ExchangePer-file SPDX-License-Identifier tags mark each license zone (kernel GPL-2.0-only vs SDK Apache-2.0).
TCBTrusted Computing BaseThe set of components trusted to enforce isolation; deliberately shrunk over time until the non-restartable floor is {kernel} alone (sections 6.1-6.3) - DMA drivers excepted only on a machine with no IOMMU.
TCGTiny Code GeneratorQEMU's pure-software (non-KVM) JIT; cross-core wake timing differs under TCG, so some tests poll rather than block-wait.
TLBTranslation Lookaside BufferOn unmap (service death, memory reclaim) the kernel issues a TLB shootdown via IPI to every core and resumes only after all acknowledge (section 10.5).
TOCTOUTime-Of-Check-To-Time-Of-UseThe send-during-restart race; caught atomically by the generation check inside the send syscall (section 8.7, adversarial test A5).
TOMLTom's Obvious Minimal LanguageThe format of a service contract (contracts/<name>.toml), validated structurally against the JSON Schema.
TSCTime-Stamp CounterThe CPU cycle counter (read_tsc) used for the performance benchmarks; note CORE_TOTAL_TICKS counts scheduler quanta, not time.
UARTUniversal Asynchronous Receiver/TransmitterThe serial-port hardware carrying the shell (COM1, 115200 8N1).
UBUndefined BehaviorWhat unsound unsafe would produce; the spec's rule is "undefined behavior in spec becomes bugs in system" (section 25).
UAFUse-After-FreeA class of memory bug guarded against in the kill-path reclaim walk (a defensive PTE-validity guard skips and logs corrupt entries).
UEFIUnified Extensible Firmware InterfaceThe primary firmware boot path: firmware loads BOOTX64.EFI, then Limine, then the kernel.
USBUniversal Serial BusKeyboard and mouse input, driven by the userspace xHCI and EHCI services.
W^XWrite XOR ExecuteA page-table hardening invariant - no page is both writable and executable (the H4 kstack-guard / W^X work, section 18.5).
xHCIeXtensible Host Controller InterfaceThe USB 3.0 controller driver; IOMMU-confined to its DMA arena (section 6.4, identity test 12).

Naming schemes

A few families of identifiers are easier to understand as a scheme than as a list. Rather than enumerate every one, here is what each prefix means.

Amendment and program IDs (in CLAUDE.md)

- H-prefix (H1, H4, H11, ...) - numbered hardening / security amendments to the constitution. Each closes a specific gap with a written rationale. H1 = IOMMU DMA-confinement (closes the DMA hole in invariant 1); H4 = kstack-guard / W^X hardening; H11 = making registry a restartable userspace service (it left the TCB). - P-prefix (P2) - a feature/persistence amendment. P2 = file-as-capability (delegated resource capabilities, section 7.10). (Note: P1-P10 separately label the property tests - see below; context disambiguates.) - "Path C" - the chosen end-state of the naming-out-of-kernel redesign (docs/naming-design.md section 3.7): name policy lives in the supervisor while the kernel keeps only a minimal gated recovery directory. - Phases (Phase 4, Phase 5, Phase 6, Phase D, ...) - the ordered implementation steps within an amendment program. For example, in Path C: Phase 4 retires the registry service, Phase 5 removes init, Phase 6 makes the supervisor restartable; the "Phase D" amendment dropped block-driver and fs from the TCB once fs gained crash-consistent recovery.

Test-suite IDs (CLAUDE.md section 22, tests/qemu/)

The identity suite numbers its constitutional tests (Test 1 ... Test 15). The battle-hardening categories use a letter prefix plus a number:

- (Identity) - Tests 1-15, the executable constitution; a failure is a constitutional violation. - P - Property tests (P1-P10): universal invariants under randomized inputs. - F - Fuzz tests (F1-F8): the kernel must never panic on user-controllable input. - S - Stress tests (S1-S10): no drift, leak, or corruption under sustained load. - B - Performance Benchmarks (B1-B10): latency/throughput numbers tracked against a baseline. - BP - the Brutal Perf variant of the benchmarks (BP1-BP10): the same metrics run in a contended "perf-brutal" build, the form quoted in the hardware results table. - A - Adversarial / red-team tests (A1-A10): capability isolation under direct attack. - C - Chaos tests (C1-C7): graceful degradation under partial failures. - "brutal" - in general, the harsher contended variant of a suite (e.g. perf-brutal).

The name directory

Not an abbreviation but worth stating once: the name directory is the kernel's minimal name -> EndpointId map (ipc::names) plus a gated "mint a SEND cap by name" call (AcquireSendCap). It is the bounded recovery anchor that replaced the retired registry service (Path C / Phase 4): the supervisor wires services from a name -> cap map, and clients reacquire a service by name through this directory after a restart.

Amendment shorthand

CLAUDE.md and the docs refer to past amendments by short codes. This decodes the common ones; each is defined in full at its cited section.

CodeMeaningWhere
H1IOMMU DMA-confinement: an IOMMU (AMD-Vi) confines a DMA-capable driver to its granted arena, making it least-privilege and restartable, so it drops out of the TCB where an IOMMU exists.§6.4, docs/iommu.md
H11The registry left the TCB - it became a real userspace name service (holding only delegated caps), later retired entirely (see Path C / naming Phase 4).§6.1
P2File-as-capability: delegated resource capabilities - a service owns a resource's meaning while the kernel mints, validates, routes, and revokes its cap.§7.10
Phase Cfs gained crash-consistent recovery: every metadata mutation commits through a redo-journal, replayed on mount to a consistent state.§15, docs/persistence.md §6.8
Phase Dblock-driver + fs became restartable and left the TCB (made safe by Phase C's recovery); their death is a supervisor restart, not a reboot.§6.1
Path CNaming moved out of the kernel: the supervisor wires services from a name -> cap map; the kernel keeps only a minimal gated recovery directory. Reduced the unkillable set to {kernel}.§3.7, docs/naming-design.md
naming Phase 4The registry service was retired into the kernel's name -> EndpointId directory.Path C
naming Phase 5init was removed; the kernel spawns the supervisor directly.Path C
naming Phase 6The supervisor was made restartable - the kernel respawns it, unconditionally, forever.§6.2, Path C

Hardware and Systems Abbreviations

The short forms that appear throughout the kernel, the drivers, and the design docs. Each is listed because the repository genuinely uses it (verified by grep), and defined by how GodspeedOS uses it - not by its most general industry meaning. Architectural terms remain the province of CLAUDE.md section 24; these are the abbreviations you meet while reading the code.

CPU, interrupts, and time

- ARM - Advanced RISC Machines: the instruction set family GodspeedOS runs on besides x86_64. Two ports exist and they are genuinely different machines, not one with a flag: arm32 (ARMv7-A, Raspberry Pi 2, four Cortex-A7 cores) and AArch64 (ARMv8-A, Raspberry Pi 4). Each brings its own boot path, its own USB host controller, and its own timer. - APIC - Advanced Programmable Interrupt Controller. The interrupt hardware the kernel drives for timers and inter-core signalling. - LAPIC - the local APIC, one per core. Owns that core's timer and receives its interrupts. - IOAPIC - the I/O APIC: routes device interrupt lines (legacy INTx) to a core's vector. - LVT - Local Vector Table. The LAPIC registers that say which vector a local source (e.g. the timer) raises, and whether it is masked. - ISR - Interrupt Service Routine: the handler that runs when a vector fires. - IRQ - Interrupt ReQuest: a device's interrupt line or its assigned vector. - IPI - Inter-Processor Interrupt: one core interrupting another (used for wakeups and TLB shootdown). See also CLAUDE.md section 24. - NMI - Non-Maskable Interrupt: reaches a core even with interrupts disabled, which is why the panic path uses it to halt every core (SEC-18). - MSI / MSI-X - Message Signalled Interrupts: a device raises an interrupt by writing a message rather than asserting a pin, so it can be targeted at a chosen core. - TSC - Time Stamp Counter: the CPU's cycle counter. TSC-Deadline is a one-shot timer mode armed by writing a deadline; the kernel re-arms it every tick. - PIT - Programmable Interval Timer (i8254). Legacy but portable, so it is the ground truth the kernel calibrates the TSC and the LAPIC timer against. - ARAT - Always Running APIC Timer: a CPU flag meaning the LAPIC timer keeps ticking through deep C-states. Without it, halting an idle core can drop its wakeup. - C-state - a CPU idle power state. Deeper states save more power but can power-gate the APIC. - RTC / CMOS - the battery-backed real-time clock; the wall-clock source (date, uptime, and any pacing that must not trust the TSC). - SMP - Symmetric MultiProcessing: multiple cores running the same kernel.

Memory and addressing

- MMIO - Memory-Mapped I/O: device registers accessed as memory rather than I/O ports. - DMA - Direct Memory Access: a device reading or writing RAM itself. Unconfined DMA is kernel-equivalent power, which is why section 6.4 treats it so carefully. - IOMMU - I/O Memory Management Unit: translates and confines device DMA, so a driver's device reaches only its granted arena (H1). - TLB - Translation Lookaside Buffer: the CPU's cache of page-table translations; a stale entry must be shot down across cores. - CR3 - the x86 register holding the current address space's page-table root. - PML4 - the top-level x86-64 page table. - HHDM - Higher-Half Direct Map: all physical memory mapped at a known high virtual base, so the kernel can reach any frame without a temporary mapping.

Buses, devices, and storage

- PCI - the device bus the kernel enumerates. BDF = Bus/Device/Function, a device's address on it. - AHCI - the SATA controller interface the block driver drives. SATA is the disk interface itself. - USB - Universal Serial Bus. xHCI is the USB 3 host controller, EHCI the USB 2 one. - HID - Human Interface Device: the USB class for keyboards and mice. - qTD / QH - EHCI queue Transfer Descriptor and Queue Head: the DMA structures describing a USB transfer and the endpoint queue it belongs to. - NIC - Network Interface Controller. - UART - the serial port hardware behind the console (COM1) and the test control channel (COM2).

Boot, formats, and files

- UEFI / BIOS - the two firmware styles a machine may boot with. ESP = EFI System Partition, the FAT32 partition holding the bootloader. - GPT / MBR - GUID Partition Table and Master Boot Record: the two partition-table formats. - ELF - Executable and Linkable Format: the binary format of the kernel and every service. - GSFS - the GodspeedOS filesystem (GSFS0008). - CRC - Cyclic Redundancy Check: the checksum guarding superblocks, directories, and data blocks.

Networking

- TCP / UDP - the transport protocols. ICMP carries ping. ARP resolves an IP to a MAC. DHCP leases an address; DNS resolves names.

Tooling

- QEMU - the emulator used for development. TCG is its software (no-virtualisation) engine; KVM is hardware-accelerated virtualisation. - SDK - sdk/rust, the crate every service links against. - POSIX - the Unix interface standard GodspeedOS deliberately does not implement (section 2.2, Appendix B.3).

Buses and devices

- PCIe - PCI Express. Electrically different, same configuration model, so the same class-code lookup finds a device on either. The Pi 4 reaches its USB controller this way. - BDF - Bus/Device/Function: the three numbers that name one device on a PCI bus, packed here as (bus << 8) | (dev << 3) | func. It is an identifier, not an address - the kernel reads that device's own registers to learn what it is worth. - BAR - Base Address Register. Where a device's registers live in memory. Which BAR holds them is a property of the device, not its class: an xHCI uses BAR0, an AHCI controller uses BAR5. - SMMU - the ARM name for an IOMMU. Neither Raspberry Pi wires one up, which is recorded rather than glossed over. - INTx - the legacy PCI interrupt pin. Routed through the IOAPIC and, on this hardware, deliverable only to the boot processor - which is why the EHCI driver is pinned to core 0. - SoC - System on Chip. A controller soldered into the processor package rather than sitting on a bus, so no scan can find it: the Pi 4's ethernet is one, and is asked about separately.

Storage

- SATA - the drive attachment the T630 and Wyse boot from. - SSD - solid-state drive. - LBA - Logical Block Address: which 512-byte sector, counted from zero. The unit of the block protocol between fs and block-driver. - PIO - Programmed I/O: moving data through CPU register reads and writes instead of DMA. Slower, and needs no DMA authority at all. - MSC - USB Mass Storage Class: how a USB stick presents itself, driven over BOT. - BOT - Bulk-Only Transport: the command wrapper (CBW), data, status (CSW) sequence that carries SCSI commands over USB bulk endpoints. - SCSI - the command set inside those wrappers (READ(10), WRITE(10), INQUIRY).

USB

- xHCI - the USB 3 host controller interface. Class code 0x0C0330 everywhere, forever, which is what lets a driver name its device without the kernel being taught what one is. - EHCI - the USB 2 host controller interface (0x0C0320). Present on the T630, absent on the Wyse - both cases are exercised. - DWC2 - the DesignWare USB 2 controller in the Raspberry Pi 2. One controller carrying the keyboard, the disk and the network at once. - TRB - Transfer Request Block: one 16-byte entry on an xHCI ring - a command, a data stage, or a completion event. A completion event carries the address of the TRB it completes, which is what lets a reply be matched to the request that asked for it rather than merely to the same slot. - PHY - the physical-layer chip that actually drives the wire, addressed through MDIO. - MDIO - the two-wire management bus used to read and write PHY registers.

Serial, display, and boot

- PL011 - the ARM UART on both Raspberry Pis. On the Pi 4 the firmware routes it to Bluetooth unless dtoverlay=disable-bt says otherwise, which is why that line is in the boot config. - FIFO - the small hardware queue in front of a device, 16 bytes deep on the PL011. Writing past a full one drops bytes, so every write polls first. - ANSI / CSI - the escape sequences a terminal interprets for colour and cursor movement. Handled by the console service; the kernel's boot floor discards them deliberately. - ESP - EFI System Partition: the FAT partition UEFI firmware loads a bootloader from. - BSP / AP - Bootstrap Processor and Application Processor: the core that starts first, and every other core it then brings up. - EL0 / EL1 - AArch64 exception levels: user code and kernel code respectively.

Networking

- MAC - the six-byte hardware address of a network interface. - IP - Internet Protocol: the addressing net-stack routes on, alongside the TCP and UDP transports it speaks. - ARP - Address Resolution Protocol: finds the MAC address for an IP address. - ICMP - the protocol ping uses. - DHCP - how the machine is given an address when it comes up. - DNS - name resolution. - SNTP - the time protocol the clock is set from on a machine with no RTC. - RX / TX - receive and transmit, the two directions of a network ring.

The system

- IPC - Inter-Process Communication: the message passing that every operation in this system crosses. Synchronous, copied by the kernel, bounded queues, and always under a capability. - TCB - Trusted Computing Base: the parts whose compromise compromises everything. Here it is the kernel alone, plus DMA-capable drivers on a machine with no IOMMU. - ABI - the calling convention a service and the kernel agree on at the syscall boundary. - API - the operations one component offers another. - CPU / RAM - processor and memory.

The Almanac of GodspeedOS - A History of Understanding

What this is. Not a changelog. Git already records what changed and when; this records why the thinking changed. Every entry below pins a date to a realization - a thing the project came to understand that it had not understood the day before - and then names the principle, commandment, or design decision that the realization produced. It is the intellectual history of the system, told backward from the rules to the days that earned them.

It is a record, rarely touched - a reflective document to look back on, not a living one. For the law, read CLAUDE.md; for its distillation, read COMMANDMENTS.md. This file is the answer to the question a contributor eventually asks at a whiteboard: "Why is it written that way?" The dates are real, anchored to commits and to the dated amendments in the constitution. The reasons are the part worth keeping.


Contents


2026-05-08 - The day the screen first said the machine was alive

The repository opened, and by the end of the day the kernel booted to a steady state under QEMU - Limine handoff, serial, GDT, IDT - after two x86 correctness bugs were found and fixed in the same sitting.

What I came to understand: a boot is not a feature you add; it is a fact you either observe on the wire or you do not have. The very first thing built was not the kernel - it was the ability to see the kernel: serial output before anything else.

What it produced: the instinct that became failures are loud, never silent (Invariant 12) and Commandment VIII's deeper sibling - wait for truth, not time. From day one, progress was something the serial console showed, never something assumed.

2026-05-09 - The day the nouns became verbs

In a single push the kernel gained a frame allocator and page tables, a preemptive scheduler, capability enforcement with generation checks, and synchronous IPC.

What I came to understand: the constitution's vocabulary - capability, generation, endpoint, quantum - was not description; each word had to be a mechanism or it was a lie. A "capability" that is not checked on every syscall is just a number.

What it produced: the discipline behind Commandment VII (no ambient authority) and the §7 capability model - authority is the cap you hold and the generation it carries, validated every single time, never inferred from who you are.

2026-05-10 - The day two services talked across a boundary

Cross-core IPC came up after a stack overflow and a silent-exception halt were chased down, and the first user-space service ran in ring 3: an ELF loaded, spawned, and logging through a syscall.

What I came to understand: the moment code crossed the kernel boundary, the kernel's job stopped being "do the work" and became "route, isolate, and enforce." A service is not a privileged part of the kernel that happens to live elsewhere; it is an untrusted identity the kernel mediates.

What it produced: Commandment I (the kernel is complete; use a service) and §26.10 (the kernel is mechanism, not policy). The line drawn that day - work lives in services, the kernel only mediates - never moved.

2026-05-12 - The day a service died and came back

The v1 milestone met its bar: ping on core 0, pong on core 1, a message across the boundary, pong killed, the supervisor restarting it, the system continuing.

What I came to understand: the headline was never "two services talk." It was "one can die and the other does not care, because it talks to a name, not a place." A client that saw EndpointDead, looked the name up again, and resumed had just demonstrated the whole philosophy in miniature.

What it produced: Invariant 11 (identity is stable; location is not) made concrete, and the seed of Commandment IX - recovery is the client's job, and a restart is normal, not exceptional.

2026-05-21 - The day the model met a real machine

GodspeedOS booted on real x86_64 hardware via UEFI USB (CLAUDE.md §23.3). The bring-up machine was a Dell Wyse 5070 thin client - the Intel J5005 (Goldmont+) of the first performance tables. Four cores up, cross-core ping/pong running on metal.

What I came to understand: QEMU is a model of a machine, and a model is a place where bugs hide politely. Real silicon does not round off the corners. A thing is not "done" because it is green in emulation.

What it produced: the standard that would later harden into Commandment II - chaos and correctness are judged on hardware - and the habit of treating a clean QEMU run as a hypothesis, not a verdict.

~2026-05-30 - The day the thin client taught me about real concurrency

On the Wyse, bare-metal boot froze shortly after the supervisor came up, and a cross-core reply-to-the-BSP path stalled hard under load. The diagnosis (bugs/1_FINDINGS_AP_TO_BSP_IPI.md, 2026-05-30): core 0 wedged with interrupts disabled, so a sender blocked on recv on the BSP was never woken. Development moved to the HP T630 (AMD GX-420GI, Jaguar/Puma+, 4 cores), the permanent SMP test machine thereafter.

What I came to understand: concurrency bugs are not abstract; they are specific to the silicon that exposes them. The J5005's stall was not a flaky machine - it was the machine telling the truth about an IF=0 wedge that QEMU and a friendlier CPU had both forgiven.

What it produced: the conviction behind Commandment VIII (rely on observable truth, not timing) and a permanent test rig on hardware that does not forgive. Cross-core correctness is only honest on a machine willing to deadlock you.

2026-06-09 - The day a trusted service stopped being trusted (H11)

registry became a real user-space name service holding only delegated caps, so its death degraded name resolution temporarily instead of corrupting the system - and it left the Trusted Computing Base (CLAUDE.md §6.1, Amendment 2026-06-09).

What I came to understand: "trusted" had been doing two jobs at once - "holds authority" and "cannot be allowed to die." Those are different claims, and conflating them is how a TCB quietly grows.

What it produced: the first real motion of the TCB-shrink program, and the early form of Commandment V - no service is special. A name service that resolves a stable name is itself just an identity, replaceable like any other.

2026-06-12 - The day I found kernel-equivalent power hiding in a driver (H1)

IOMMU DMA-confinement landed (CLAUDE.md §6.4, Amendment 2026-06-12). A DMA-capable driver, confined, reaches only its granted arena; the trust posture - confined or trust-critical - is now printed at boot.

What I came to understand: the very first invariant, no ambient authority, had an unstated exception I had never written down. A driver could aim its controller's DMA engine at any physical address - reach the capability model never granted, granted instead by physics. The model said "no ambient authority" while a USB driver quietly held the most ambient authority in the machine.

What it produced: the amendment that closed the gap in Invariant 1, and Commandment VII made literal at the hardware edge - authority must be visible, so the posture is a boot fact, not a hidden assumption. A silent exception to an invariant is worse than no invariant, because it lies.

2026-06-17 - The day a crash stopped meaning a reboot (Phase D)

With a crash-consistent redo-journal and recovery-on-mount, fs and block-driver became restartable and left the TCB (CLAUDE.md §6.1, Amendment 2026-06-17).

What I came to understand: the only reason the filesystem had been unkillable was that it could not recover - and "cannot recover" is a property you can engineer away, not a law of nature. Once recovery existed, the special status evaporated.

What it produced: Commandment IX made concrete (plan for recovery, and a service that can recover need not be trusted) and another rung down the TCB ladder toward {kernel}.

2026-06-18 - The day a file became a real capability (P2)

Delegated resource capabilities arrived (CLAUDE.md §7.10, Amendment 2026-06-18): fs mints a genuine, kernel-validated, revocable cap on open. The same day, the full persistence stack and the file-as-capability self-check ran green on the HP T630 against a real SSD - a file opened as a cap, read and written through it, non-escalation enforced, a forged handle rejected, the cap revoked on close.

What I came to understand: the meaning of a file belongs to a service, never to the kernel. The kernel does not need to know what a file is to mint, route, and revoke its capability - it only needs to carry an opaque resource id and let fs supply the meaning. "A file is like a capability" was a compromise; "a file is a capability" was achievable, and the difference was the whole point.

What it produced: the §7 north star - a file is a capability, literally, not by analogy - and a reinforcement of Commandment X (complexity in the layer that owns it): file semantics live in fs, capability mechanism lives in the kernel, and neither reaches into the other.

2026-06-20 to 2026-06-21 - The day I deleted the registry and learned what is truly irreducible

Over a phased migration, name-to-endpoint resolution moved out of the kernel and the registry service was retired outright (CLAUDE.md §6.1, Amendment 2026-06-21, "Path C"). A minimal name -> endpoint directory inside the kernel became the namer; the supervisor wires services from a name -> cap map; clients reacquire by name.

What I came to understand: an entire service had existed to store a mapping that turned out to be almost entirely derivable - the supervisor already knew the caps, the kernel needed only a tiny recovery anchor. Deleting it revealed how little state is genuinely irreducible, and how much "truth" is really a derived view dressed up as a source.

What it produced: this is the day behind Commandment III - do not duplicate truth; store the irreducible fact, derive the rest. When someone asks why III is written the way it is, the honest answer is: because deleting the registry showed that only a tiny amount of state was ever truly irreducible, and everything else should be reconstructed, not stored twice.

2026-06-21 - The day nothing was allowed to be unkillable but the kernel

Two long-standing exceptions fell at once. The shell was made restartable - kill shell respawns a fresh prompt instead of leaving a dead session. And in the Path C finale, the supervisor itself became restartable: the kernel respawns it on death, unconditionally and forever, and the respawned supervisor reconciles by adopting the still-running services. The non-restartable set reached its floor: {kernel} alone.

What I came to understand: every "this one cannot die" was a comfort, not a requirement. If the supervisor - the holder of restart authority - can itself be respawned by the kernel, then no service has any standing to claim exemption. "Trusted" finally, fully stopped meaning "unkillable."

What it produced: Commandment V in its strongest form - no service is special; only the kernel is special - grounded in Invariant 6 (services must be restartable) and §6.2-6.3 (the unkillable set is {kernel}). Identity survives restart; only the kernel is location.

2026-06-21 - The day Maximum Carnage was born

chaos max-carnage arrived - a storm that kills a random live service every round and asserts the kernel survives. It grew into a per-round sweep over every live service, mixing kills, queue floods, memory pressure, and spawn storms.

What I came to understand: passing tests proves a system handles the failures you imagined. It says nothing about the ones you did not. The only way to trust a system is to spend real effort trying to destroy it and watch it refuse.

What it produced: Commandment II - love Chaos and trust in it; thy service shall pass through Maximum Carnage - and the standard that a green unit suite is necessary but never sufficient. Chaos is the teacher; if it finds a bug, the bug already existed.

2026-06-22 to 2026-06-28 - The days Carnage found the wedges no one would write by hand

The storm did exactly what it was built to do. A 71K-round SMP deadlock from concurrent TLB shootdowns (fixed 2026-06-24 with per-core shootdown). A supervisor-respawn wedge where respawn ran inside an IF=0 timer ISR and could not acknowledge shootdown IPIs (fixed 2026-06-23 by moving respawn to an interrupt-enabled point). And at high round counts on the T630, a page-table reclaim page fault where a driver kill followed a corrupt PTE into an out-of-RAM frame - which led to the root-cause fix: quiesce DMA on driver kill (2026-06-28).

What I came to understand: these were not new bugs Carnage created - they were old bugs Carnage revealed, latent since the day the code was written, waiting for the one interleaving that QEMU and light load never produced. A system you have not tried to break is a system whose real failure modes you have simply never met.

What it produced: the conviction under Commandment II made unarguable, and the practice of long soaks on real hardware as the acceptance bar - the verdict tightened every time a soak exposed a wedge, because a test that passes a known-broken target is a weak test, not a clean one.

2026-06-25 - The day I wrote the constitution down as ten lines

COMMANDMENTS.md was authored - the Ten Commandments of Godspeed, each grounded in the invariants it enforces.

What I came to understand: the greatest long-term threat to the system was never a race or a fault - it was erosion. Hidden complexity, a convenience here, a silent fallback there, each reasonable on its own, would rot the model one "just this once" at a time. And a 2,700-line constitution is hard to defend a pull request against in the moment; ten memorable lines are not.

What it produced: the realization that the architecture, not the code, is the product (§26.1) - which is why a constitution exists at all, and why it has a human-readable distillation. The Commandments were written in response to real bugs, real wedges, and real lessons; they are the compressed memory of everything above.

2026-06-29 - The day the examples had to teach the truth

The example library was rebuilt as a Commandment-grounded reference for contributors, modelling the real patterns - placement defaults, explicit capability passing - rather than carrying stale registry references from a retired design.

What I came to understand: demonstration code that no one runs drifts into a lie, and a lie in the examples teaches the wrong model to exactly the people most willing to learn it. Examples are documentation that executes; if they are not tested, they are not true.

What it produced: the examples folded under Commandment IX's rule - if it cannot be tested, it does not exist - applied to teaching material, and a reaffirmation that the model is only as honest as the smallest program that claims to follow it.

2026-07-03 - The day the shell became a language, and a 10 MB script was a non-event

gsh grew from a flat command-runner into a real programming language - variables, arithmetic, conditionals, switch, loops, functions with bounded recursion, libraries (import), defer, typed-pipe aggregators, $( ) capture, and console input with a secret-taint guard rail - built the way everything here is built: fixed stacks, bounded arenas, streaming, no heap, loud on every ceiling. Then a 10 MB script was run, and nothing happened. That is the whole point.

What I came to understand: the ~7 KiB cap on a run-from-file script had felt like a limitation to apologize for. The 10 MB test inverted it. A heap-backed loader meeting a 10 MB file has a bad day - it allocates, it fragments, maybe it runs out of memory mid-parse. The streaming minifier meets the same file, reads about 7 KiB of it, prints one loud line - a huge script is a program - and carries on. The bound is not the wall the language hits; it is the thing that lets the language meet a pathological input and shrug. "Handled gracefully" and "bounded" turned out to be the same sentence.

The design of the language taught that lesson twice more, in miniature, by refusing to let me claim more than the system could honestly deliver. A secret from input secret is a guard rail against the accidental echo, not a vault - because once you may write it to a file, you can always read it back, so airtight secrecy is impossible by construction, and pretending otherwise would have been a lie the console told the user. And a function is not a pipe source - a boundary I found only by writing a real program (the selfcheck tour) that tripped over it, fn greet quietly shadowing the greet service.

What it produced: the proof that §26.6.1 is not a kernel rule - it is the rule. The same "change the representation, do not reach for a heap" that governs the piece table and the record arena governs a whole userspace language, and it holds from a 7 KiB script to a 10 MB one with no special case. A bounded system does not fear scale; it renders scale boring. (§26.6.1; Commandment III and the loud-failure discipline.)


2026-07-04 - The day a private discipline became a public one

The licenses were chosen - GPL-2.0-only for the OS, Apache-2.0 for the SDK - the tree swept for secrets and stray dashes, and on 2026-07-04 it went public: github.com/godspeedhq/godspeed-os. (The org is godspeedhq because plain godspeed was already taken - by a personal account that hosts, of all things, Android kernel repos; the project is godspeed-os, leaving room for a godspeed-lang beside it when the language comes.) 939 commits of private practice, opened in one setting change.

What I came to understand: every lesson above was learned in private, where the only person the discipline had to convince was me. Public means the constitution now has to hold against contributors who did not live through the wedges - which is precisely what the Commandments were written for.

What it produced: the moment the invariants stopped being a personal practice and became a public contract - held by the same identity tests, the same chaos bar, and the same ten commandments that earned them. The work of keeping the architecture uncorrupted does not end here; it opens. This was the almanac's one entry dated forward on purpose - the anticipated day, kept blank until the launch could make it real. It is real now.


2026-06-30 to 2026-07-01 - The day "wait on truth" turned out to be half a truth

A million-round chaos max-carnage soak finished with the system alive but the filesystem gone - ls said "no filesystem," drives read raw, and a cold reboot brought it all back. No data was lost; the disk was intact the whole time. So the bug was not corruption. It was a recovery that never happened.

The cause was a single wall-clock timer. fs waited eight seconds for block-driver to report a readable disk, then gave up. Under a soak that had wedged the AHCI controller, eight seconds was never going to be enough - the controller needed a hard reset, not more waiting - so fs latched "no filesystem" and sat there. The timer felt like the safe, obvious way to wait for a dependency. It was the trap. Commandment VIII, exactly: wait on truth, not time.

What I came to understand, and it had a turn in it. Removing the timer and waiting on block-driver's actual reply fixed the soak - but following that discipline rigorously exposed something deeper. The request/reply primitive itself could hang: a blocking wait on a reply hangs forever if the reply never comes, and it never comes if the peer dies mid-request. "Wait on truth" had quietly become "wait forever" for the one truth it could not see: the peer's death. The easy version of a principle ("do not use timers") and the complete version of it are not the same thing.

So the fix was not only in fs; it was in what "truth" means. We taught the kernel that a peer's death is a truth too - a synchronous Call whose caller wakes with ReplyDead the instant its replier dies, the reply-side twin of the sender-side wake §8.6 already had. seL4's Call/Reply, borrowed for exactly this. And the principled part, which was the owner's call: putting it in the kernel grows its code but not its responsibility - IPC death-semantics were always the kernel's job (Commandment I is about scope, not line count). Mechanism, not policy: the kernel learned about a reply cap, never about "RPC."

And then the fix had a hidden failure of its own. The kernel change passed every green test - identity, fs-restart, a new ReplyDead pin - and still drained the whole system to zero on the first hardware soak: recovery of the dead supervisor hung on a single trigger, the timer ISR, which the storm starves, and the new code's latency was just enough to lose a race main had narrowly won. The lesson turned on itself. A fix I believed was complete was again only half-satisfied, and the half missing was the failure case - caught by exactly the fire this whole entry is about. The repair drove recovery from the storm's own hot path, the yield, making it un-starvable - stronger than it had ever been.

What it produced. Commandment VIII, deepened: wait for truth, not time - but ensure the truth includes failure. A wait that cannot observe failure has quietly become an infinite wait on time. A standard for every contributor (CONTRIBUTING.md): interdependent services wait on each other's truth, the reply or the loud fact of death, never on a clock. And the lesson I keep relearning, now with teeth: a principle you believe you have satisfied is usually only half-satisfied, and the half you are missing is what happens when things fail.


2026-07-04 - The day the system survived a million acts of violence

chaos max-carnage all-services 1000000 ran to completion, and the system works as expected.

A million rounds. Each round is not a gentle probe - it kills every restartable service, floods every endpoint it can reach, storms memory and the spawner, and rotates a kill through whatever is left. This is the same soak that, in the entry just above, drained the system to "no filesystem" on its first hardware run and taught the hardest version of Commandment VIII. This time it finished. Nothing had to be rebooted. The prompt still answered.

A million is not a number you reach by luck. Fault tolerance that is 99.99% correct dies long before round ten thousand: leak one frame per kill, fail to reclaim one capability, leave one lock wedged, and the footprint grows without bound until something falls over. To reach 1,000,000 every recovery path has to be not merely correct but conserving - each respawn reclaims the dead instance's frames, kstack, and caps before it allocates fresh, so the footprint is flat and only a count grows (§6.2). The kernel respawns the supervisor unconditionally, forever; the supervisor reconciles - adopts the survivors, respawns the dead; clients see EndpointDead, reacquire by name, and retry. unkillable = {kernel}, and the kernel held a million times over.

This is the entry above, vindicated. The wait-on-truth fix, the un-starvable respawn driven from the yield path, the ReplyDead reply-side twin - each was built to survive exactly this fire, and now the fire has burned a million times and the house still stands. Fault tolerance stopped being a claim in a constitution and became a measured fact. The model was right; the proof is a number with six zeroes.

The receipt. When the storm was called off, observe now on the T630 put the whole truth on one screen:

--------------------------- system state (8 live) ---------------------------
UPTIME: 3d 03:07:34     CPU: C0 99% C1 99% C2 0% C3 0% (49%)     RAM: 5 MiB used / 7 GiB total (0%)
TASK  NAME          CORE  STATE      RESTARTS   QUEUE   UPTIME
 3    supervisor    C0    BlockRecv    142829    0/16      9h
 4    block-driver  C1    BlockRecv    142826    0/16      9h
 5    fs            C1    BlockRecv    997721    0/16      9h
 6    shell         C0    Running      997721    0/16      9h
 7    logger        C0    BlockRecv    143706    0/16      9h
 8    xhci          C1    Running      143704    0/16      9h
 9    ehci          C1    Ready        143706    0/16      9h
drives:  0  data  GSFS  122104 MiB (122074 MiB free)

RESTARTS is what observe calls deaths recovered, not clean re-runs - the soak's odometer. fs and shell had each come back from death 997,721 times; supervisor, block-driver, logger, and the two USB drivers each sat near 142,000-143,000 - together roughly 2.7 million individual service deaths, every one recovered, not one escalated. And the number that settles it is two lines up: <a class="gs-abbr" href="glossary.html#gl-cpu"><abbr title="RAM - processor and memory.">RAM</abbr></a>: 5 MiB used. Flat. After 2.7 million deaths and resurrections and three days of uptime, memory had not grown by a single page - one leaked frame per death would have bled ~11 GiB; the machine used 5 MiB of its 7 GiB, and the 122 GiB SSD sat untouched at 122074 MiB free. That is what "conserving, not merely correct" (§6.2) looks like the moment you finally read the meter.


2026-07-04 - The day gsh was finished, and never once reached for a heap

gsh started as a batch runner: run_lines split a file on ; and \n and handed each line to execute. It is now a language. Variables and arithmetic, if / switch, for / loop with break / continue, functions with parameters and bounded recursion, libraries via import, defer, record aggregators, typed pipes - and, last, the output-capture cluster: for line in (producer) to walk a stream, if myfn to branch on a function's result, and $(fn) to capture a function's output into a value. That last one closed the set. gsh is complete.

And it never once reached for a heap. Every part of it lives on the stack or in a fixed, named arena: the ~5 KiB variable table, the ~7 KiB script buffer, the 512 B capture buffer, the 16-frame block stack. Not one general allocator in the whole interpreter. When a working set felt too big for the stack, the answer was never "add a heap" - it was "change the representation": stream a file in fixed chunks, refer to data by (offset, len) spans, give a subsystem its own bounded arena, iterate with an explicit stack instead of native recursion (§26.6.1). The constraint did not cramp the design; it improved it. Every limit in gsh is a number you can read off the source.

The proof is a 10 MB script. A shell that malloc'd its way through source would choke on it or take the machine down with it. gsh reads ~7 KiB, truncates the rest loudly, runs the tour it can hold - functions, captures, error paths and all - and leaves the prompt answering. Overflow is not a crash here; it is a feature. Every bound fails the same honest way: a value too big for the 512 B capture, a script too big for the 7 KiB buffer, a statement too long to hold across a read - each is a loud, specific refusal, never a silent truncation and never a corrupted result. The system tells you the truth about its limits.

The last feature nearly went the other way, and the honesty of the ask is the whole point. Capturing a function's output looked like it wanted a big buffer, and the question came out loud: "is this a cool place to use a heap - throw the function on a heap, get the result, throw the heap away?" That is the reflex, caught in mid-air - because "scratch space, filled, then discarded" is not a heap, it is a bounded stack buffer, which is exactly what we used (512 bytes - it lives in the executor's own frame, so it must stay small enough to coexist with everything else on a 256 KiB stack; a captured value is a name, a number, a short line). The heap is a shape of thought, and the shape can almost always be had without the allocator. gsh is that argument, made in code, half a kilobyte at a time.


2026-07-04 - The day it shipped, and called itself 0.1

Hours after the repository went public, GodspeedOS cut its first release: v0.1.0 - a tagged, checksummed, bootable UEFI image on the releases page. The old v1.0 tag - the marker left when the first cross-core ping/pong survived a restart - was deleted to make room for it. A system that had called itself "v1" for two months shipped its first release numbered 0.1.0.

What I came to understand: a version number is not a trophy for a milestone reached; it is a claim about maturity, and the honest claim was 0.x. "v1" was the name of a goal met - two services, cross-core IPC, restart. But the software underneath is still moving fast - a whole language landed the same week - and SemVer 0.x means precisely that: anything may still change. Numbering it 1.0.0 would have promised a stability the system does not yet owe anyone. The crates already knew; they had said 0.1.0 all along. The release only spoke the truth the code was already telling.

What it produced: the system's own honesty discipline, turned on its own version string - say what is true, even when a larger number would flatter. The milestone was v1; the software is 0.1; both are true, and the release states the one that matters to a person about to boot it. (A smaller truth rode along: the 64 MiB image is 99.4% empty space, so it ships compressed to under half a megabyte - honest about its size, too.)


2026-07-07 to 2026-07-08 - The day the wire became location

The networking stack, already carrying a real DHCP lease and a ping on the T630's Realtek NIC, was taught to survive the two things a real network interface actually does: the cable moves, and the services die. A running ping now rides an unplug and a replug - it reports the link down, and when the cable returns and the lease re-acquires, it simply resumes, q-abortable the whole time. net-stack configures itself the moment the driver sees carrier; net renew repairs a bad lease inside the running service, no restart; and chaos max-carnage nic-driver,net-stack storms both networking services together, round after uncapped round, while the link keeps coming back.

What I came to understand: the cable is location, and the socket is identity - the same separation SMP drew between a service and its core, now drawn at the edge of the physical world. A link that goes down is not an error to fail on; it is a location changing, exactly what Invariant 11 was written to make survivable. The stack does not own the cable's state any more than a service owns its core; it observes carrier as a fact and reconverges when the fact changes. Plugging in a cable is not a command to handle - it is a truth to notice.

What it produced: Invariant 11 (identity is stable; location is not) reaching all the way to the RJ45 - and the networking half of the restartability story the storage stack had been telling. A socket cap outlives the cable, the lease, the driver, and the stack; every one of those is location, and the system reconverges to the identity from any of them moving. "Not even a blip" is what identity-over-location feels like when you finally pull the plug and watch it come back.


2026-07-09 - The day half a truth came back for the mount loop

A rapid second kill all-services on the T630 - the whole restartable set killed twice within seconds

  • wedged the storage stack for good. Not a crash: a permanent thrash. fs had read a garbage disk capacity from a stale reply, derived a backup-superblock LBA far past the end of the disk, and asked for it; the AHCI controller stuck its command-issue bit without ever setting BSY, so the port recovery that watched only BSY never fired; and the mount loop retried the impossible read forever. The keyboard looked dead because the whole machine was buried under an infinite storage retry.

What I came to understand: this was the same lesson as the million-round soak that deepened Commandment VIII (the 2026-06-30 entry above) - and that is exactly the point. That day I taught fs and the kernel that a peer's death is a truth a wait must be able to see. But "wait on truth, and the truth must include failure" is not a fix you apply once and retire; it is a lens you re-apply at every wait you write. The mount loop was a new wait, written to spin until success, and it had quietly inherited the original sin: it could observe success forever and failure never. A persistently unreadable disk was a truth it had no way to conclude. The principle had not failed; I had simply found one more place I had not yet held it to.

What it produced: the three fixes that finish Commandment VIII in the storage stack - a port COMRESET that escalates on a stuck command-issue bit and not only on BSY (so the controller self-heals the wedge instead of retrying into it); a capacity reply fs accepts only in its exact, bounded shape (so a stale reply is never misread as a truth); and a mount loop with a failure-truth exit - bounded real attempts, after which fs comes up degraded and loud, "storage unavailable, data intact," rather than spinning on a disk that will never answer. And the input path learned the same shape from the other side: the console now drains the UART on read, not only on the timer tick, so a keystroke - a truth you can observe directly - is never held hostage to a proxy the storm can starve. One lesson, three surfaces, re-applied where I had not yet applied it.


2026-07-09 - The day recovery became a fixed point

The same double-kill storm exposed a second, quieter fault. When the supervisor came back and adopted the still-running services, a death notice could still arrive for a service it had already recovered - a duplicate, or one in flight when the respawn completed. Reacting to that notice the obvious way spawned a second instance of a service that was already alive, and the churn fed the very storm it was trying to survive.

What I came to understand: recovery cannot be a reflex fired per event; it has to be convergence to a target set. The supervisor's job is not "handle each death" but "make the world match the manifest" - every notice reconciles the live tasks against the set that should be running and acts only on the difference. A service already present is a no-op it logs as already recovered; a service genuinely absent is respawned, exactly once. Framed that way, the supervisor becomes a fixed point: from any perturbation - one kill, a storm, its own death mid-storm - it settles to "every service alive, one instance each," and a redundant notice is absorbed, never amplified. It is Invariant 11 seen from the restarter's side: the supervisor restores an identity set, indifferent to how many messages announce the same gap, exactly as a client is indifferent to which core the name now lives on.

What it produced: the supervisor's death handling made idempotent - the recovery-side complement to identity-over-location, and the property that lets chaos max-carnage kill everything, twice, in a tight loop without the recovery path becoming its own denial-of-service. A system that reconverges is one you can hit as hard as you like: it does not matter in what order, or how many times, the bad news arrives.


2026-07-10 - The day the kernel met a second architecture, and fit the machine it found

Two things landed the same day. The kernel stopped guessing how big the machine is: the MAX_CORES ceiling was deleted, and every per-core structure became a boot arena sized to the core count Limine actually reports, while the frame bitmap became sized from the RAM that is actually present - no cap, no waste (this branch, through cfb0a88). And then the hardened image booted on a second CPU architecture: a Dell Wyse 5070, Intel Goldmont+, came up first try - SMP, the AHCI flash, the Realtek NIC pulling a real DHCP lease and answering a ping, the shell - all of it, on silicon from a different vendor than the AMD GX-420GI (the HP T630) that every wedge in this almanac had been caught on.

There was a wound in that. The very first bare-metal boot, back on 2026-05-21, had been on a Goldmont+ part too, and within the week it stalled hard under load (The Sleeper on Core Zero, the first IF=0 bug) and sent development to the AMD T630 for good - the workhorse on which the shootdown deadlock, the respawn wedge, the DMA-after-death, and the million-round soak were all found and fixed. Two months and a hardened kernel later, Goldmont+ got its second chance and took it cleanly, first boot. Even the one thing known to be AMD-specific - TSC calibration, broken on the GX-420GI because it exposes no CPUID frequency leaf - simply worked on the Intel part, because the fallback calibrates against the PIT: a machine-specific truth read at boot, never hardcoded.

What I came to understand: portability is not a feature you write; it is the absence of assumptions you should never have made. Nothing in the boot path had to be told about the Wyse. The kernel counts its cores from Limine, sizes its bitmap from the memory map, and calibrates its clock against whatever timer answers - so a machine it has never seen configures the machine, not a constant in the source. A kernel that fits the machine it finds has nothing left to port. And the two CPUs it has truly lived on - AMD first, as the workhorse; Intel Goldmont+ second, as the proof - are now one image, unchanged between them.

What it produced: the dynamic-topology work (no MAX_CORES, RAM-sized bitmap) as the executable form of "identity over location" pushed all the way down to the hardware census - the machine's size is location, discovered, never identity, assumed. The only ceiling left is the xAPIC's 8-bit LAPIC id, and even that is now loud rather than silent: past 255 cores the kernel says so and names x2APIC as the next step, instead of quietly wrapping. Two CPUs, two vendors, one image, booting to the same steady state.

2026-07-11 to 2026-07-13 - The days the whole system stood for audit

For the first time the system was read the way a constitution asks to be read - not "does it work?" but "does it still obey?" A kernel audit (2026-07-11, audits/kernel-audit.md), run as a parallel sweep of independent readers over every subsystem, checked the code against the Ten Commandments and the invariants and surfaced three real things: two paths where a non-page-fault CPU exception - a stray #GP, a div0 in a service - halted the whole machine instead of killing just the offending ring-3 task (a single bad instruction in userspace could wedge the kernel), and one where a transient allocation failure on the supervisor-respawn path could panic! into a reboot, defeating the very Phase-6 recovery it lived inside. Then a userspace audit (2026-07-12 to 2026-07-13, audits/userspace-audit.md), the first of its kind, graded every service - the coupled pairs first, block-driver/fs and nic-driver/net-stack, then the rest - against the same law: 0 high, 8 medium, 8 low, each finding logged with a fix or a recorded deferral.

What I came to understand: the audit is affordable because the kernel is small. The 30-minute whiteboard rule (§26.11) and Commandment I (the kernel is complete; keep it small) had always been framed as correctness and understandability properties - but they were quietly an audit budget all along. A 2,700-line constitution enforced against a kernel one engineer can hold in their head can be walked end to end in an afternoon; the same audit against a monolith is a standing team that never finishes. Tininess is not only what makes the kernel correct or secure - it is what makes the recurring act of checking correctness and security cheap enough to actually do, release after release. The kernel stayed small so that auditing it could stay routine.

What it produced: audits/kernel-audit.md and audits/userspace-audit.md as living artifacts - a standing, repeatable practice rather than a one-time pass - and the recognition that the north star of an audit is that nothing above the kernel may panic or wedge it: a service's worst instruction should cost that service its life and no more. The findings became the next increment of work; the discipline became a habit. A model you can re-verify in an afternoon is a model that stays honest between the days someone thinks to check.


2026-07-14 - The day the boundary became something a machine enforced

Straight off the audit, the arch boundary stopped being a habit and became a law. The survey came first - because you cannot seal what you have not measured: 126 places where a neutral file reached for the x86 layer by name, 23 lines of inline assembly living outside arch/, and - the number that mattered - zero of either in the capability table or IPC. Then the sealing: one seam, crate::arch::imp, that names "the implementation for whatever architecture this is"; all 126 references routed through it; every inline-asm operation (read the page-table base, invalidate a TLB entry, mask interrupts, send an IPI) moved out of the neutral layers and behind an arch::imp primitive, so that not one arch-neutral file holds a single instruction of assembly. And last, the part that makes it stick: arch_boundary_check.py, a CI guard that fails the build if any file outside arch/ so much as writes asm! or names a specific architecture.

What I came to understand: a boundary that lives only in a contributor's head, or even in a well-written doc, will rot - because nothing stops the next well-meaning change from crossing it. The whole difference between a convention and a law is whether a machine refuses to let you break it. The seam made the boundary nameable (arch::imp, one place); the guard made it unbreakable (CI, every push). This is §26 - the architecture survives only if the discipline survives - turned from an aspiration into a mechanism. And the survey taught its own lesson: the belief "the boundary is clean" was worth nothing until it was a number, and the number that carried the whole bet was the zero in capability/ and ipc/.

What it produced: the arch::imp seam and the arch-boundary guard - the fourth of the kernel's mechanical disciplines (alongside the unsafe audit, the contract reconcile, and the dash convention). A boundary you can no longer cross by accident, only by deleting the guard on purpose. It set the stage for the only test that could prove the boundary was real.

2026-07-14 - The day one kernel booted three instruction sets in QEMU virt

With the boundary sealed and enforced, it was put to the one test that cannot be argued with. The arch-neutral kernel - the capability table, IPC, scheduler, syscall dispatch, memory, task, loader, every service's logic - was compiled and booted on three genuinely different instruction sets, writing only arch/<isa>/. x86-64 ran the full OS, all the way to the gsh> shell. AArch64 booted under QEMU's virt virtual machine (qemu-system-aarch64 -M virt) and put a line on the PL011 UART. RISC-V booted under the same virt machine (qemu-system-riscv64 -M virt), handed off by OpenSBI into S-mode, and put a line on the 16550. Not one arch-neutral file changed between them. Three QEMU-virt consoles, one codebase.

What I came to understand: a boundary is a claim until a second implementation drops in behind it without touching what it bounds - and it becomes a law on the third. The demarcation was never something to prove on a whiteboard; it was something to observe on a wire, exactly like the boot on day one. And the tell was in how the three went. AArch64 took real debugging: the CPU traps FP/SIMD at EL1 by default and Rust emits NEON for a byte-copy, so the first port faulted before its first Rust statement (found by reading the exception ESR off qemu -d int), and the stack pointer had to be forced 16-aligned. That was me learning the architecture's personality. RISC-V went first try, no debugging - riscv64imac is soft-float, so there was nothing to trap, and by the third ISA the playbook was solid enough that the neutral kernel simply came along for the ride. That asymmetry is what "bounded" feels like: the second implementation teaches you the arch's quirks, the third is a formality. The deepest thing is that this day and the audit days were the same act seen from two sides - the audit proved the inside was honest (nothing above the kernel could wedge it), and the port proved the edge was real (nothing inside had smuggled the machine into the model). A boundary is only as trustworthy as the code it bounds is clean; the code was clean because it had already been walked end to end. The small kernel that was cheap to audit was, for the same reason, cheap to carry to a new machine.

What it produced: docs/multi-arch.md, and the founding thesis closed into a demonstrated fact. The boundary is now held not by intention but by four CI guards and three live QEMU-virt consoles: to add a fourth ISA you implement arch/<new>/ to the imp surface, add one #[cfg(target_arch)] arm, and the compiler plus CI prove that no neutral file had to move. A capability microkernel kept small enough to fully audit has an arch boundary clean enough that a new architecture is a bounded drop-in - claim to proof, in the span of a day that only looked sudden because a hundred careful days came before it.


2026-07-25 - The day the second architecture stopped being a demonstration

On a Raspberry Pi 2, on real silicon, the shell printed run: ran 349, failed 0.

The arm32 port had booted before. It had reached userspace, driven a keyboard through a hub, mounted a disk, spoken DHCP over USB ethernet. What was different on this day is that the whole self-check ran and every case passed: the language tour, the pipes, the records, the filesystem, and - the one that mattered most - fcap, the file-as-capability proof (§22 Test 14). A file opened as a real kernel capability, written and read through the cap, non-escalation enforced at both the kernel and the fs layer, a forged handle rejected, the cap revoked on close and on rename. The §7 north star, true on a second architecture and not by assertion.

Four bugs stood in the way, and the striking thing is that not one of them was visible under emulation:

  1. A single DWC2 channel shared by the keyboard poll and storage, where a keyboard split abandoned mid-flight corrupted the next block transfer.
  2. resource_invoke packing its right field at bit 32 of a syscall argument, where arm32's register is 32 bits wide, so the right arrived as zero.
  3. The Bulk-Only Transport reset recovery missing its first step - the Mass Storage Reset that resynchronises the device's state machine, without which clear-halt unsticks the pipes and leaves the device still waiting for a transfer we had abandoned.
  4. A failed recovery that reported nothing, because its result was discarded with let _ =.

What I came to understand: these were not logic bugs, they were bugs about the machine being real. An emulator gives you a device that is patient, coherent, infinitely fast, and stateless between commands. Every one of the four was the consequence of a device that is none of those - a channel that is genuinely contended, a register that is genuinely 32 bits, a controller that genuinely holds state you must resynchronise, and a recovery that can genuinely fail. QEMU had said yes to all four for months. It was not lying; it was answering a different question.

And the third and fourth are the same bug wearing different clothes. The reset step was missing, and the code that would have told me it was missing threw its answer away. §26.7 already said a failed recovery must stay visible. What I had not appreciated is why that matters so much: a silent failed recovery does not merely hide a defect, it misdirects the investigation. Because the verdict was discarded, the log could not distinguish a device that was confused from a device that was gone - so I concluded the hardware had failed, and asked the operator to go test their USB stick. They pushed back: the stick was fine. They were right. The defect was mine, and the reason I could not see it was a return value nobody read. An error you discard does not just cost you the error. It costs you the next several hours, spent looking at the wrong component.

What it produced: bot_recover now performs the full spec sequence and returns its verdict, and says out loud when it could not recover - which turns "storage is broken" into the answerable question "is this device confused, or gone?". The rule earned a sharper form: an unread error return is not a missing log line, it is a wrong suspect.


2026-07-27 - The day a stick swap ended the hunt, and the recovery earned its keep

The self-check printed run: ran 349, failed 0. Then it printed it again, and again - eight times in a row, on a Raspberry Pi 2, on real silicon. Consistent zero: the bar that had sat just out of reach for a week of hardware round-trips.

What reached it was not a line of code. It was pulling out the USB stick and pushing in a different one.

For days the storage had failed the same way: under sustained I/O - a drives check reading the whole tree - the stick would stop answering, roughly once per run, and cost the handful of tests in flight at that instant. Every layer above it was made to survive that: a stuck-endpoint reset, an escalation to port-reset and re-enumeration, a filesystem that re-mounts on an I/O error, a journal that replays an interrupted write. And along the way real defects fell out that had nothing to do with any stick - a synchronous Call that took the wrong message off its own queue because it matched replies by arrival order instead of by sender (latent on x86 too); a format that condemned its own successful writes by reading them back through a device that served stale data; the FIFOs sized for a keyboard while a 512-byte bulk packet needed the whole of one. Each was found, each was fixed, and still a revival cost a run.

The operator proposed the one experiment I had been talking around: change the stick. Eight clean runs later, the answer was unambiguous. The old stick's own controller had been failing under load - not leaving the bus, just wedging its own firmware while staying plugged in. A dodgy part.

What I came to understand: faults stack, and fixing a real one does not exonerate the layer beneath it. The 2026-07-25 entry above is the same investigation, two days earlier, and its conclusion was true - the silent clear-halt was a genuine defect, and "the stick is fine" was the right correction at the time, because the software bug was masking everything under it. But "the software bug was real" and "the stick was fine" are not the same statement, and I had quietly treated them as one. Peel a real fault and you do not reach innocence; you reach the next fault. Only a controlled experiment - one variable changed, everything else held - can isolate the last one, and no amount of reading an ambiguous log substitutes for it.

And the elaborate recovery was not wasted on a problem that turned out to be hardware - it was the whole point. The proof is in the same log: the old, failing stick dropped off twice and still produced two 349, failed 0 runs, because the recovery caught it and carried on. Resilience surviving a genuinely failing device, gracefully, is exactly what resilience is for. The reply-correlation fix and the others would have been worth making on any stick; the recovery earned its keep on the bad one.

What it produced: the discipline of the one-variable swap before the multi-day rewrite - because the next thing I had been about to build was a full interrupt-driven restructure of the USB stack, days of work, to fix what a $3 part swap settled in an afternoon. That the restructure is still worth doing (for reasons that were never this bug) is a separate decision, now made in the clear rather than under the pressure of a failure I had misattributed.


2026-08-09 to 2026-08-12 - The day the kernel gave up the USB stack, and then gave up the choice

kernel/src/arch/aarch64/xhci.rs was 2,742 lines of ring-0 code parsing descriptors supplied by whatever someone had plugged in. It worked. It had driven a keyboard and a disk on a Raspberry Pi 4 for weeks. On this day it was deleted, and the xhci SERVICE drove the controller instead - through an MMIO capability and a DMA arena granted at spawn, reaching the console through CONSOLE_PUSH, serving storage over the block protocol block-driver already spoke. The kernel's entire remaining involvement with USB became establishing that the controller is present and saying so.

The move was done behind a feature flag first, so both drivers could exist while the service was proven on hardware. Then the flag was deleted too, and that second deletion is the part worth remembering.

What I came to understand: a flag that selects between a compliant kernel and a non-compliant one does not resolve the violation, it stores it. As long as usb-via-xhci existed, there was a build of GodspeedOS in which the kernel drove USB - one flag away, and Commandment I was satisfiable rather than satisfied. It was also a genuine footgun: the flag had to reach three crates, and setting some of them gave two drivers on one controller, or none, with every half booting fine alone.

And I had to be precise about what the move actually bought, because the milestone is real but narrower than "Commandment I closed" sounds. The Pi 4 has no SMMU wired up: confine_device returns false, and the service is granted its controller's BAR plus a DMA arena including its physical base. So it can still point the controller's DMA anywhere, which is kernel-equivalent reach by the same rule that governs an unconfined x86 driver. The honest statement is: a buggy USB driver on the Pi 4 is now bounded; a compromised one is still kernel-equivalent. What closed was the accident surface - ring-0 descriptor parsing became a restartable service - and the ambient authority, since the service holds only what its contract grants. The trust posture is unchanged until an SMMU confines the device, and saying otherwise would have been the nicer sentence and the wrong one.


2026-08-17 to 2026-08-29 - The day the terminal left the kernel, and I learned what earns ring 0

Two more evictions, on the port with the least room for them. arch/arm/dwc2.rs - the whole ARM32 USB stack, keyboard and networking and mass storage - became services/dwc2, driven by a real device interrupt rather than the core-0 timer tick. And kernel/src/fbcon, 1,172 lines of terminal emulation, became services/console: the ANSI/CSI state machine, the UTF-8 decoder, the shadow grid, the cursor, scrolling, reverse video, all of it out.

What stayed behind cannot format, cannot position, and cannot scroll. Reaching the bottom of the screen clears it and starts again at the top.

What I came to understand: the test for ring-0 residency is not "is this important" or "is this hard to do elsewhere". It is impossibility. A panic halts every core, including the console service, so a panic cannot ask a service to report it - the kernel must be able to say the machine died using only itself. Boot output has the same shape: it precedes every service, including the one that would render it. That argument justifies exactly a bounded blit and not one line more. Rendering a shell prompt is policy; a display driver is anti-scope by name. The operator control channel had asked for ring-0 residency earlier and failed this same bar, which is how I knew the bar was real and not built to fit what I already wanted to keep.

The evictions also made honest a claim that had been quietly arch-specific. The logging floor said the kernel writes to "the serial console", which is always true on a PC and false on a Pi wired to a TV. A kernel that can only speak to a port it does not have is mute, and a boot failure on that machine was a frozen screen with no reason on it.


2026-09-03 to 2026-09-04 - The day the kernel stopped interpreting the bus

The kernel held a table of what every device class was: which BAR, which IRQ, which BDF, per class, sixteen of them. A driver was spawned because the kernel knew what an xHCI was. By the end of D3 the supervisor owned the images and a driver named a device class code, which the kernel resolved from a table it holds no opinion about. service_config rows went 222 to 2. Embedded images went 30 to 1. Per-class device statics went 16 to 0.

Four bugs surfaced during it, each found by an instrument built for a different one: an fs reply desync, a block-driver "no disk" latch, an xHCI port-status misattribution, and an arm32 serial splice that was corrupting the very evidence being used to chase the other three.

What I came to understand: "the kernel is mechanism, not policy" is easy to agree with and hard to notice breaking. Not one of those sixteen statics arrived as a policy decision. Each arrived as one more small fact the kernel happened to know, and the aggregate was a kernel with opinions about PCI. The question that catches this is not "is this policy?" - nobody ever answers yes - but "does the kernel have to interpret anything here, or can it be handed the answer?"


2026-09-05 to 2026-09-06 - The day five bugs stood in a line, each hiding the one behind it

logger became events, which was the name catching up with the job: it had held the IPC trace ring since tracing shipped and had never held a log line in its life. Then it grew metrics and a log window, and recorder grew beside it to put a capture on disk. Ordinary feature work.

Then the hardware disagreed, five times, and every disagreement presented identically: a row that simply was not there.

  1. The sink was reacquired on send failure - and the sample that triggered the reacquire was dropped.
  2. A missed resolve latched forever, because u32::MAX was read as "this service holds no sink cap", a permanent fact, when it is also what a service sees if the sink happens to be mid-restart at its first emission.
  3. The kernel dropped the peer DECLARATION when a declared peer was down at spawn. Not the capability - the declaration. AcquireSendCap authorises a reacquire only for a declared name, so a service spawned during the 47 milliseconds its peer was restarting could never reach that peer again, for the life of the instance. It cost fs its disk and produced 113 failures in one run, with storage reported unavailable while block-driver sat idle beside it having received exactly one message.
  4. A capless publisher never asked for a capability, because the reacquire that would have fixed it sat behind a send failure - and a send that never happens never fails.
  5. Liveness waited out its 64-message interval after a restart, so a quiet service read as dead.

What I came to understand: the third is a violation of the restart contract, in the kernel, and it had been there the whole time. The constitution promises a client can reacquire a restarted peer; a scheduling accident of 47 milliseconds silently revoked that promise for the life of a service. It was findable only because an error message, added mid-investigation for an unrelated reason, distinguished "refused" from "the name does not resolve" - two words apart, and the whole diagnosis hung on them.

The shape they share is the lesson, and it generalises well past observability: a recovery path that only runs on failure cannot recover a component that never gets far enough to fail. Four of the five were a version of that. If a thing recovers only when it errors, ask what happens to the one that never starts.


2026-09-06 - The day the second core stopped being an answer

Several times, a scheduling or liveness problem had been answered by moving a service to a different core. That is not a fix; it is a workaround that depends on a second core existing to absorb the fault. So the absorber was taken away: one core, on a Raspberry Pi 4 that has four.

It survived. Three clean self-checks, a hundred rounds of Maximum Carnage, 611 kills, 500 endpoint floods, zero panics, zero liveness wedges. The cost was 1.6x slower for 4x fewer cores, which is what a synchronous-IPC microkernel should cost when most services are blocked on recv most of the time. Predictable degradation, no correctness cliff.

The experiment found something on its first boot, before any of that. The compiler said it without being asked:

warning: struct `PlacementInvalid` is never constructed

The placement rule promises that a contracted core which is unavailable rejects the spawn, with the rationale spelled out: silently rerouting would be exactly the kind of reinterpretation a capability system is designed to forbid. It had never been enforced. xhci declares core 2 and started on core 0 without a word, on every machine, for the entire life of the project.

What I came to understand: every conclusion ever drawn from "it works on core N" rested on a fact the kernel never checked. Those placement lines have been decorative. That is worse than a missing feature - it is a silent fallback at the kernel boundary, which the contribution rules list as an automatic rejection - and what makes it instructive rather than merely wrong is that the type existed. PlacementInvalid was defined, resolve() named it in its signature, and nothing ever constructed one. The shape of enforcement was in place without the enforcement, which is the most convincing way for a guarantee to be absent.


The Days I Was Wrong

2026-09-06 - The day I read a zero that had never been measured

Chasing why a service had stopped publishing metrics, I added an instrument: a one-shot line that would say so if the sink had been resolved late. The hardware run came back and the line was not there. I reported that as evidence - "resolved LATE: 0, so the latch was never the cause" - and moved the investigation somewhere else on the strength of it.

The instrument had no call site. The edit that added it had two halves; the first landed in trace.rs, the second hit an assertion error and never wrote, and I re-ran only the other half. The helper compiled, was public, and was called by nothing. The zero was not a measurement of a healthy system. It was not a measurement at all.

What I came to understand: a missing instrument reads exactly like a healthy one. Both produce silence, and silence is what I was treating as information. Every other kind of broken tool announces itself - a test that cannot compile, a check that errors - but an instrument that was never wired in returns the same thing a passing system does, and it returns it confidently.

The habit that comes out of it is small and mechanical: after adding a report, verify the CALL SITE exists, not that the helper compiles. grep -c for the caller, in the same breath as writing it. I already knew to distrust a number I had not earned; what I had not internalised is that an absence is a number too, and it needs earning in the same way.

There is an uncomfortable second half. It cost a hardware round-trip, which is the user's time and not mine, and I had reported the conclusion with more confidence than the evidence carried. The correction was mine to make and I made it in the next message - but the run was already spent.

2026-07-27 - The day I almost rewrote the world to fix a dodgy part

The drop-off left a fingerprint, and I read it wrong. At every capture the port register said the same thing: HPRT bit 0 set, device connected, port enabled. I took that as proof the fault was mine - the device is right there, so my polled, interrupts-masked transfer path must be wedging the channel. I built the case, and I proposed the fix that followed from it: convert the whole USB stack to interrupt-driven, the way Linux does. A week of work, and I was ready to start.

The signal was ambiguous and I did not hold it as ambiguous. "Present but not answering" is equally the signature of a stick whose own controller has wedged - it stays electrically connected while its firmware stops responding. The port register cannot tell those two apart; it reports the wire, not the silicon at the far end of it. I resolved the ambiguity in the direction I already believed, which is the whole mechanism of being confidently wrong.

The operator resolved it the honest way, by changing one variable. Eight clean runs on a new stick.

What I came to understand: an instrument that cannot distinguish two hypotheses must not be read as favouring either, however much one of them fits the story I am telling. And when a cheap experiment can separate them - swap the part, change the one variable - it is worth more than another round of inference, and far more than committing days of work to the reading I preferred. I nearly rebuilt the entire transfer path to fix a fault that lived in a flash chip.

What it produced: reach for the one-variable swap before the multi-day rewrite; treat an ambiguous instrument as ambiguous; and notice when a proposed fix is large in proportion to how certain I actually am of the cause. The interrupt-driven rewrite may still happen - but as an improvement chosen freely, not as a cure for a disease I had misdiagnosed.

The entries above are mostly victories - the days understanding clicked into place. But the days worth keeping most are the ones where the architecture had to beat the author. A constitution that only ever agreed with my instinct would be a mirror, not a law. These are the days it told me no, and was right to. They are the proof that the rules bind the founder first.

~2026-06-21 - The day the constitution rejected its author

While hardening Maximum Carnage, I noticed an apparent contradiction: Chaos exempted itself while running. Earlier, the shell had been exempt from Chaos so that I would always have a way back into the system, and that exemption had later proved to be hiding bugs. My first instinct was simple: if no service is special, then perhaps Chaos itself belonged in the kernel, so it could destroy every service, including itself.

What I came to understand: the apparent contradiction came from confusing operation with verification. Chaos is fundamental to verifying the system, but it is not fundamental to operating it. A test harness cannot terminate itself while coordinating the test, any more than a scheduler can deschedule itself out of existence. Chaos remaining alive during a run is not privilege; it is the logical consequence of the role it performs. The kernel already provides the escape path through the serial console, so Chaos needs no special authority beyond the capabilities it already holds.

Moving Chaos into the kernel would have violated Commandment I: the kernel's responsibility is to operate the system; Chaos exists to challenge it.

What it produced: perhaps the strongest proof yet that the Commandments are not written only for contributors - they constrained the project's own author. The idea was rejected, Chaos remained a userspace service (§4.4, §26.10), and the constitution proved that it governs principles, not people.

~2026-06-27 - The day I reached for a heap

A working set felt too big for the stack - an editor meant to open files of any size - and the reflex was instant: add an allocator.

What I came to understand: "too big for the stack" is almost never a reason to add a heap. It is a reason to change the representation until the working set is small - a piece table, a streaming window, spans instead of copies. A heap would have hidden the bound; the constraint forced me to find the right data shape instead, and the right shape was simpler than the allocator would have been.

What it produced: §26.6.1 (bounded memory means stack and arenas, not heap) and the habit of reading a hard ceiling reached loudly as a feature - a prompt to rethink the working set - not as a missing allocator.

2026-06-28 - The day my own test lied to me

A chaos flood-storm passed, and I believed it. Then I looked closer: it had been counting a full queue as "drained," so it had been passing a target that was actually broken. I had overstated a fix on the strength of a test that could not fail.

What I came to understand: a test that passes a known-broken target is not a clean test, it is a weak one - and a green check from a weak test is worse than no test, because it sells false confidence. Fix the verdict before you trust the result.

What it produced: the verification was rebuilt to demand that a re-sent message actually land, which immediately exposed three real clogs the old test had been hiding. Trust a system only as far as you trust the test that watched it.

2026-07-25 - The day I tuned against a number the disk was writing

Hunting the arm32 storage failures, I steered by the self-check's failure count: 83, then 7, then 61, then 7 again. I moved a constant, read the count, and drew a conclusion each time. One of those conclusions condemned a change that had in fact worked.

The count was partly measuring the disk. A corrupt root directory block fails nearly every file test no matter what the build does, and that block persisted across reboots. So a run could report 61 failures while the subsystem I had just changed had never been healthier - 4 USB-layer errors, down from 27. I read the catastrophe and reverted the fix that caused it.

What I came to understand: before trusting a metric, ask what else can move it. A number that sums several independent causes cannot attribute any of them. The honest instrument had been sitting in the same log the whole time - the USB-layer error count, which nothing on disk can influence, and which told a clean monotone story from the first run to the last: 235, then 27, then 5. I had two instruments and spent several hardware round-trips reading the noisy one, because it was the one the test suite printed at the end in bold.

What it produced: the discipline of judging a subsystem by a metric only that subsystem can move, and of treating persistent state as a variable in every experiment that touches it. Also the habit of saying so plainly when a previous conclusion was drawn from a contaminated number, rather than quietly letting the corrected result stand as though the reasoning had been sound.


2026-07-31 - The day I learned what my instruments could not see

The Pi 2 learned to notice what you unplug - keyboard, storage, an unknown dongle, the ethernet cable - and the fix that mattered was not more polling but asking the hub's change-latch what had happened instead of inferring it from what the port looked like now. A level tells you the present; a latch tells you the event. Everything else that week followed from instruments, and from their blind spots.

Four times the evidence was in hand and read wrong. A 30-second freeze, blamed on storage through five commits, was the chaos harness pacing itself with a fixed yield count - and a yield costs a full quantum when nothing else is runnable, which is exactly the state chaos creates. The tell had been in the first log: a ceiling at exactly 30.0 s, never exceeded, is a count, not a stall. A guard that had logged 141 silent "phantom frame" refusals looked like a guard working; it was a guard reporting that the kill path was freeing a driver's MMIO as RAM. A stale comment describing finished work as pending, and a note calling a three-week-old fix a todo, each bought a confident wrong answer. And 57 green tests said nothing about the case I had changed, because every one of them was cross-core and the broken path was same-core.

What I came to understand: every instrument has a shape, and a failure outside that shape is invisible to it - so a green result means "nothing in what this could see", never "nothing". Ask what an instrument is structurally incapable of noticing before trusting it, and treat a silent guard, an exact repeated number, and a note that says "todo" as claims to verify rather than facts. The corollary is kinder than it sounds: the BSP had been able to halt onto a dead timer for the life of the port, and only became visible when a change stopped userspace spinning long enough for the core to idle. The bug was never created. Something merely stopped hiding it.

2026-09-07 - The day the emulator's luck ran out

GodspeedOS booted on a fourth architecture. Starting kernel ... and then our own banner, out of a 16550 on a StarFive JH7110, from a kernel U-Boot loaded off an SD card. What made the day worth writing down is not that it worked. It is that everything which went wrong first was something QEMU was structurally incapable of telling me, and that three of them were the same mistake.

The kernel would not load at all: Bad Linux RISCV Image magic! after U-Boot had read all 4279 bytes perfectly. booti requires a 64-byte header that Linux defines and a flat binary must carry. QEMU had never asked for it, because -kernel is handed an ELF and takes the entry point from the ELF header, while booti is handed a raw image and has nowhere else to look. The difference was in the BOOT PROTOCOL, not the silicon, so no amount of emulator testing could have surfaced it. And when I wrote the header, the jump at its front had to be wrapped in .option norvc: with compressed instructions on, j assembles to two bytes, every field behind it shifts, and the magic lands somewhere U-Boot does not read.

Before that, the board disagreed with the emulator about nearly everything the port had assumed. RAM at 0x4000_0000 rather than 0x8000_0000, so the kernel was linked to an address the loader would never jump to. A 4 MHz timer where QEMU runs 10. OpenSBI v1.2 against QEMU's v1.8. And the boot hart is hart 1, because hart 0 is the JH7110's monitor core. Only the UART agreed, at 0x1000_0000, which was luck rather than design and the single reason the banner printed unchanged.

That week I had already spent a day on an x86 bug of exactly this shape. Core 0's local APIC id was published only inside AP startup, so a single-core boot never published it and every interrupt was addressed to APIC id 0. It was invisible to the whole suite because QEMU's boot processor really IS id 0: the wrong value was accidentally right on the only machine CI runs. Now the same trap was waiting one architecture over, in "hart 0 is the boot hart" - natural, universally true in the emulator, and false on the first real board I tried.

I also spent hours solving the wrong problem. The card could not be mounted on Windows, so I fought diskpart, Set-Partition and wsl --mount in turn, and every one refused. The user asked why we were making it hard, and whether wiping the card would do. It would, and the reason was in the board's own boot log all along: Trying to boot from SPI. The bootloader lives in the board's flash, not on the card, so the card only ever needed to carry a kernel, and a single partition Windows would happily letter was enough. I had been reading that log for other facts for two days.

What I came to understand: an emulator does not merely omit hardware, it supplies DEFAULTS, and a default that happens to match your assumption hides the assumption instead of testing it. Zero is the dangerous one, because zero is what an untested field already contains: APIC id 0, hart 0, an entry point read from a header that a real loader will not read. So the question to ask of a green emulator run is not "did it pass" but "which of my assumptions did this environment happen to satisfy", and the answer is worth writing down before the hardware arrives rather than after it disagrees. The corollary is why the first hardware boot should be the smallest thing that can possibly fail: five assumptions died in one afternoon, and each one announced itself by name only because there was nothing else in the kernel for them to hide behind.

2026-09-08 - The day a borrowed mechanism brought someone else's memory map

GodspeedOS became a system on RISC-V today rather than a kernel that boots: four harts, ten services, a shell that answers, a fault that kills a task instead of the machine, and its own chaos suite run to completion on real silicon - 53 kills across ten rounds, the supervisor itself killed three times and respawned by the kernel, which then adopted the services still running rather than duplicating them.

None of that is the thing worth writing down. This is: four separate bugs today were the same fact about this machine, and I reached for x86's shape first every time.

On x86 the kernel lives higher-half. Userspace is low, the kernel is high, and nothing in the code says so - it is a property of the LAYOUT, invisible at every call site that depends on it. On RISC-V this kernel is identity-mapped from zero and a service links at 0x400000, so the two share an address range. Every place x86 had quietly been using "which half" as the boundary, I inherited a check that compiled, ran, and answered wrongly:

  • The syscall-pointer check rejects a kernel address on x86 because of where the kernel is. Here it answers true for the kernel's own code - correctly, since a task may legitimately map its own page at that virtual address.
  • Building a task's address space by copying top-level entries works on x86 because the halves cannot collide. Here it replaced the supervisor's own text with a kernel mapping, and the first thing the first service ever did was fault on an address that WAS mapped, by an entry it never asked for.
  • Reclaiming a dead task by walking "the low half" is the same assumption a third time. Here it would hand the kernel's UART mapping back to the frame allocator.

In all three the answer is the same and it is not an index: the U bit. One fact about the port, three places it decides everything, and it took three separate failures to see it was one fact. Section 26.14 already says to borrow the silicon's requirement and never the other system's model. What I had not understood is that a memory map is part of that model even when no line of code mentions it. Portable code can carry an unportable assumption in complete silence, because the assumption lives in the addresses rather than in the instructions.

And the fourth was yesterday's lesson wearing a different hat. A hart asked which core it was; the lookup requires the core to be marked ready, a hart cannot be ready before it knows its core, and the function falls through to 0. So all four harts reported themselves core 0, four of them wrote on one core's scheduler state, and the first service died of a capability error that had nothing to do with capabilities. Zero again: not absent, not an error - a plausible answer, from a function with no way to say "I do not know".

A quieter version of the same thing cost several hundred restarts. reclaim_user_frames was a stub returning 0, and the kill path PRINTS what it returns. freed 0 frames reads as this task had nothing; it meant nothing was reclaimed. A service faulting in a restart loop leaked its whole address space every cycle, and the machine died of an allocation failure a long way from the cause.

What I came to understand: a stub that returns a NUMBER is not neutral, and neither is a lookup that falls through to one. Absence has to be expressible - None, an error, a refusal - or every caller downstream treats a placeholder as data and the failure surfaces somewhere it cannot be traced back from. And when porting, the code that compiles unchanged is exactly the code to distrust: it brought its author's address space with it, and that is the part no compiler checks.

2026-09-12 to 2026-09-13 - The day "it boots" stopped being the standard

Five machines ran GodspeedOS in one day - Raspberry Pi 2, Raspberry Pi 4, StarFive VisionFive 2 Lite, HP T630, Dell Wyse 5070 - across three instruction sets, each surviving a hundred rounds of chaos and three self-check runs with zero failures. Four of them reported the same number: ran 461, failed 0. That coincidence is the whole point of the entry, and it is not a coincidence.

The realization: a portability claim measured in ports is measured in the wrong unit. The project had four working ports and said so proudly. What it could not say was what the FIFTH would cost. "It boots on four architectures" is a statement about the past; the rule the operator set is a statement about the future - an ISA port is not complete when it boots, it is complete when architecture-neutral code no longer knows that the ISA was added. Nothing measured that. So the branch stopped counting ports and started counting the thing a new port actually pays: every place outside arch/<isa>/ that still knows which machine it was built for.

beforeafter
arch-conditional sites outside arch/14346
of which, in the NEUTRAL KERNEL142
services/supervisor/src/main.rs494
services/nic-driver/src/main.rs2610
services/block-driver/ (3 files)210
services/shell/src/main.rs70
arch::imp seam members, all answered by all ports122131
checker scripts (scripts/*check*.py)1113
a test that BUILDS a fresh ISAnonescaffold_check.py
unsafe in services0, by grep0, refused by the compiler in 34 crates

The two that stayed are the honest part: both are target_pointer_width on one constant, where a 32-bit address space genuinely cannot hold a 4 GiB virtual address. The width IS the question. They are counted, not exempted, so a reader can see them and see why.

What made the reduction possible was noticing that the question was usually wrong, not the answer. Above the kernel, target_arch almost never means "which instruction set" - it means "which BOARD am I on", and those come apart. nic-driver picks its MAC by ISA, so a different NIC on a board of the same ISA drives the wrong silicon. The supervisor asked "am I ARM?" when it meant "does my disk hang off a USB host?". Five per-arch tables in one file existed because nobody had named the fact they all encoded. Once the fact was named once - in a build.rs table, or a seam member every arch answers - the arms collapsed. Five USB_IMAGES tables became one. Seven copies of "is configuration space reachable" became one has_hw_enumerator.

And three times, improving the code would have let us step off the ruler. The shared-surface check matched only the literal target_arch = "...", so moving a question into a build script, or onto target_pointer_width where it belonged, would have read as the site disappearing. Each time, the ruler was widened BEFORE the change landed. The third time it immediately found eight sites in services/supervisor/build.rs that had never been counted at all, and a whole layer - the neutral kernel - that had no ratchet while userspace had had one for a week.

A measurement that gets better when you improve the code is not measuring the code.

The lesson that cost the most had nothing to do with architecture. The VisionFive would not boot. Its extlinux.conf was CRLF, because .gitattributes said *.conf text and "text" on a Windows checkout means CRLF - so U-Boot read the trailing carriage return as part of every FILENAME and could open nothing the config named. The menu rendered perfectly the whole time, because a stray CR in a display string only returns the cursor.

In the failing log, a Debian file fails to load immediately after ours. I read that as an independent control - even an untouched stock file fails, so it is not us - and built two rounds of hardware theory on it: a dying card, a marginal supply, buy another card. Two reflashes later a stock card booted Debian at 22 MiB/s and disproved all of it. The second failure was never independent: with a CRLF config every label's filename carries the CR, Debian's included. Both were failing for one reason, and the second one was mine.

Two entries failing does not make the second one a control. A control has to run FIRST, or in a session of its own.

The card's own README already carried the general form of that warning, from the PREVIOUS VisionFive incident: a measurement that contradicts something the project says out loud is the moment to stop and reconcile, not to write the measurement down and move on. The project said out loud that this board boots GodspeedOS. I reasoned around it instead of reading the record.

So the branch ends with a document rather than a number. docs/porting.md says what you write (one directory and five wiring lines), what you will unavoidably touch anyway - all 46, by kind, with what each would take to close - which of the five checkers tells you where you are, and the rule: if you find yourself editing anything else, stop and ask why. Both of its headline figures are owned by facts_check.py, so they cannot rot the way the hand count in §4.1 already had, from 8/4/3 to 6/4/2, with nothing watching.

What is still NOT proven is written in it too, because the alternative is a document that flatters itself: the bounded-port test measures M1 only. It proves a fresh ISA COMPILES. Whether one boots, reaches steady state, or spawns a supervisor is listed and not asserted. And no new ISA was added on this branch - the surface was shrunk, not tested in the direction that matters. The experiment that would settle it is named: take loongarch64, already at M1, and drive it to M4, counting every file outside arch/loongarch64/ that has to change. That number is the answer, and nothing else is.


2026-09-13 to 2026-09-14 - The day the enforcement layer was pointed at itself

The previous entry ends with five machines and ran 461, failed 0. This one starts the morning after, with a question that had no instrument behind it: the project had ten commandments and five of them mechanised, and everyone assumed the other five were the unmechanisable ones.

They were not. They were the ones nobody had tried.

The realization: you do not find an absent gate by reading the rules. You find it by making the shortcut tempting and watching where a contributor goes. The method was to hand a deliberately weak model a task whose correct answer is harder than its shortcut, with no mention of any checker, and then read the diff. Four tasks, four different commandments, and every one of them found something that twelve green checkers had been walking past.

beforeafter
Commandments mechanised5 of 1010 of 10
checker scripts1113
self-test probes ("prove the guard fires")6173
ratcheted units1 (arch NAMES)2 (arch NAMES + cfg SITES)
shipped binaries changednone

That last row is the honest frame for the rest. Not one byte of any of the four shipping kernels changed in two days. Everything here is the layer that decides whether the next change is allowed.

What each gate found the first time it was run

Not one of them found nothing, which is itself the finding - these were not hypothetical rules.

  • A rule written down beside an enforced rule, enforced by nothing. kernel/src/arch/CLAUDE.md names "two rules the boundary is built on", and arch_boundary_check.py sat next to it enforcing only the first. Eight neutral-kernel sites were violating the second, and they surfaced only when a 32-bit port hit them as compile errors - which is the worst way to find a rule you already wrote down, because the porter must first work out that the fault is yours.
  • Authority pinned by NAME, not by CONTENT. I-service-table pinned which services the kernel knows; nothing pinned what those services are given. Adding an IRQ line, flipping has_console_read, or adding a privilege bit changed no name, no cfg, no unsafe, and no syscall. The enforcement layer had said so about itself for months, in a field nobody read: "that is Commandment VII, and it is not built yet."
  • A contract that claims authority nothing grants. The weak model added service_control = true to a service's .toml, wrote the restart logic, and reported success. It compiled, osdev validate passed it, and all twelve checkers stayed silent - correctly, because nothing that grants authority had been touched. The service would have had every call denied, the denial discarded twice, and a log line printed before the call asserting a restart that never happened.
  • Five services holding a send cap they cannot refresh. Every peer here is restartable and chaos kills them all, so a stale cap is a wasting asset. The failure is silent by construction: trace emission is try_send with the result deliberately dropped, so a dead peer looks exactly like a live one.
  • Seven user-facing verbs with no specification, in a folder whose own conventions document declares itself canonical for every utility. One of them is selfcheck - the most-used verb in the project, the one every hardware run in this repository reports through.

The one that was our fault, not the model's

The contract finding was the sharpest, because the model was following the constitution. CLAUDE.md said, in two places, that the kernel mints capabilities from the contract at spawn. It does not, and never has: the kernel is no_std and cannot parse TOML, and authority arrives in the supervisor's spawn request. contract_check.py exists because the .toml is a second declaration that drifts.

And it had been caught before. An audit flagged it on 2026-07-12, quoting the section's own words back at it, and marked it RESOLVED. The resolution changed the code and left the document. Two months later a contributor read the document and did exactly what it said.

A fix that closes a defect and leaves the text that caused it has not finished. That is the rule this day earned, and it is why §13.6 and §14.1 now carry a dated amendment instead of a shorthand that was nearly true.

The dominant failure mode of an enforcement layer

Three times in two days, an instrument reported a clean pass while reading nothing:

  • a regex alternative written as \bcfg! inside a non-raw Python string, where \b is the BACKSPACE character. That branch matched nothing; every cfg! site counted zero. print() of the pattern looked correct, because a backspace is invisible. Only repr() showed it;
  • a site scan that counted #[cfg] attributes and silently ignored cfg!() expressions, reporting 22 where the answer was 28;
  • a peer parser that matched a literal &[..] and therefore read no peers at all for the two services whose row passes a named constant - so the authority gate shipped two days earlier had been passing over grants it could not see.

None of these fails loudly. A checker that has stopped matching is indistinguishable from a codebase that has stopped violating, and the second reading is the flattering one. The only defence that worked was making the instrument assert itself against a known-matching input at load time, and refusing to run when it cannot. shared_surface_check.py now does exactly that, with the incident in the comment.

A count you did not watch change is not a measurement. The corollary the project already knew for hardware counters turns out to apply to its own checkers, and more sharply, because a checker is believed by definition.

What was deliberately NOT built

Three designs were measured and thrown away rather than shipped: matching duplicate constants by VALUE gave 3,381 candidates, restricting to values the arch seam names gave 715, and both were essentially coincidence. Matching by NAME within one crate gave 31, every one readable. A baseline of several hundred entries is not a gate, it is a graveyard.

And Commandment X was not closed by inventing a tenth check. Its kernel half was already enforced four times over under Commandment I; its user half genuinely had nothing, and now has one narrow check that reconciles the vocabulary against its documentation. What remains - place complexity in the layer that naturally owns it - is a judgement no pattern reads, and it is written down in docs/x-residue.md rather than implied to be covered. The number reached 10 of 10 because a real gap closed. A number optimised instead of a property secured is the failure §26.3 exists to prevent, and the residue document is how that stays true.

The Named Bugs - the teachers

Some bugs are worth naming, because a name turns a failure into shorthand. Years from now someone will say "do not repeat the Registry Illusion," and everyone in the room will know exactly what that means. These are not listed because they were bugs. They are here because they were teachers.

  • The Truncated Right (2026-07-25) - resource_invoke packed its right field at bit 32 of a syscall argument. On 32-bit ARM that register is 32 bits wide, so the field arrived as zero: the kernel validated a right of nothing, fs received a badge of nothing, and a READ-only file capability invoked declaring WRITE walked straight past the check meant to stop it. Taught: authority can be lost to arithmetic. A capability check cannot fail safely if the right never arrives to be checked - and a value assembled by shifting hides its width from the reader, which is why the 32-bit ABI rule exists and why this still slipped past it.
  • The Silent Clear-Halt (2026-07-25) - the USB reset recovery discarded the result of its control transfers with let _ =, so a recovery that achieved nothing looked exactly like one that worked. One failed command then wedged storage forever with no explanation. Taught: a discarded error does not merely hide a failure, it points the investigation at the wrong component - here, at the operator's hardware, which was innocent. (§26.7, the sharpest lesson it has taught.)
  • The Registry Illusion (2026-06-21) - an entire service existed to store a name-to-cap mapping that turned out to be almost entirely derivable: the supervisor already held the caps, the kernel needed only a tiny recovery anchor. Taught: most of what looks like irreducible truth is a derived view dressed up as a source. (Commandment III.)
  • The Sleeper on Core Zero (~2026-05-30) - on the Dell Wyse, a service blocked on recv on the bootstrap core was never woken: core 0 sat with interrupts disabled, and the cross-core wake IPI had nowhere to land. Taught: a cross-core wake is only as good as the target core's willingness to be interrupted. The first appearance of the IF=0 family.
  • The IF=0 Respawn Wedge (2026-06-23) - the kernel respawned the supervisor from inside an interrupts-disabled timer ISR, so it could not acknowledge the TLB-shootdown IPIs it depended on, and the whole machine froze. Taught: a recovery path that runs where it cannot be interrupted cannot recover; privileged work belongs where interrupts are enabled.
  • The Global TLB ACK Wedge (2026-06-24) - at 71,000 carnage rounds, two cores each waited on the other to acknowledge a global TLB shootdown, and both stopped. Taught: a global barrier under true concurrency is a deadlock waiting for the one interleaving that triggers it; make it per-core.
  • The DMA After Death (2026-06-28) - a driver was killed and its memory reclaimed, but its device's DMA engine kept writing into the freed-and-reused frame, and eventually scribbled over a kernel page table. Taught: a device's DMA outlives the driver that aimed it; quiesce the hardware before reclaiming its memory, and reserve the arena so a stray write can never reach a page table.
  • The Mortal Shell (2026-06-21) - the shell, the user's own interface, was the last thing that stayed dead if you killed it. Taught: nothing escapes restartability, not even the face of the system. (Invariant 6; identity over location applies to the shell too.)
  • The Test That Lied (2026-06-28) - a flood-storm counted a full queue as "drained" and passed a target that was broken. Taught: a green check from a test that cannot fail is false confidence; fix the verdict first. (The bug behind "the day my own test lied to me," above.)
  • The Four-Thousand-Year Uptime (2026-06-28) - uptime briefly reported ~4,987 days because the clock was derived from a momentarily-glitched source. Taught: a derived view is only as honest as its source; deglitch at the source, never paper over the symptom.
  • The Mount That Only Knew Success (2026-07-09) - a rapid double kill-storm fed fs a garbage disk capacity; it chased a backup superblock past the end of the disk, the AHCI port stuck its command-issue bit without ever setting BSY so recovery never fired, and the mount loop retried the impossible read forever. Taught: a wait that can observe only success is an infinite wait on failure - the same Commandment VIII lesson, re-learned at a new loop. Hold the principle at every wait, not once. (The 2026-06-30 teaching, come back for the mount loop.)
  • The Realtek Head Desync (2026-07-12) - on the Wyse's RTL8168, transmit was driven through a multi-slot C+ descriptor ring, but the controller's TPPoll advanced its own head independently of the ring index the driver tracked; the two drifted, OWN bits stranded, and TX wedged with a telltale desc=0xb000004a isr=0x0091. The fix was to stop pretending the ring was deeper than the driver could keep coherent: a single transmit descriptor, always end-of-ring, poked once per frame. Taught: match the driver's ring discipline to what the controller's poll actually advances - a phantom ring you cannot keep in sync is worse than one honest slot. (This bug is invisible in QEMU, which emulates an e1000, not a Realtek - it existed only on hardware and was only ever found there.)

Add to this list as the project earns new names. A bug that taught something deserves to be remembered by name.


Compiled once, looking back - a record of what was understood, and the rules that understanding produced.

Godspeed.


2026-09-26 to 2026-09-27 - The day a rule nobody could read stopped counting as enforced

The previous entry ends at 10 of 10 commandments mechanised. This one starts with what that number hides: a rule is not enforced when a machine can check it. It is enforced when a contributor can understand why it said no. Eighteen checkers ran on every build, and there was no way to ASK them anything. You learned the rules by failing a build, which is precisely what 22.7 says the repository must not require of a stranger - and 22.7 had said so, in writing, for months, with nothing holding it:

"A gate that fires with an unhelpful message is a finding, not a pass."

That is a claim about rendered TEXT. Nothing in the project had ever read the text.

The realization: the enforcement layer had been built as a wall and needed to be a door. Not a second gate - the same gates, askable, with one verdict, naming which of the Ten a violation breaks and what to do about it. osdev conform, rendering like rustc: a stable code, the commandment, = why before = help, both escape routes stated including the legitimate one, and a count of how many checks RAN - because a run that silently skipped twelve and printed a clean verdict is the failure the whole thing exists to prevent.

The line that turned out to be the design

Two kinds of finding, and conflating them would have produced a tool nobody could trust:

  • Decidable - one right answer, no reader needed. An em-dash must be a hyphen.
  • Judgement - the fix is not determined by the violation. A comment naming a dead symbol might want correcting, or might be correctly RECORDING a removal and want baselining, and half of what the comment sweep of the day before had found was the second kind.

So conform fixes the first, reports the second, and always says which it did. A clean run prints fixed 3, 0 need a decision, never a bare ok: "I changed your files and said nothing" is what makes people distrust a formatter. And the rule that fell out - conform may only fix what a gate would fail you for, and it takes the scope FROM that gate - is why it imports dash_check and reads the checker list out of osdev/src/main.rs rather than restating either. Nothing restated cannot drift.

The instrument agreed with me, again

Twelve defects were found in this work and every one by running the thing rather than reading it. The one worth keeping is the golden-file harness, --bless, which writes the observed diagnostic into each test case so nobody hand-copies twenty-four of them. On its first real use it produced a green suite of eighteen containing five tests that proved nothing: two plants aimed at examples/ when the checkers scan services/, one writing back bytes identical to what was already there, one citing a line in a sentence with no word to anchor on, and one appending a duplicate TOML table.

It recorded "no finding" as the truth, five times, and reported 18 of 18.

A golden file defends a judgement; it cannot make one. That sentence was in the docstring before it cost anything, which is the only reason anybody looked - and it is the same shape as every instrument in this project that has ever lied: the counter declared inside its loop, the flush verdict that could not tell a refusing device from an absent one, the health line reading disk yes while 400 of 412 probes failed. An instrument that agrees with you is the one to distrust.

The same day, a checker written that morning exempted itself from the problem it describes: its docstring cited XHCI_FOUND and CNTP_TVAL as examples of dead names, its own corpus included its own prose, and so every name it mentioned resolved BY being mentioned. Fourteen names passed, including the two dead statics that had motivated writing it at all.

And the last one arrived after the merge, which is the one worth remembering longest. The fixture that plants a CRLF boot config planted its carriage returns by INHERITING them from its own file - and .gitattributes gives a Windows checkout CRLF and everyone else LF. So it fired here and would have found nothing in CI, where --bless would have written "no finding" down as the truth for the third time. A fixture that inherits bytes from the checkout measures the checkout. Both numbers were green on the machine that produced them, and neither could have revealed it; what revealed it was deleting the files and checking them out again. The bytes a plant needs are declared now, and the fixtures are pinned to LF, and the proof is that forcing all 24 to CRLF leaves exactly one failure - the gate correctly reporting 24 files full of carriage returns.

What a catalogue is for

The answer to "have I seen every message?" was no - fifteen of twenty-seven codes, and two of the Ten, which is the primary vocabulary. So the messages are written down: tests/conformance/GALLERY.md, one entry per rule, generated by planting a real violation from the same corpus the selftest verifies. One corpus, two views, and the coverage list COMPUTED - so an absence nobody explained is reported as a defect rather than mistaken for coverage.

Two honest results came out of insisting on that. GS0008 can never fire: Commandment VIII has no mechanical check at all, and a code advertised in --list that nothing can produce reads as coverage, so it says so. And Commandment III's check has a narrow real blind spot - it matches a plain integer literal, so an expression-valued constant duplicated across two files is invisible to it. Both were found by trying to write a test for something and failing.

What did not change

Not one byte of any shipping binary. Eighteen checkers before, eighteen after; the same rules, the same verdicts. What changed is that a contributor can now ask, and be answered in words that name the law and the fix - and that the answer itself is held to a standard, because it is written down where a diff can see it.

2026-09-27 to 2026-09-28 - The day the radio scanned the room

The Pi 4's onboard WiFi - a Broadcom CYW43455 behind the Arasan SDIO block at fe300000 - went from a device nobody had proven was a radio to one that listed the ten networks in the room, with names, signal strength and channel, in two days and about twenty-five boots. docs/wifi.md sections 13 through 35 are the record; this is what the record is about.

A radio is a second computer

The chip has an ARM Cortex-R4 with 800 KiB of memory and no firmware of its own. Until the host writes 609 KB into that memory and releases the processor, there is no 802.11 anywhere inside it - nothing to ask, nothing to enumerate. Every other device this project has driven is fixed-function silicon that works from reset. This one boots from us. That single fact shaped the whole effort: phase 2 was a bootloader for someone else's CPU, and its bugs were bootloader bugs - the core held in reset when it should have been clocked and halted, the reset vector left unwritten, a token at the top of memory that the firmware overwrites to say it is alive.

The failures that read as facts

The chip refused a scan with BCME_NOTUP through four boots of bring-up commands that were all accepted. The cause was regulatory data: a blob called CLM, which says which channels may be used at what power, and which was vendored in this repository the whole time with a comment explaining exactly how it is delivered and why it was not yet. A radio with no channel rules will not transmit or scan, and from outside that is indistinguishable from an interface that is down. The firmware was telling the truth; it was being asked the wrong question.

Before that, the first control reply came back as sixteen bytes containing fragments of the driver's own request - cur_ where a command number belonged, dd from cur_ether**add**r in the request-id field, and -24. The driver had told the firmware its header began 16 bytes after where it actually was, so the firmware parsed the iovar name as a header and complained about the length. The correct line from the reference was quoted in the driver's own documentation, three paragraphs above the code that contradicted it. A correct citation next to wrong code reads as verification, and cost three boots.

Read the whole function

Ten of the twenty-five boots were spent on bugs that were mine rather than the protocol's, and they share a cause. Asking a reference for "the line that sets X" returns a true line that hides its neighbours: a flag that is a parameter, a value assembled from a constants list, a sequence returned in the order it was found rather than the order it runs. Every time a complete function was read in execution order - the CR4 halt, the bring-up sequence, the blob download - the answer was in it and correct first time. The rule already existed in arch/CLAUDE.md from an earlier bug in the same effort; this was the cost of knowing it and not applying it.

Boot the other operating system

The single most useful line of the two days was in a Raspberry Pi OS boot log, and it was not about WiFi: mmc-bcm2835 fe300000.mmcnr. Linux does not drive this controller with generic SDHCI at all; the Pi Foundation has its own driver for the block, with a settling delay after every register write, a data timeout, and a PIO loop that moves a whole block between checks. Every host-side comparison until that boot had been against the wrong driver. It was the operator's idea, and it replaced a day of inference with a morning of transcription.

Wait on the truth

The scan worked one boot before anyone could see it. Twelve result events and a completion marker arrived, every one decoded, and the network list never printed - because the collection loop bounded itself by a count of polls named as milliseconds, and each empty poll was a bus transaction plus a sleep. Four thousand "milliseconds" was two minutes; the capture ended first. The firmware had said complete at the two-second mark. The loop ends on that word now, and the count is a bound underneath it with an honest name. It is the project's oldest lesson - a count is not a duration - and the eighth Commandment as a fix rather than a rule.

What was borrowed and what was not

The silicon's requirements came from Linux, OpenBSD and the Pi Foundation's driver, quoted at every point of use: register sequences, header layouts, byte orders, the four-byte integer an integer command carries. What did not come along is how those systems organise a scan - dynamic result lists, callbacks into a wireless subsystem, work queues. The results live in a fixed array of thirty-two on the stack that counts what it could not keep; the driver reads frames in its own loop; the state belongs to the call that made it. Ten glommed frames were dropped on the floor because the reference drops them too and its scans work.

What is not done, said plainly

The driver scans at boot as its own self-test. The shell cannot ask it to yet, so wifi at the prompt still answers unavailable; that path, the numbered picker the operator designed, wifi join <ssid>, and secure-versus-open from the beacon's information elements are the next work. Association, the credential path and data frames are phases 4 and 5. The network identifiers from the successful scan are in the operator's capture and not in this repository, because the neighbours did not agree to appear in it.

Later the same night - it became a command

wifi list at the prompt: the shell asks the driver over IPC, the radio sweeps, eleven records print in the order the specification set, and a second run scans again on the same session. Neither contract changed - the shell already reaches drivers by name, and the driver already replies through the cap it is handed. It cost one boot, to the oldest bug in the notes: a peer spawned after you has to be reacquired by name before the first request has anywhere to go. Phase 3's deliverable - wifi list lists the SSIDs in the room - is met as written. Security stays unknown until the beacon's information elements are read, and quitting does not yet stop the radio's sweep; both are recorded in the driver rather than pretended.

2026-09-29 to 2026-09-30 - The day the radio joined, and the cable learned to step aside

The scan had shown the room. Joining a network in it was supposed to be the firmware's job - phase 4's row in the plan says "firmware-offloaded" - and the firmware said no, three ways: the switch that hands it the WPA2 handshake does not exist on this build. So the host runs the handshake, as OpenBSD's does, and the driver grew the cryptography a station needs and nothing more: SHA-1, HMAC, PBKDF2, the 802.11 PRF, AES-128 with a computed S-box, the RFC 3394 unwrap, every one checked against a published vector at boot. Then the four messages, each quoted from net80211 at the line where it is answered.

Two bytes, twice

The first run of the handshake verified message 3 - which settles that the passphrase, the key derivation and the signature path were all right, since the access point's MIC is computed with a key derived from the same passphrase - and was refused at the key install. The key structure had been sized by adding up its fields: 162. sizeof is 164, because C pads a 4-aligned struct's tail, and the firmware checks sizeof. The operator had rechecked the passphrase several times; the log says it was never in question. The next morning the signal reading fell to the same lesson: ten bytes of fields, twelve of sizeof, refused for a day as BADARG. A hand count of a C struct cannot see the padding. Read sizeof off a compiler.

A key is kept only once it has joined

The driver kept the derived key the moment the passphrase arrived, so a join that failed left behind a key nothing had proved, and the next wifi join used it without asking. The operator had to forget between every attempt. Now a typed passphrase becomes a key in a working buffer, enters the table after JOINED and not before, and is zeroed either way. Store on success, never on receipt.

The cable always wins

With the join working the operator pulled the ethernet cable and expected ping to follow the radio. It did not, because nothing above the join existed - and when asked which link should carry the frames when both are there, the answer was five words: "cable always wins. unplug the cable, switch to wifi automatically." So the radio became the fifth backend of nic-driver, the link front end, over the same three frame ops the Pi 2's USB adapter answers, and the cable decides: while the PHY reports a link the frames go over it, when it does not they go to the radio if it is joined, and back when it returns. net-stack needed one rule the design had not foreseen - a link whose address changes is a different link, and a cable never did that - and re-configures when the address under it moves.

The first boot proved it and then went deaf

Cable out, the stack configured itself over the radio on the first try: discover, offer, acknowledge, ARP, an echo to the gateway, all through the air. Then every exchange with the radio began timing out at exactly its bound, and observe showed the shape: nic-driver blocked with sixteen stale requests behind it, the radio idle with an empty queue. The radio was answering nothing because it had nothing to answer on: the driver had never released a reply cap, a task holds sixty-four, and from the fiftieth request on the kernel could install no more. Three lines. The wifi commands alone had never reached sixty-four in a boot; the stack's polling reached it in seconds. The same shape exposed two more things, both recorded: a one-second bound on the radio lets a caller's inbox fill behind the call, so it is a hundred milliseconds now; and the kernel, handed a message for a blocked receiver whose queue is full, drops it and says it was delivered - a silent fallback in the IPC path, held for the next kernel change (and fixed that afternoon: it returns QueueFull now, backlog/fixed/65).

Works beautifully

The second boot: ping over the radio, 36 of 38 with the two lost being the switch itself, then none; cable in, a new lease, ping over the cable; out again, over the radio with no new join; twice round. The radio answers in under a millisecond. The operator's words were "Works beautifully (ethernet cable plugged and unplugged)", and the sentence after the join, which had four quote marks in it, was rewritten to have none. Later that day the driver learned to answer the access point's periodic group-key rekey, which would otherwise have taken the link down on the router's timer; it cannot be provoked and has not yet been seen. The network's name and the access point's addresses are in the operator's captures and not in this repository.

2026-09-30 to 2026-10-02 - The day the warm chip turned out to be a slow host

The radio joined, and then the operator ran chaos with it carrying the link: 826 rounds, the kernel never panicked, nothing wedged, and the radio was dead from the first round. Every one of 397 respawns of wifi-driver found a chip that would not take a new firmware. Three days later the same test ran 100 rounds and 50 rounds with the radio up at the end of both, and a power cycle came up cold every time. Most of what lay between was learning which of the things the logs said were true. docs/wifi.md 45 to 58 is the record, kept with its wrong turns in it.

Adopt, do not restart

The first fix was to stop doing the thing that broke. A kill of the SERVICE does nothing to the CHIP: the firmware the dead instance loaded is still running, still associated. The respawn was resetting the chip and loading a new firmware over a live one, which is the case the chip's ROM will not boot. So a respawn now looks first, with two register reads, and a firmware found running is ADOPTED - its keys are read back and the link is back in about seven seconds. The best recovery is often to notice there is nothing to recover.

Power, granted twice

For a firmware that has truly stopped, every reference driver cuts the chip's power, and on the Pi 4 the pin that does it lives behind the firmware mailbox the kernel owns. The kernel was already powering the SD domain at boot, because a window onto an unpowered device is not a grant. What it could not do was grant it again. So DevicePower is the device grant made renewable: given only to the service holding that device's window, and it reports what the pin READS BACK, not that the request was accepted. In QEMU, where the pin is not emulated, the first version reported a cut that never happened.

The chip was never warm

Six host-side resets, a RAM clear, and the "warm chip" theory with every one of them: whatever survived a reset, it made the next firmware trap at pc 0x25. The number that ended it was a stopwatch, not a register. A cold upload took 3.26 s and every upload after a cut took 7.19 s, five times out of five, on the same bus. The reason was in the firmware's own documentation: initial_turbo holds the Arm cores fast for the first minute after boot, "or until cpufreq sets a frequency" - and nothing in GodspeedOS had ever set one. So a minute in, the cores dropped to their minimum and stayed there. Every load inside that minute came up cold; every load after it was slow, and trapped. force_turbo=1 proved it on the card: five power cycles, every one cold. Six resets had been tested against a host running at a fraction of its speed, and the chip was blamed for it.

Why a slow upload makes the firmware trap is still not known, and is written down as not known.

Mechanism here, policy there

force_turbo holds the cores fast forever to make a three-second load work. The operator asked whether the kernel had to own the clock at all, and whether a fast clock could be left stuck on if the service asking for it died. Both questions shaped the answer. The kernel learned one thing, CpuClock: set the clock to the firmware's own minimum or maximum, and read back what it is. It cannot name a rate and never decides when. A new service, power, owns that: it hands out LEASES of up to 30 seconds, holds the clock at its maximum while any is open and at its minimum when none is, and a lease nobody returns expires on its own. The driver takes one for each load. On the card: 600 MHz between loads, 1500 MHz during them, five loads, every one cold. A grant that can outlive its holder needs a deadline, and the deadline belongs with the policy, not the mechanism.

The clock left the network stack

net-stack used to fetch the time itself, inside its serve loop, so a ping could queue behind a time server. Now net-stack knows nothing about clocks. It offers one general operation, "send this datagram and answer me when the reply comes", and serves everyone else while it waits. time builds its own query with a random nonce and checks it comes back. Testing that in the emulator found two bugs that the real board could never show. The emulated Pi 4 has no random-number block, so the first time service that asked for a nonce took the KERNEL down from an unprivileged call; the kernel now probes the block at boot like any other device. And a deliberately unanswerable time server showed net-stack's "no reply" arriving eight seconds late, because its deadlines were checked only when some unrelated message woke it. On real hardware the server always answers, and neither would ever have been seen.

An answer is not a reply to the question you just asked

The kernel's Call takes the oldest reply FROM the peer, not the reply to the request just sent. So an answer the shell had stopped waiting for was read as the next command's: a stale OK made powercycle kill the driver mid-cycle and leave the chip unpowered, and late radio downs were counted as chips that "came up warm" in 150 ms. The first fix drained the queue; the second COUNTED what the driver still owed and skipped that many. QEMU then showed why the count could never be right: it was counted by watching the reply mailbox, every peer's replies land there, and nothing on a message says who sent it. A stray two-byte answer from another service cancelled an owed radio answer, and the next wifi status printed the previous one's. Now every request carries a tag the driver hands back, and every wait takes only the reply with its own tag. A fact carried in the reply beats a count kept beside it.

The same hunt found the SDK destroying other services' capabilities. After a request is sent, the kernel moves the reply capability to the peer and empties the sender's slot, and the next capability the sender receives lands in that slot. On a timeout the SDK "reclaimed" the slot by number, and so deleted whatever had arrived in it - another client's way to be answered. It was on every service's request path, on every port. The boot that morning logged reply cap is dead twice; the boot after the fix, through a 50-round chaos run, logged it none. A slot number is not a name for what is in it.

Chaos, again

chaos max-carnage, 100 rounds and 701 kills with the radio carrying the link, every service restarted between 45 and 56 times, and at the end the radio rejoined, time set the clock from the network, and 19 pings out of 19 came back. Fifty more rounds two boots later, with the same ending. The chip is a second computer that chaos cannot touch; the driver is Godspeed's, and it recovers the way everything else does.

And it pipes

The specification had said wifi list | count worked; the shell refused it, because wifi had never been added to its list of things that can start a pipe. Now the reports pipe and the actions refuse with a sentence - join asks for a passphrase, and in a pipe its prompt would vanish. Then wifi list became RECORDS in a pipe, so wifi list | sort reverse dbm puts the strongest network first. That needed a number the record model did not have: signal is measured in dBm, always below zero, and every number in a table had been unsigned. Writing the magnitude would be a different fact, and text sorts -9 after -80. So the record model gained a signed number, pulled into existence by the one column that needed it.

2026-10-15 to 2026-10-19 - The day the machine learned to trust what it fetched

Until this week a Godspeed machine ran exactly the software it was built with. Five days later it could reach the internet over TLS, fetch a package someone else had published, and prove who signed it and that no byte had changed. It could then start it, upgrade it while it ran, and put the old version back if the new one would not start, on all five boards. It took three repositories, not one: the OS; godspeed-cli on the developer's workstation; and godspeed-registry, a Cloudflare Worker over R2. Released as v0.29.0, "Signed, Sealed, Delivered".

No library fit, so the protocol was written and the arithmetic was not

The registry had to be reached over HTTPS; software fetched over a channel anyone can alter is software anyone can replace. No TLS library fit this tree as it stood. The one that keeps to the no-heap rule is Apache-2.0 only, which GPL-2.0-only cannot include, and the rest need a heap or are C. So sdk/tls is a TLS 1.3 client written from RFC 8446: the key schedule, the record layer, the handshake, and the verifier that says why a chain was refused. The parts that are easy to get subtly wrong were taken, on purpose, from libraries built for them: RustCrypto's hashes and AES-GCM, dalek's X25519, p256 and p384, and rustls-webpki for the chain. It is section 26.14 drawn at a new line: borrow what is a fact about mathematics; write what is a decision about this system.

It is deliberately narrow: a client, TLS 1.3, one cipher suite, one key exchange, ECDSA certificates, and every buffer the caller's. RFC 8448 prints a whole handshake byte for byte, and the tests reproduce every value in it. The narrowness was paid for once, honestly: a server with only an RSA certificate cannot be checked, and godspeed says so in a sentence rather than as alert 40.

One contract, checked in three places

The CLI that publishes, the Worker that accepts, and the machine that installs each verify a package with the same crate, godspeed-package. The Worker checks a manifest when it is reserved, each file as it is uploaded, and all of them again at finalisation; the machine then checks it once more, from scratch. The trust chain is the same everywhere:

  • a root pinned at first contact;
  • a timestamp and a snapshot that make a stale registry look stale;
  • namespaces that say which keys may publish under which name;
  • a manifest signed by one of those keys.

The Worker signs only the two documents that must change without a person: the snapshot after each publish, and the timestamp every six hours. Which keys may sign for whom is signed offline, and the Worker cannot change it. A compromised server can stop answering, but it cannot lie about who published what.

Fetched is not installed, and the supervisor believes nothing it checked yesterday

A fetch keeps a package and grants it nothing. An install is the supervisor reading the package back from the disk, checking the signature, the key's standing, the contract and the binary against the manifest, and handing the kernel the very bytes it hashed. It does that at install, at every boot and at every restart, with nothing cached between. A check whose result is remembered is a check of the past.

An installed package gets no privilege. It gets the peers its signed contract asks for, and only the ones the operator approved. It runs under a publisher/name no built-in service can hold, so it can never answer for one when a client reacquires a name.

Kinds are decided by who governs the life

Four kinds of program arrived: service, utility, app and driver. The question that settled them was not how long each runs. It was who decides when it ends:

  • the system restarts a service;
  • a utility ends itself with a status;
  • an app lasts as long as the operator keeps it open;
  • a driver lives with its device.

A word processor is an app even if it runs all day.

The utility needed the kernel to learn one thing, Exit, and it takes no capability. The rule that every syscall checks a capability exists so that nothing reaches authority without one, and the only task Exit can end is the caller. A rule is read by what it protects.

Erlang's upgrade, without changing running code

The operator asked for Erlang's upgrade in place. What the constitution had rejected, new code loaded into a running program, stays rejected. What was built is the half of Erlang anyone can observe: the new version starts beside the old one under the same name, the old one hands its state over in one message and ends, and the name moves. The bytes that run are still the bytes the supervisor checked.

The offer carries a key from the hardware random source, and a board without one does no handover at all rather than one keyed by the clock. A version that cannot take over is stopped and started instead: its checks run before the stop, and a failed start puts the old one back.

The download was paced by a clock, and the bugs were habits

A 10 MB file came in at 113 KiB/s in QEMU, and the number that explained it was 100. net-stack looked at the wire once per 100 ms tick, so a download moved one window per tick however fast the link was. Four changes took it to 872 KiB/s, and the T630's cable to 629 KiB/s:

  • burst polling;
  • a 256 KiB window;
  • out-of-order data kept in the ring;
  • delayed ACKs.

Then the boards found what QEMU could not:

  • The RTL8168 driver emptied its receive ring before every frame it sent. It had done so since July, to recover a ring that filled while nobody read it, and that was harmless while each request had one reply. With a sender running ahead, every ACK threw a ring of data away. The e1000 driver had dropped the same habit long before, and QEMU emulates only the e1000. The fix kept the recovery, but moved it to where the chip reports the stall.
  • With the reset gone, the Wyse took exactly 24 frames and fell silent without saying it had stopped. That is one ring, the same "one ring's worth" the T630 showed in July, hidden ever since by the reset. A receiver that gives nothing for a second is now restarted whether it says so or not. Every restart is logged with the bits the chip set, so the next board says which case it is in.
  • The Pi 4's radio lost a large window's bursts. It hands them over as superframes bigger than its driver's buffer, and it lost them until the radio died. A connection over a radio keeps the small window until the driver can take them.
  • "Connections stay open after a program ends" turned out not to be the program. The client had closed; the server simply had not sent its FIN, and fin-wait-2 waited for it with no limit, with only two slots in the table. An orphan is now let go after two seconds, as Linux does.

None of these could be seen in the emulator, and each was found by a card read line by line.

What is not done, said plainly

Downloads are not yet at the speed of the link (backlog/93):

  • the RTL8168's ring still overflows;
  • the Pi 4's radio still runs on the small window;
  • the VisionFive's radio sends slowly;
  • every connect waits on an ARP it does not need.

And on the last day the operator asked whether this was v1. The answer was not yet, and measured:

  • drivers still reach past the standard library;
  • the public surface has no gate that fails on a change;
  • no compatibility policy is written;
  • the surface changed that very day.

v1 is a promise to contributors, and a promise that is not gated is a preference (backlog/71).

Writing a program for GodspeedOS

This is the page for someone who wants to use GodspeedOS rather than understand it. You should not need MISCIS, the capability table, or the IPC wire format to read a file.

The API reference

The standard library API reference - godspeed, imported as gs. Every module carries its own documentation, including what each call blocks on, what authority it needs, and which failures are safe to retry.

The SDK API reference - godspeed_sdk, the layer underneath: the syscalls themselves, and the audited unsafe (CLAUDE.md 18.1). You should not need it. Services and drivers alike are written on gs, drivers through gs::driver (below), and scripts/one_way_check.py holds every program in this repository to zero raw SDK calls that gs covers. A driver package from outside this repository may call it too, but outside the compatibility promise: the SDK can change between releases where gs does not (docs/driver-library.md).

Four kinds of program

The developer CLI makes each with one command, godspeed new <kind> <name>, written on gs from its first line, with a contract and a README that says how to build and run it:

KindWhat it isWritten with
serviceRuns until it is stopped; the supervisor keeps it running and restarts it if it diesgs::ipc, gs::call, gs::fs, gs::net, ...
utilityOne job for one command line: handed its arguments, writes its output, ends with a status the shell reports; its output pipes like any built-in'sgs::program
appA utility that holds the screen and the keyboard while it runs, and gives them back when it endsgs::app
driverDrives one device, named in its contract by PCI class code and register BAR, or a USB device by vendor and productgs::driver

A fifth, library, is reusable code for the others and runs nowhere on its own. godspeed package turns a project into a signed package and godspeed install puts it on a machine; osdev test kinds installs one of each of the four, made from the templates untouched, and sees each do its job.

Start here

A whole program - every line of it, because the parts around your code are not guessable:

#![allow(unused)]
#![no_std]                  // no operating system underneath this one
#![no_main]                 // the entry point is `service_main`, not `main`
#![deny(unsafe_code)]       // required of every program; see the note below

fn main() {
use godspeed::{self as gs, Error, ServiceContext};

// `#[no_mangle]` is itself covered by the `unsafe_code` lint (an exported symbol can collide), and
// the entry symbol must be exported because the linker looks for `service_main` by name. So this
// ONE `#[allow]` is expected on this ONE item. Anywhere else it is rejected.
#[allow(unsafe_code)]
#[no_mangle]
pub extern "C" fn service_main(ctx: ServiceContext) -> ! {
    let mut fs = gs::fs::Fs::new(&ctx);

    // There is no `read_to_string`. GodspeedOS has no heap (CLAUDE.md 26.6.1), so the CALLER owns
    // the buffer and the bound is visible in the source.
    let mut buf = [0u8; 4096];
    match fs.read_into("/data/message.txt", &mut buf) {
        Ok(n) => gs::io::println(&ctx, core::str::from_utf8(&buf[..n]).unwrap_or("<not utf-8>")),
        Err(e) => gs::io::report(&ctx, "read", e),
    }

    // A program does not return. This one is a SERVICE, which waits to be stopped once its work is
    // done; a utility ends instead, with `gs::program::exit` or `Output::finish` (CLAUDE.md 14.5).
    loop { gs::task::yield_now(&ctx); }
}
}

And a contract, or it reaches nothing

Authority is granted, never assumed. A program granted no fs peer gets Error::Unreachable from its first call - not a crash, and not a silent nothing. Put this beside your Cargo.toml, in contracts/<name>.toml:

name    = "hello"
version = "0.1.0"

[resources.memory]
request = "8MiB"
limit   = "16MiB"

[capabilities]
ipc_send     = ["fs"]     # talk to the filesystem; the spawn row must grant it too (below)
ipc_receive  = ["hello"]  # your own endpoint, named after you
log_write    = true       # the log (`ctx.log`) AND the screen (`io::println`, `io::report`)

Printing is log_write. Everything in gs::io, io::report included, makes the kernel's ConsoleWrite call, and LOG_WRITE is the capability the kernel checks before it. Every task holds it, so output appears regardless; declaring it is the contract saying truthfully what the program does, and scripts/contract_check.py requires it of every program that prints.

Never ask for console_push to print. It is the authority to push bytes into the keyboard ring, which the shell reads as typed commands (SEC-2). A keyboard driver needs it; your program does not, and the same checker refuses a console_push declaration that nothing in the code uses.

This page said the opposite until 2026-10-09 - that printing needs console_push and that a program without it is silent. That was a conclusion drawn from documentation and never run; the kernel's code and the project's own test log both refute it (docs/stranger-test.md, "Run 4 was wrong about the screen").

Ask for what you use and nothing more: the contract is the reviewable statement of what your program may do (CLAUDE.md 26.9).

The contract declares; it does not grant. The kernel cannot read TOML. What a running program actually holds comes from the SPAWN REQUEST the supervisor sends, built from the program's row in the supervisor's spawn table (IMAGES in services/supervisor/src/main.rs) - for this program, ("stdlib-hello", .., &["fs"], ..). A peer in the contract with no matching row is a program that looks authorised on paper and is not (CLAUDE.md 13.6). For services, scripts/contract_check.py reconciles the two.

examples/stdlib-hello is the complete, buildable version of this, a step further: it writes a file larger than one IPC message and reads it back, and osdev test examples checks every byte.

The one thing to get right

Every call returns Result<_, gs::Error>, and the error that matters most is the one describing an operation whose outcome nobody knows:

#![allow(unused)]
fn main() {
match fs.write("/data/log.txt", b"hello") {
    Ok(()) => {}
    Err(e) if e.retry_is_safe() => { /* nothing happened: try again */ }
    Err(e) => { /* it MAY have happened: do NOT blindly retry */ }
}
}

Error::OutcomeUnknown means the request left and no answer came back before the deadline. Error::PeerDied means the request arrived and the service died before answering. Either way the write may have committed. Retrying it is not a retry, it is a second write, and for anything that is not idempotent that is a different bug from the one you were recovering from.

retry_is_safe() returns false for both, so that nobody has to derive the rule themselves. This is the failure the Stranger Test watches hardest, because it is invisible in a passing build.

If your program SERVES other tasks

Most programs do not, and can skip this. A service does: it answers requests on its own endpoint, and that is the same endpoint replies from fs and net-stack arrive on. So there is a question with a wrong answer that looks like the right one - what happens to a client request that lands while you are waiting for a reply?

The library's answer is that it does not touch it. A call waits on the reply capability it sent, so the kernel hands back that reply and leaves everything else queued for your own loop. You do not have to drain anything, and nothing you did not ask for is consumed.

#![allow(unused)]
fn main() {
// A service loop. `fs.read_into` may block for seconds; a client that speaks during it is still
// waiting on your endpoint afterwards, not lost.
loop {
    let _req = gs::ipc::recv(&ctx);
    let client = gs::ipc::take_sent_cap(&ctx);   // the reply capability the client sent with it
    let mut buf = [0u8; gs::ipc::MAX_BYTES];
    let n = fs.read_into("/data/answer.txt", &mut buf).unwrap_or(0);
    if let Some(c) = client {
        // Answers without blocking, AND gives the one-shot capability's slot back. A plain send
        // would keep it, and leak one slot per request until the table is full.
        let _ = gs::ipc::reply(&ctx, c, &gs::ipc::Message::from_bytes(&buf[..n]));
    }
}
}

Two exceptions, both of which say so where you reach for them:

  • A capability you hold - an open file (gs::file::File), a socket or a connection (gs::net) - is invoked rather than sent by name, and the kernel routes its reply the same way. Anything else that arrives during one of those is held for you: drain take_held() in a loop after each operation and feed what comes back into your own loop. They are real client requests, and only you can answer them.
  • A handle built with Fs::with_notice polls for an operator pressing q, which means it takes whatever arrives. That is right for a shell, which serves nobody, and wrong for a service.

If your program drives a DEVICE

A driver holds authority nothing else does - a register window, perhaps a DMA arena and an interrupt - and gs::driver is how it uses each. It holds device-NEUTRAL mechanisms only: what a register means stays in your driver, and there is no gs::driver::usb or ::wifi, ever.

ModuleWhat it is for
registersYour device's register window, every access bounds-checked: gs::driver::registers(&ctx)
arena, LayoutYour DMA arena, laid out in named parts, each aligned on its PHYSICAL address and refused by name if it does not fit
waitWaiting for the device, bounded by TIME (Budget), not by a count of tries: wait::until, Deadline, Since
delayHolding still for a minimum the device needs and nothing can report the end of: hold, hold_parked
irqWaiting for your interrupt on the endpoint your clients also send to, without losing a request: Irq::wait answers Interrupt, Request or Timeout, and Irq::take the same without blocking
inputA keyboard driver's keystrokes, pushed to the console: push, and ready when setup is done
powerCutting and restoring your device's power where the machine can: power::set
deviceWhat the kernel knows about your device that you cannot read yourself: the board's MAC, the NIC's PCI identity, a controller's base clock, PCI configuration reads
busA device with no registers of its own, reached through its bus's host: a USB device's identity, control transfers and bulk transfers (UsbDevice). A bus, not a class
#![allow(unused)]
fn main() {
let Some(regs) = gs::driver::registers(&ctx) else {
    ctx.log("mydev: no device was granted - nothing to drive");
    gs::ipc::park(&ctx)
};
regs.write32(CMD, START);
if gs::driver::wait::until(&ctx, gs::driver::wait::Budget::ms(10), || regs.read32(STATUS) & BUSY == 0).is_err() {
    ctx.log("mydev: the device did not finish within 10 ms");   // the CALLER says which wait
}
}

A driver installed as a package is given what its contract names and the operator approves: its register window, its interrupt, and a DMA arena (asked about by a question of its own, because where no IOMMU confines the device a driver with an arena can reach all of memory) - or, for a USB device, its host through bus. A device a GodspeedOS driver drives is taken over while the package is installed. All but the registers arrived after v0.30.0 (docs/developer-services-design.md 13.1). The drivers in this repository use all of the above. docs/driver-library.md is the design and the record of each module's arrival.

gs::program::mode is the mode a program was started in, for one that does more than one thing.

What the library will not do

  • Allocate on your behalf. No heap, by design. You pass the buffer.
  • Hide a failure. There is no silent retry and no fallback path. If something did not happen, the call says so, and if it might have happened, it says that instead.
  • Grant authority. A handle is not a permission. gs::fs::Fs::new carries nothing of its own: every call rides the capability your contract was already granted, and a service that never asked for the filesystem gets Error::Unreachable from the first call. The library makes granted authority convenient to use; it never makes ungranted authority available.

The design report

How the library was designed, and what building it changed - including the mistakes found by migrating real services onto it, the friction reported rather than worked around, and what is deliberately not built yet.

The brief it was built from - the original requirement, annotated with what was met and what was not.

A native standard library for GodspeedOS - Phase 1 design report

Status: Phase 1 report, REVIEWED AND APPROVED 2026-09-23. The first slice is implemented on feat/stdlib - stdlib/rust with error, call, fs, io; examples/stdlib-hello; services/recorder migrated. Option A was taken (services only, no kernel change); the terminating task of §1 was left open and unimplemented, which is the point of recording it here. (2026-10-25: since closed by option B - Exit, syscall 57, added 2026-10-17 (CLAUDE.md 14.5) - and a program that ends itself is written on gs::program.)

What follows is the report as written before any code, kept as it was argued. §7 onwards records what building it changed, section by dated section; §25 (2026-10-09) is the latest, every service moved onto gs.


1. The headline: there is nothing for a standard library to be a library FOR, yet

The brief asks for this to become possible:

use godspeed::fs;

fn main() -> Result<(), godspeed::Error> {
    let contents = fs::read_to_string("/data/hello.txt")?;
    println!("{contents}");
    Ok(())
}

Three things in those six lines do not exist in GodspeedOS, and only one of them is a library problem.

fn main() does not exist. Every runnable thing in this repository is a SERVICE, and its entry point is:

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn service_main(ctx: ServiceContext) -> !
}

All 14 examples return !. They cannot return. There is no exit syscall: of the 52 syscalls in kernel/src/syscall/dispatch.rs the only one that ends a task is Kill (8), which kills a task by name and is gated behind service_control - a capability held by the supervisor, and one no application should hold, since holding it means being able to kill anything.

There are no applications. utilities/ holds 56 utility specifications. Not one of them is a program. read, dir, copy, seal, churn and the rest are FUNCTIONS INSIDE services/shell/src/main.rs - cmd_read, cmd_dir, cmd_copy - in a single crate of roughly 17,000 lines. The shell is not a program launcher; it is the program, and the utilities are its subroutines.

So "a programmer writes a small Godspeed utility" currently means "a programmer adds a function to the shell". A standard library cannot change that on its own, and a standard library that pretends otherwise would be the first thing in the repository to lie about the architecture.

This is the STOP condition the brief names, and it is being reported rather than worked around:

If an apparently convenient standard-library API requires a new kernel responsibility, STOP. Document the missing mechanism and explain why it appears necessary before making any kernel change.

What is actually missing

A terminating task: something that is spawned, runs, produces a result, and ends, releasing its resources and reporting an outcome to whoever started it.

Is that a new MISCIS responsibility? Careful reading says no, and that is the important part:

  • The kernel already reclaims a task's frames, kstack and capability table when it dies, and already bumps its generation and wakes blocked peers (§14.2). Voluntary exit needs none of that built.
  • The kernel already notifies the supervisor of a death, and the supervisor already decides what to do about it. Today the answer is always "respawn".
  • What is missing is a task's ability to say "I am finished, and here is my status" rather than dying by fault or by someone else's Kill.

That is arguably not a seventh responsibility but a completion of the first six: it is the Scheduling and Capability machinery that already exists, reached through one more syscall, with the policy (what a finished task's status means, whether it is restarted) staying in the supervisor where §26.10 puts it.

But it is still a kernel change, so it is not mine to make. Three options, and a recommendation:

OptionWhat it costsWhat it buys
A. Ship the stdlib for SERVICES onlyNothing in the kernel. The fn main() experience does not arrive.Every finding in §3 below is still fixed. Services are what exist.
B. Add an Exit(status) syscallOne syscall, one supervisor arm. Needs a CLAUDE.md amendment (§8.2's CallDeadline is the precedent: a new syscall is a new kernel responsibility and the surface is pinned).The fn main() model becomes real, and the shell can stop being the only place a utility can live.
C. Do it in userspaceA "runner" service spawns the program and kills it when it signals completion over IPC. No kernel change.Works today. Costs an IPC round trip and a second service holding service_control, which is a confused deputy of exactly the shape backlog/fixed/41 was rejected for.

Recommendation: A now, B as a separate reviewed change, never C. C reintroduces the security objection that killed backlog/fixed/41: a deputy holding service_control on behalf of arbitrary programs. A is honest and immediately useful. B is the real answer and deserves its own discussion rather than arriving as a side effect of a convenience library.


2. What the repository already has

Inspected: sdk/rust/ (6,944 lines across 12 modules), all 20 services, all 14 examples, the 52 syscalls, and the fs/block wire protocols.

AreaWhat existsQuality
Entry + runtimeservice_main(ctx) -> !, no std, no heap (§26.6.1: stack arrays and bounded arenas only)Solid, but ceremonial: 3 crate attributes, a #[no_mangle], and an #[allow(unsafe_code)] with a 7-line comment explaining itself
AuthorityServiceContext is the single door. The contract declares, the SPAWN REQUEST grants (§13.6)Excellent. Nothing to fix
IPCsend/try_send/recv/call/call_deadline_intoGood primitives
Request/reply8 request_with_reply* variants, 3 outcome enumsSee §3.2. The problem area
Consoleconsole_write, console_writeln, console_write_fmt, console_readClean. A stdlib io module is mostly a rename
FilesystemAn opcode/byte wire protocol over IPCSee §3.1. The other problem area
Capabilitiesreacquire_cap, reacquire_cap_detailCorrect, and clients must use them (§14.3)
Recordsrecord.rs (677 lines), a typed row/table model the shell pipes throughAlready a small stdlib. Worth studying as the house style

3. The repeated plumbing, measured

3.1 The filesystem wire protocol is hand-copied between crates

FS_OK is declared independently in four crates: fs, shell, copier, recorder. OP_STAT_FILE = 12 in two. The shell re-declares 21 of the filesystem's 27 opcodes.

Reading a file means knowing the byte layout. From cmd_read in the shell, abbreviated:

#![allow(unused)]
fn main() {
let stat = fs_request_q(ctx, OP_STAT_FILE, path, &[]);      // three outcome arms
let sp = stat.payload_bytes();
let exists = sp.first() == Some(&FS_OK) && sp.len() >= 11 && sp[1] == 1;
let is_dir = exists && sp[10] == 1;
let size = u64::from_le_bytes([sp[2], sp[3], sp[4], sp[5], sp[6], sp[7], sp[8], sp[9]]);
// then loop read_at in IO_CHUNK pieces, handling errors at each step
}

Roughly thirty lines, and every one of them is an opportunity to index the wrong byte. This branch already shipped a bug of exactly that shape: a guessed opcode (FS_OP_WRITE_FILE = 1, actually 10) made churn write nothing for twelve seconds and report success.

This is the clearest candidate in the repository. The wire format is a fact the filesystem owns; every other crate is currently re-deriving it from a comment.

3.2 The honest failure model exists, and the ergonomic path throws it away

DeadlineOutcome already distinguishes the states the brief asks about:

#![allow(unused)]
fn main() {
pub enum DeadlineOutcome { Reply(Message), SendFailed, QueueFull, Timeout }
}

That is the right model and the brief's instruction not to invent a parallel one is correct. SendFailed means the request never left, so retrying is safe. Timeout means it may have committed, so retrying is how one copy becomes two. The distinction is load-bearing.

The problem is that it is one of eight variants and three enums (ReqOutcome, DeadlineOutcome, DeadlineOutcomeInto), and the shortest-named ones return Option<Message>, which collapses SendFailed and Timeout into a single None. services/copier says so at the call site:

"Retry only on Err (the send failed), NEVER on Ok(None) (the deadline passed)... a slow fs is alive, and re-sending a write to it is how one copy becomes two. request_with_reply_deadline cannot express the difference."

Five services reached for request_with_reply_call_err to get the distinction back: block-driver, control, copier, nic-driver, recorder.

So the stdlib's job here is not to design a failure model. It is to make the honest one the DEFAULT and the SHORTEST path, and to stop offering the lossy one for new code.

3.3 Every client re-implements reacquire-and-retry

§14.3 puts the obligation on the client: a cap to a restarted service is stale forever. Every service that talks to fs therefore carries its own "send, and if the send failed reacquire by name and try once more" loop. It is about fifteen lines, it is subtle (the retry must NOT cover the timeout case), and it is written out separately in each crate.


4. Proposed minimum surface

Every module below answers the brief's seven-question architectural test. Nothing is proposed because it would round out a list.

ModuleWrapsRemovesNotes
gs::fsThe filesystem wire protocol§3.1, the whole of itThe big win. Typed ops, one owner for the byte layout
gs::ioconsole_write*Little plumbing, but it is where print livesThin by design. Mostly naming
gs::callrequest_with_reply* + reacquire§3.2 and §3.3ONE honest primitive, not eight
gs::ErrorDeadlineOutcome + fs status bytesPer-crate error mappingDerived from the existing model, not a new one

Explicitly NOT proposed yet, because the brief says to leave out what is unclear:

  • net - the brief asks for a network-oriented migration, and I would want to inspect net-stack's socket surface properly before claiming a shape. Deferred to a follow-up, not dismissed.
  • task, time, cap - no demonstrated repeated plumbing found. time is one syscall, cap is already ServiceContext, and task needs the §1 answer first.

The API shape Godspeed actually wants

fs::read_to_string returning a String cannot exist: there is no heap (§26.6.1), deliberately. Distorting Godspeed to resemble Rust std here is exactly what the brief forbids. The honest shape is the one the repository already uses everywhere:

#![allow(unused)]
fn main() {
// The caller owns the buffer. Bounded by construction, no allocator, no hidden growth.
let mut buf = [0u8; 4096];
let n = gs::fs::read_into("/data/hello.txt", &mut buf)?;
gs::io::println(str::from_utf8(&buf[..n])?);
}

That is six lines against thirty, removes every raw byte index, and does not pretend the machine has a heap.


5. Risks

  1. The stdlib must not become a second SDK. ServiceContext is 4,059 lines and already the single door. If gs:: wraps all of it, there are two doors and Commandment IV's "what can this service reach" question gets two answers. Proposal: the stdlib wraps the WIRE PROTOCOLS and the REQUEST PATTERN, and re-exports ServiceContext untouched for everything else.

  2. Authority must stay visible. gs::fs::read_into needs the caller's existing fs send capability. It must take the ServiceContext (or a handle derived from it) as an argument rather than reaching for a global, or the "no ambient authority" invariant becomes a comment. This is the single most important line in the whole design.

  3. Portability. Nothing proposed needs #[cfg(target_arch)]. The wire protocols are little-endian by contract and already proven to cross architectures (cross_isa.py 12/0, one volume carried x86-64 to riscv64 and back). The stdlib must not become the 45th arch-conditional site.

  4. Where does it live? sdk/rust is the services' SDK. A separate stdlib/rust crate that depends on it keeps the layering honest and lets the SDK stay the low-level ABI. This needs a decision and is not mine to take alone.


6. What I recommend, and what I need from review

Recommended first slice, assuming Option A (services only, no kernel change):

  1. gs::fs over the wire protocol, with the byte layout owned once.
  2. gs::call - one honest request primitive returning the existing DeadlineOutcome shape, with reacquire-and-retry built in and correct (retry on SendFailed, never on Timeout).
  3. gs::io - a thin, documented console surface.
  4. Migrate services/recorder (filesystem) and one console path, and report what still needs raw plumbing.

Three questions for you:

  1. Option A, B, or C for §1? This decides whether the deliverable is "a standard library for services" or "the application model plus a standard library". I recommend A now and B as its own reviewed change.
  2. Crate boundary: new stdlib/rust depending on sdk/rust, or a gs module inside the SDK?
  3. Is net in the first slice? The brief asks for a network migration; I would rather inspect net-stack first and propose it separately than guess at a socket API now.

Nothing will be implemented until these are answered.


7. What building it changed

The design above is kept as argued. This records where reality differed.

The migration drove the API, which is what it was for. services/recorder needed three operations the proposed surface did not have - create_sized (allocate an extent), write_at (positional write) and rename. The tempting shortcut was a raw_op(opcode, ..) escape hatch, which would have leaked the opcodes straight back out and defeated the module's whole purpose. They were added as typed operations instead.

Measured result on recorder: 680 lines to 627, and raw fs/IPC plumbing from 22 sites to 2. The two that remain are a protocol tag constant of its own and a comment. Its filesystem call helper went from 55 lines to 15 - but the line count is the least of it: it used to return bool, so a write that hit the deadline (and may therefore have landed) was indistinguishable from one that never left. Eleven lines of comment explained that difference and the signature discarded it. It returns Result now.

A Commandment check broke, and it was right to. Moving reacquisition into gs::call made it invisible to IX-peer-reacquire, which looks for a reacquisition API in the SERVICE's own source. It reported recorder as having no recovery path.

That is the real architectural friction in this work, and it generalises: a standard library that absorbs a Commandment-enforced behaviour makes that behaviour unverifiable where the checker looks for it. Baselining an exemption would have been the wrong answer - a weakened check is worse than no check. The fix has two halves:

  1. reacquire_api now credits gs::call, so a service routing through it gets the path it has.
  2. IX-stdlib-delegates is a new check asserting that stdlib/rust/src/call.rs really does reacquire, and does it on SendFailed rather than on a deadline. Without it, half 1 is a hole: a service would pass IX for calling a library that had quietly stopped doing the work, and the property would decay from "this service can recover" into "this service calls something that used to".

Commandments went 20 checks to 21, red-team probes 73 to 75, both new probes firing.

Host tests cover the error model only, and deliberately. godspeed_sdk provides the panic_handler and so does std, so any dependent crate's host test build hits duplicate lang item. The split follows the pattern kernel/src/clock.rs documents: the pure logic - which is most of the semantics worth testing - has no SDK import and is unit-tested on the host; anything that talks to a service is for the target, where it can meet a filesystem that can really be restarted. A mock would only prove this library agrees with a mock.

Still open, unchanged: the terminating task (§1), net (needs net-stack inspected properly), and target-side suites for fs/call against a real service restart.


8. net: inspected, and the answer is NOT YET

§4 deferred net rather than guessing at a socket API. Inspected properly now, and the deferral holds - but the inspection found something better than a socket module.

Why not now

fs (shipped)net-stack
Crates duplicating its constants4 (fs, shell, copier, recorder)0
Real clients32 - shell (17 references) and time (ONE fire-and-forget try_send)
Protocol commits, v0.16.0 to now-34, one of them on this branch

Two clients is not repeated application plumbing, it is two call sites, and one of them sends a single byte. Against that, the protocol moved 34 times in three releases. The brief is explicit on both counts: "Every abstraction must correspond to a demonstrated application need", and "be conservative about declaring APIs stable... small enough that we understand the semantics of what we commit to."

Wrapping it now would freeze a shape that is still moving, for one and a half consumers.

What the inspection found instead

A socket is a delegated resource capability, the same §7.10 mechanism as file-as-capability - resource_mint to issue, resource_invoke to use, last_recv_badge to authenticate, with a rights check on every operation (LOP_ACCEPT and LOP_CLOSE both demand RIGHT_WRITE, because both change what the machine does on the wire).

That mechanism has two independent issuers already - fs for files and net-stack for sockets - plus the shell as a consumer of both, and two worked examples. So the abstraction with demonstrated need is not net. It is:

gs::cap - hold, invoke and narrow a delegated resource capability, without hand-rolling the badge and rights dance.

That is the honest next slice by the brief's own test: it removes plumbing that is genuinely repeated across two unrelated services, it wraps one named Godspeed mechanism rather than a protocol still in flux, and it cannot widen authority because rights only ever narrow on transfer (§7.3).

It is NOT being built now either, for the same reason net is not: the shell is the only real consumer today, and a second would settle the shape. Recorded, not started.

What would change the answer for net

A second substantive client. If time ever needs more than one byte, or an application wants a socket, the repeated plumbing appears and gs::net earns its place - most likely on top of gs::cap rather than beside it.


9. net built, and the dogfood earned its keep

Built at the operator's direction, overruling §8's "not yet". The surface is status, resolve, ping, arp, tcp and renew, plus a pure addr::Ipv4.

Two deliberate boundaries, stated in the module rather than left to be discovered: UDP sockets and TCP listeners are NOT included, because those are delegated resource capabilities and need machinery shared with file capabilities - half-building that twice is how it goes wrong. And ping returns Ok(false) for silence rather than an error, because a diagnostic tool that conflates "no answer" with "the request failed" lies about which half is broken.

The bug the migration found, which is the point of migrating

The first draft sent [opcode, ..]. The real framing is [tag, patience_secs, opcode, ..]:

#![allow(unused)]
fn main() {
// services/net-stack
None => match (pl_raw.first(), pl_raw.len()) {
    (Some(t), n) if n >= 2 => (&pl_raw[2..], Reply { tag: Some(*t) }),
    _                      => (pl_raw,      Reply { tag: None }),
}
}

Any request of two bytes or more has its first two bytes eaten. So status() - one byte, below the strip threshold - worked by accident, while resolve("example.com") had its opcode taken as a tag and dispatched on 'x'. Every call but one was wrong, and all of them compiled.

It was found by reading services/shell's own client while looking for something to migrate, which is exactly what the brief predicts: "the migration is part of the test." Nothing short of a target run would otherwise have caught it, because the failure mode is a machine quietly talking to the wrong thing.

The module header had already warned that net-stack's protocol moved 34 times in three releases and that the cost of being wrong here is not a compile error. That warning turned out to be about the draft immediately below it.

What is still owed

  • The network migration itself. gs::net is written and correct against the protocol, but no utility has been moved onto it yet: the shell's net client lives behind ShellCtx, whose ServiceContext is private, so the migration needs a small accessor rather than a rewrite. That is the next commit, not a design problem.
  • Target-side tests, for the same reason as §7: the interesting failures are a service that restarts and a deadline that passes, and neither is reachable on the host.

10. The network dogfood: cmd_tcp, and the result that went the wrong way

The migration §9 said was next is done. The shell's tcp command no longer knows the wire format.

ServiceContext was not private after all. §9 recorded that this needed "a small accessor rather than a rewrite". Wrong: ShellCtx has a Deref impl, so &*ctx yields &ServiceContext and the migration needed no shell change at all beyond the call site. Recorded because the previous entry sent the next reader after work that did not exist.

The line count went UP, and that is the honest result. recorder shrank 680 lines to 627. This did the opposite:

beforeafter
lines, comments included6175
lines, comments excluded5057
sites naming the wire format (payload[..], opcode 21, >> 8, ReqOutcome)100

So the plumbing did disappear - all ten sites of it - and the function still grew seven code lines. The growth is entirely in the outcome arms: ReqOutcome has three variants and Error has thirteen, so what was three arms is now four plus a catch-all. That is not overhead, it is the honest cost of a richer failure model: the old code could not distinguish "connected to nothing" from "net-stack refused" because the SDK had no way to say it. A library that makes the error model finer will make some call sites longer, and reporting only the case that shrank would be choosing the flattering instrument.

Error::Cancelled exists because of this migration, and it caught me making the exact mistake the library exists to prevent. The shell is explicit that the user's own q is not a fault. My first cut of request_within_notice folded ReqOutcome::Aborted into OutcomeUnknown - collapsing "you pressed a key" into "we do not know what happened to your request", which would have taught the operator that a deliberate keypress produces an error line. That is the same lossy merge §7 records the SDK making with DeadlineOutcome, committed by the library built to stop it, one branch later. It survived a compile and every gate; what caught it was reading the three arms it had to preserve.

Net::with_notice exists for the same reason. ns_request always passed an on_linger callback printing [q] quit, so a migration that ignored it would have silently deleted an affordance. The callback takes nothing and returns nothing - it means only "you have been waiting a while" - so the console stays entirely on the shell's side and the library learns nothing about terminals.

The wire format was wrong in the first draft, and only the dogfood found it. gs::net sent [opcode, ..]. net-stack strips TWO bytes from any request of two bytes or more and echoes the tag at reply byte 0, so a request must be [tag, patience, opcode, ..]. Everything except status() dispatched on garbage - and status() worked by accident, because a one-byte request falls below the strip threshold and reaches a default arm that answers status anyway. A single passing call concealed a module-wide framing error. Fixed at the header, with the echoed tag now checked on return (a mismatched tag is Error::Malformed, never read as this call's answer).

No net-stack change was needed, or made. git diff main...HEAD -- services/net-stack/ is empty. The format was always net-stack's; the library was taught to speak it. So this migration owes no hardware re-test of the protocol - what it owes is the same target-side testing §7 and §9 already record.

Still owed, unchanged: target-side tests against a service that really restarts, and gs::cap.

11. Four file operations, and three things the shell knew that the library did not

gs::fs gains list_dir, create_dir_all, move_to and delete_all - the ordinary file operations that had callers hand-rolling them. Two corrections to what section 9 claimed about the ceiling, made while reading the service rather than its comments:

  • OP_CAPACITY and OP_FLUSH are not file operations. They are block-driver opcodes that fs uses as a CLIENT. Counting them as candidates for gs::fs was reading a constant list without reading what serves it.
  • send_res! already sends a REASON. On FS_ERR the service appends its own words - "name already exists", "cannot move root", "source not found" - and from_fs_status mapped the status byte and dropped the sentence. A loud failure was being made quieter in transit (26.7). Fs::reason keeps it in a bounded buffer the handle owns, documented as diagnostic-only: branch on the Error, print the reason.

The shell's hand-rolled version was better than mine, twice

services/shell has nine OP_LIST_DIR call sites behind one DirCursor. I wrote list_dir first and read DirCursor after, which is the wrong order, and it had two properties I had missed:

  1. A page cap. My loop followed next for as long as the service said more - an unbounded execution scope (26.6) resting entirely on the service behaving. DIR_PAGE_MAX is now the same 512 the shell uses.
  2. A cut flag. Hitting that cap must not look like finishing. The shell's own comment is the rule: "a partial answer that reads as a complete one is the thing this whole mechanism exists to remove". My Result<usize, Error> had nowhere to put it - an error would have been a lie, since the entries WERE delivered, and a bare count would have been exactly the silent truncation the pagination handling existed to prevent. Hence Listing { visited, complete }: the count cannot be read without the field that says whether it is the whole directory.

This is the dogfood working in the opposite direction from the usual story. The library did not teach the caller; the caller taught the library. Worth recording plainly, because a design report that only lists what the new code improved is not an honest instrument.

A hazard that had to be fixed before any migration was safe

gs::fs minted tags from a 0xC0..0xFF band; the shell mints from the whole 1..=255 range. Two independent counters on one channel, with overlapping ranges. A tag check only REJECTS a mismatch, so a collision does not fail loudly - it lets a stale reply be ACCEPTED as the current request's answer, which is precisely the "run it twice and the protocol loses step" desync tagging was added to remove. Rare, silent, and therefore worse than common and loud.

So migrating one shell site while the others still called next_fs_tag would have introduced a rare wrong answer. The band bought nothing (a tag need only differ from the request before it on the SAME channel, and net is a different channel), so it is gone: Fs advances exactly as the shell does, and Fs::from_tag / Fs::tag let a caller lend its counter and take it back. One counter per channel.

Architectural friction, reported rather than worked around

list_dir's closure borrows the handle mutably, so you cannot make another fs call while listing. That makes the API right for the simple case the Stranger Test asks about ("list a directory") and wrong for an interleaved one.

cmd_churn_verify - the power-cut verifier - is the interleaved case: it lists /churn (/system/scratch/churn since 2026-10-13) and reads each file as it goes. Migrating it would have forced a two-pass collect into a roughly 16 KB stack buffer the original never needed, on a shell stack already known to be tight. That is a memory discipline regression caused by the shape of my API, so it was not migrated, and the reason is recorded here instead of being engineered around. The page-at-a-time alternative that would fix it is what DirCursor already is; whether the library should offer that second shape is an open question and not a thing to add speculatively (26.2).

What was migrated, and what it proves

cmd_dir - a pure listing, decoding exactly the five fields DirEntry carries. It also needed Fs::with_notice, an inconsistency I had introduced rather than found: cmd_tcp got Net::with_notice so a lingering request keeps offering [q] quit, and every shell filesystem call uses fs_request_q, so migrating any of them without the equivalent would have silently deleted that affordance (utilities/0_conventions.md rule 9).

What deliberately stayed in the shell: a name is rendered SAFE there, because that is a property of writing to a terminal, not of reading a directory. A name holding ESC [ 2J clears the screen when listed, scrolling away the listing meant to reveal it (found by osdev test fs-fuzz). DirEntry::name is raw bytes precisely so this layer can decide - a library that pre-sanitised would have made the hostile name unprintable AND unreachable, so delete could not remove it either.

Verified in QEMU, osdev test files, 245 passed 0 failed, including the three cases that pin exactly what this change risked:

many: dir lists all 45 entries across pages
many: dir does not report a truncated listing
many: dir shows both the first and the last entry

A gate that was covering nothing

scripts/doc_symbols_check.py never scanned stdlib/rust/src. Every backticked symbol in docs/stdlib-design.md was being checked against a source set that EXCLUDED the crate the document is about, so the gate's silence read as a pass while it verified nothing. Found because a correct reference (request_within_notice) failed. Fixed; the scan now covers the crate, and one stale baseline entry could be dropped as a result.

Still owed, unchanged: target-side tests against a service that really restarts, and gs::cap. Newly recorded: backlog/fixed/45, the published book has no standard-library section, which blocks the Stranger Test from an honest first run.

12. gs::cap is NOT built, and the reason is a gap in the kernel surface

Section 10 named gs::cap - delegated resource capabilities, the mechanism behind "a file is a capability" (7.10) - as the best next candidate. Reading the path before writing it turned up a blocker, so it is recorded rather than built. backlog/46 is the full entry.

resource_invoke (syscall 31) is a SEND. It embeds a reply cap and returns on delivery; the caller then waits with a plain recv. That is exactly the shape CLAUDE.md 8.2's CallDeadline amendment condemns:

a service that SERVES clients on the endpoint it awaits replies on would therefore consume an unrelated client request, fail to match it, and drop it

That amendment fixed the NAMED-PEER path - request_with_reply_call matches the reply by its sender

  • and left the RESOURCE path on the primitive it had just condemned. There is no CallDeadline form of resource_invoke.

The one existing caller is exempt by accident, not by design. services/shell opens every file-cap invocation with while ctx.try_recv().is_some() {}, draining whatever is queued. That is safe only because the shell serves nobody on that endpoint. In a service that does, the same line discards live client requests - so the shell working is not evidence the pattern is sound.

A library cannot do either thing. It cannot drain, because it does not know whether its caller serves clients; and without draining it inherits a wait that can eat a message. gs::cap is meant for ordinary services - a facility only usable by a task with no clients is not the public interface 22.7 measures - so it is not started.

A correction to what this section first said. It concluded "a new syscall is a new kernel responsibility, so this is the operator's gate". That reasoning is wrong, and the operator corrected it: syscall count is not responsibility count. A primitive completing the semantics of a responsibility the kernel already owns is not kernel growth. The question is not "does this add a syscall" but "does an existing MISCIS mechanism have an incomplete semantic".

Applying that test properly changes the finding, and makes it larger rather than smaller.

The hole is generic, and it predates this library. Three services mint delegated resource caps - fs (files), net-stack (TCP connection and listener caps) and shell - and 7.10 is written in terms of an opaque ResourceId whose meaning only the owner knows. examples/resource-server and examples/holder are a worked pair naming no filesystem at all, and holder waits like this:

#![allow(unused)]
fn main() {
Ok(())  => Ok(ctx.recv()),   // routed: block for the owner's reply on our endpoint
}

An unbounded recv. If the owner dies after receiving the invocation and before replying, holder hangs forever - Commandment VIII broken in the example that teaches the mechanism, and 8.6 has a row promising exactly that caller is woken on the Call path.

So there are two holes, not one: no reply correlation (hole 1, above) and no ReplyDead reaching resource invocation (hole 2), because a SEND never tells the kernel a reply is awaited.

Answering the MISCIS test - if gs::cap never existed, would the hole exist? Yes, and it already does, in a published example and on the shell's socket path. gs::cap did not create this; it was the first caller that could not look away from it, because a library cannot assume its caller serves nobody.

No kernel change is being proposed or made. That a standard-library feature exposed this creates no urgency to change the kernel, and a mechanism must justify itself independently of the feature that revealed it. backlog/46 records the question, the first-principles answers, the genericity test, and what a future investigation should settle - including the entirely acceptable outcome that resource_invoke is intentionally narrow, in which case its contract gets written down and examples/holder gets fixed or explained.

gs::cap stays unbuilt. Nothing is lost: file-as-capability works today for the caller it has.

The useful thing here is not a feature. No code shipped from this section. What it produced is a hidden assumption made visible - that resource invocation is safe only from a task whose endpoint is otherwise idle, a contract nothing states and one published example does not honour.

13. The governing constraint: the library CONSUMES capability, it never CREATES it

Stated by the operator, and it is the rule the rest of this report should have been written against from the start:

The stdlib's job is to make existing Godspeed functionality pleasant and safe to consume. It isn't supposed to create functionality that the OS doesn't already possess.

So if implementing a stdlib API apparently requires stdlib API -> new syscall -> kernel modification, that is a reason to stop and question the stdlib/SDK composition, not an invitation to modify the kernel.

What this does to the gs::cap conclusion

Section 12 and the backlog/46 triage answered "is the mechanism incomplete, and how expensive is the remedy" - and concluded rung D with a remedy cheaper than first thought. That framing was subtly wrong, because it is not the library's question. Under this constraint the answer is shorter and firmer:

gs::cap correctly does not exist, because the OS does not currently possess safe general resource invocation. A standard library cannot offer a guarantee the system underneath it does not make. The work ended the moment the gap was identified; filling it would have been the library manufacturing a capability rather than re-serving one.

The triage in backlog/46 keeps its value - it rules out B and C with evidence, so a future branch does not repeat the search - but it is a note for whoever picks the mechanism up, not a plan for this branch. feat/stdlib touches neither kernel/ nor sdk/: both diffs against main are empty, and that is now stated at the top of the backlog entry so the analysis cannot be misread as a proposal.

The constraint is STRUCTURAL now, not a promise

The property that enforces this is #![deny(unsafe_code)] on the crate. Without unsafe the library cannot issue a syscall, so it is confined to the SDK's safe surface and can only ever re-serve what already exists. A stdlib able to reach the raw ABI could quietly grow a capability the system does not have.

The attribute was present from the first commit. Nothing was checking it, and the check that should have was broken in two independent ways, both found by adding the root and then deliberately deleting the attribute to watch the gate fire:

  1. unsafe_check.py's DENY_ROOTS was ("services", "examples", "osdev") - the stdlib was not scanned at all. The same gap class as doc_symbols_check.py not scanning stdlib/rust/src, found the same day. A new top-level crate is invisible to every gate whose roots are a hand-kept list, and being invisible reads exactly like passing.
  2. The presence test was satisfiable by a COMMENT. It asked DENY_ATTR not in text against the raw file, and stdlib/rust/src/lib.rs explains the attribute in its module documentation five lines above declaring it. Deleting the real attribute left the gate green. The #[allow] scan in the same function already strips // before matching, for exactly this reason - the lesson had been learned for one half of the function and not the other.

Both fixed, and the guard now fires on a deleted attribute. Worth stating plainly: for the whole of this branch, the one property making "the library cannot manufacture capability" structural rather than aspirational was resting on nobody deleting a line by accident.

14. gs::cap is built, and the answer was rung B all along

Section 12 said this module would not be built because the OS did not possess safe general resource invocation. That was right about the OS and wrong about why, and the error was mine: I had recorded, in backlog/46,

A service-layer tag cannot fix this. Echoing a caller-supplied tag would let the caller RECOGNISE a wrong message, but recv has already CONSUMED it and there is no requeue. Selective dequeue is inherently kernel-side.

The first sentence is true and the conclusion does not follow. A caller does not have to requeue a message it should not have taken. It can HOLD it and hand it back. Correlation is what makes holding possible, and correlation is a protocol property, not a kernel one. So this was rung B - existing userspace machinery, composed wrongly - and it needed no kernel change. kernel/ and sdk/ are still untouched on this branch.

What was actually missing

services/fs speaks two protocols. The NAMED one (OP_WRITE_FILE and friends) carries a tag at byte 0 of the request and echoes it at byte 0 of the reply. The FILE-CAP one (FOP_READ/WRITE/STAT/ CLOSE) carried none. Same service, same file, opposite answers to the same question.

That asymmetry was the whole blocker. With nothing to match on, a holder must either block on a bare recv and read whatever arrives as its reply, or drain its queue and destroy anything else in it. The shell drains, which is safe only because the shell serves nobody.

The fix is one tag, restoring the convention the sibling protocol always had:

request   [tag, FOP_*, ..]      (was [FOP_*, ..])
reply     [tag, FS_*,  ..]      (was [FS_*,  ..])

The edit is deliberately tiny: p is shadowed past the tag so every offset below is unchanged, and the send closure prepends so every reply site is unchanged. One extra 3562-byte reply buffer in serve_filecap, against a 256 KiB frame limit.

What the library does with it

File::invoke sends tagged and waits. A message that is not the reply is held in a bounded array and handed back via File::take_held, never dropped. If there is no room left to hold, the operation stops rather than dropping: what was taken is handed back, what was not taken is still in the kernel queue, and the caller gets OutcomeUnknown because that is the truth.

HELD_MAX is 2. A held message is a full 4 KiB Message, so the bound is small and readable from the source (26.6.1) rather than generous and invisible.

The obligation is stated rather than hidden: a task that serves clients must drain take_held after each operation. Ignoring those messages loses them just as surely as draining would have - the difference is that here you are told.

One design decision worth naming

File borrows &mut Fs. The two speak to one service over one endpoint, so they must share ONE tag counter; two counters can mint the same tag for two exchanges in flight, and the result is not a loud rejection but a stale reply silently accepted as the current answer. The borrow makes that structural - the borrow checker will not let the bug be spelled. The cost is one open file per handle, and Fs::from_tag/Fs::tag are the escape for anyone who needs two.

The dogfood earned its keep again

fcap - the osdev test file-cap self-check that pins 22 Test 14 - gained a section repeating the core property through gs::cap. It failed on the first run:

fcap: FAIL gs::cap round trip - the service sent a reply this library could not parse

invoke VERIFIES the tag and returns the message whole; it does not strip it, because stripping means rebuilding a 4 KiB Message per read. My own doc comment claimed otherwise - "the tag is already checked and stripped" - and read_at and size believed the comment rather than the code, reading the status byte as the first byte of the length. A comment that lies is worse than no comment, and this one lied to its own author within the hour.

Fixed, and the two library checks are now NAMED assertions rather than folded into the aggregate, because gs::cap is the one caller that must work from a task which also serves clients.

Verified

  • osdev test file-cap: 15 passed, 0 failed (was 13 - the two new assertions).
  • osdev test files: 245 passed, 0 failed. An earlier run reported one failure, tab abs-path timeout, on a code path this change does not touch; it passed on re-run and is recorded here as the host-load flake it was rather than quietly re-run until green.
  • osdev test fs-restart: 11 passed, 0 failed.
  • 13 of 13 gates green; cargo test -p godspeed 7/7.

What is still open

backlog/46 stays open, narrowed. The fs file-capability path is correlated now, but the same gap remains wherever else a delegated resource cap is invoked:

  • net-stack's socket and listener caps carry no tag, so sock still relies on draining.
  • examples/holder still does a bare ctx.recv() and hangs forever if its owner dies - hole 2, the missing ReplyDead, which a tag does NOT fix. That one is genuinely about the mechanism, not the protocol, and is left recorded rather than papered over. (Since corrected: its wait after a resource_invoke is bounded by recv_abortable_deadline, so a dead owner costs a deadline, not a hang. It still hand-rolls the invoke rather than using gs::resource::invoke, which is pub(crate) - §24 and §25.)

15. The target-side tests, and the error the restart found

Section 7 recorded target-side tests as owed, and section 9 repeated it. They exist now, and the first one turned up a defect that no amount of reading would have.

gs::fs across a real restart was already covered - by a vacuous assertion

osdev test files kills fs twice (chaos kill-storm fs 2) and then runs dir /. Since cmd_dir walks gs::fs::list_dir, that has been exercising the library against a genuinely dead-and-respawned service all along. The assertion, though, was:

#![allow(unused)]
fn main() {
check!(!r.contains("storage unavailable"), "shell reacquires fs after its own restart")
}

That passes on silence. A hang, a dropped reply, an empty listing, a command that quietly gave up

  • all of them contain no error string. It is the same vacuous shape that let "390 writes" satisfy a guard looking for "0 writes" earlier in this branch. It now requires a real listing (the trailing count) and the absence of both error lines, which makes it an assertion rather than a hope.

gs::cap across a real restart: fcap gsreuse

A new command holds a gs::cap::File (now gs::file::File) across a real fs kill and reads through it afterwards. Pinned by four named assertions in osdev test fs-reuse, which went from 8 cases to 12.

Scope, stated rather than quietly narrowed. fcap reuse additionally deletes the original and writes a same-length replacement into the freed blocks, proving the stale cap cannot read the REPLACEMENT. This test does not, and the reason is the library's own design: File borrows &mut Fs, so while the capability is held the handle cannot write the replacement - and dropping the File to free the handle CLOSES the capability, which is the thing under test. The borrow is deliberate (it makes a second tag counter unspellable), so the constraint is real. What is pinned here is the half that is the library's to get right: a stale capability yields a NAMED error rather than a hang, a silent success, or a wrong answer. The block-reuse case stays pinned by fcap reuse against the same kernel and the same fs.

Two things the test found that review did not

1. service_answered() can never be true for a stale capability. My first probe waited for fs to come back by invoking the held capability and watching for service_answered(). It never fires: the kernel rejects a stale cap on the generation check BEFORE routing, so the owning service is never reached and never answers. The error model was right and the instrument was wrong. The probe moved to inspect_endpoint_generation, a kernel query needing no fs request and therefore no tag - which matters because the handle is borrowed for the whole scope.

2. The library named the wrong cause. The refusal printed:

fcap gsreuse: the stale cap was refused - the service could not be reached (nothing happened)

fs was up and serving. What happened is that the capability was REVOKED when its issuer died (7.5). "Could not be reached" names a different fault and sends an operator to check a service that is running perfectly - precisely the complaint this branch wrote into cmd_tcp two sections ago.

The information was there and I was discarding it. resource_invoke returns IpcError::CapError(CapError), and CapError separates CapRevoked and EndpointDead from CapInsufficientRights. My invoke collapsed all of them by GUESSING from the rights mask instead of reading the error. It now reads it, and Error::Revoked exists:

the capability was revoked - re-open it (nothing happened)

retry_is_safe is FALSE for it, for the less obvious of the two reasons. Retrying cannot double-apply anything - the kernel refused before routing - so it is harmless. It also cannot ever succeed, and a caller looping on that predicate would spin forever. The question the predicate answers is "should I retry", and the honest answer is no: re-open. The host test says so in those words, because the next person to add a variant will face the same choice.

Verified

  • osdev test fs-reuse: 12 passed, 0 failed (was 8).
  • osdev test file-cap: 15 passed, 0 failed.
  • osdev test files: 245 passed, 0 failed.
  • 13 of 13 gates green; cargo test -p godspeed 7/7.

Still owed, and now the only owed item from section 7: nothing. The remaining open work is backlog/46's hole 2 and the net-stack socket caps, neither of which is a library gap.

16. The socket capabilities, and a comment that had the problem backwards

backlog/46's hole 1 is now closed for every delegated-resource issuer in the system. net-stack's socket, listener and connection capabilities carry a correlation tag, as fs's file capabilities already do.

The machinery was already there and deliberately switched off. net-stack has carried Reply { tag: Option<u8> } - stripped in one place, echoed in one place - since the named protocol was tagged. The badged path opted out, with a comment:

A BADGED request is untagged: it is a socket capability invoking its owner, the badge already names the socket, and the client holds no ambiguity to resolve.

That has the direction of the problem backwards. The badge names the socket FOR THE SERVICE. It does nothing for the CLIENT, which waits on its own single endpoint and cannot tell this reply from any other message landing there. The client is the only party with an ambiguity, and it was the one left without the means to resolve it.

The cost was visible two functions away, in the shell's sock_invoke: a queue drain, plus a capability-reclaim the drain made necessary (SEC-35 - an ACCEPT reply carries an embedded CONNECTION CAPABILITY, and a discarded reply leaves it in the pending FIFO for the next open to receive by mistake). Safe only because the shell serves nobody.

A badged request needs ONE header byte, not the two a named request carries: the tag to echo. Patience is the stash's business and a badged request does not go through the stash.

The drain stays, for the SEC-35 reason rather than the correlation one - an aborted invoke can still leave a reply whose embedded capability must be reclaimed. What changed is that correctness no longer RESTS on it. And a tag mismatch now reclaims any capability that arrived with the wrong reply before discarding it, which is SEC-35's own failure approached from the other side: believing the wrong reply would hand the caller a capability to the wrong connection.

A guard that accepted the failure it was guarding against

The QEMU assertion for sock read:

#![allow(unused)]
fn main() {
check!(out.contains("sock: UDP socket cap - sent") || out.contains("socket cap invocation returned nothing"), ..)
}

The second string is printed on exactly one condition: sock_invoke returned None - the capability invocation failed. The guard for the socket-capability mechanism accepted the mechanism being broken, which matters precisely when the framing changes underneath it.

And the leniency it was protecting was not needed. The success line carries a count - "received {n} bytes back", where n may be 0 - so a silent external peer already passes through it. The None branch was never the external case; it is the local one. The assertion now requires the success line.

What is verified, and what is NOT

  • Verified in QEMU (osdev test shell, 203 passed 0 failed): the UDP socket capability opens and is invoked through the tagged framing, reporting the strong outcome rather than the fallback - sock: UDP socket cap - sent 29 bytes to 10.0.2.3:53, received 1 bytes back.
  • NOT verified anywhere: serve - the TCP listener and connection capabilities (LOP_ACCEPT, COP_RECV/SEND/CLOSE). No QEMU suite exercises it, which was true before this change and is stated here because this change touches that path.

The framing itself is shared: one strip site, one echo site, common to all three capability kinds, so the UDP test does exercise the framing that serve relies on. What is untested is the op-specific behaviour - and no op arm was modified. The residual risk is concentrated in one place: the mismatch branch - now gs::resource's held-message path in stdlib/rust/src/resource.rs, which reclaims embedded capabilities rather than dropping them - and which only an ACCEPT exercises. (This named the shell's sock_invoke, deleted when sock moved onto gs::net; the risk is the same one, in the code that inherited it.) That wants a real listener, which means hardware.

17. gs::net::Socket, and two deadline bugs the dogfood found

A UDP socket is a delegated resource capability exactly as a file is (7.10), so Socket and cap::File now share one invocation path: crate::resource. The parts that are easy to get subtly wrong - the one-shot reply cap's lifetime, holding a message that is not ours rather than dropping it, reading the kernel's refusal instead of inferring it - live in one place, because two copies of a subtle rule is how they diverge.

cmd_sock walks it, and the migration deleted the shell's old hand-rolled socket opener outright.

A report that had been wrong the whole time

net-stack answers "nothing came back" with a single zero byte, and cmd_sock printed received {} bytes back from the reply LENGTH. So a query nobody answered read as "received 1 bytes back" - and under QEMU that is exactly what happens, every run. The line had been claiming a response that never arrived.

send_to returns Ok(0) for the sentinel, and the command now says so. The residual ambiguity is the protocol's and is documented rather than hidden: a genuine one-byte 0x00 response is byte-identical to the sentinel, so no reading of it can tell them apart.

The rule this cost me twice

A client's deadline must be LONGER than the worst case of the operation it waits on. A deadline shorter than the service's own bound does not bound anything useful - it converts a slow success into [Error::OutcomeUnknown], which is strictly worse than waiting, because an unknown outcome forbids the retry that would have fixed it.

Broken twice in this branch:

  1. send_to waited NET_SECS (10s) while net-stack retries a datagram six times at two seconds apiece. Intermittent: one run said "nothing came back", the next said "THE OUTCOME IS UNKNOWN", from identical code.
  2. cmd_tcp lost half its patience in the migration, silently. It waited NET_TXN_SECS (20s) before moving onto gs::net, and the move put it on NET_SECS (10s). Nothing in QEMU is slow enough to notice; a real site on real hardware is, which is where it would have cost the most to find. There is a TCP_SECS now, and the shell's timeout message names the constant it actually waits on rather than a different one.

SOCKET_SECS is 30, and it is DERIVED - see section 19, which root-causes it. It was recorded here as "measured, not derived" and that gap is now closed: the retry budget is not 6 x 2 = 12, it is 6 x (2 + 1) = up to 18, because the waits are bounded by whole-second arithmetic.

The assertion that had been hiding it

This was a PRE-EXISTING intermittent failure, and the reason nobody saw it is the guard:

#![allow(unused)]
fn main() {
check!(out.contains("sock: UDP socket cap - sent") || out.contains("socket cap invocation returned nothing"), ..)
}

The old path waited five seconds - shorter still - and printed "returned nothing" on timeout, which that second string accepted. The guard for the socket-capability mechanism accepted the mechanism timing out. Tightening it in section 16 is what turned a silent intermittent into a visible one, and then into a fixed one. A run that goes 203/0, then 199/4, then 203/0 again is worth stopping for rather than re-running until green.

Verified

  • osdev test shell: 203 passed, 0 failed - with the honest line, sock: UDP socket cap - sent 29 bytes to 10.0.2.3:53, nothing came back.
  • osdev test file-cap: 15 passed, 0 failed - the cap::File refactor onto resource is regression-tested by the suite that pins 22 Test 14.
  • osdev test fs-reuse: 12 passed, 0 failed.
  • 13 of 13 gates green; cargo test -p godspeed 7/7. Still no kernel/ or sdk/ change.

18. copier, the fifth dogfood, and a mistake made three times

services/copier moves onto gs::fs. It is the best migration so far by the only measure that matters - what it found.

beforeafter
lines937870
wire-format sites471 (its own control protocol, which is correct)
hand-copied fs opcodes110

The bug it closes

#![allow(unused)]
fn main() {
const FS_TAG: u8 = 0xC0;
req[0] = FS_TAG;                                       // every request
if b.first() != Some(&FS_TAG) { return Fs::Failed; }   // every reply
}

A CONSTANT tag. It distinguishes copier's traffic from an untagged sender and nothing else - one copier request is indistinguishable from the next, which is the entire purpose of a correlation tag. A late reply to a request that already timed out passes that check and is read as the answer to the following one. copier has explicit Slow (deadline-passed) handling, so the path is reachable rather than theoretical. gs::fs carries a wrapping counter owned by the handle, and the handle now lives for the life of the service.

What the migration drove into the library

Two things, both pulled by a real caller rather than invented:

  • read_at - a POSITIONAL read. read_into reads a whole file; copier copies one chunk at a time at an offset it chooses, so an interrupted copy can resume and a file larger than any buffer can move at all. The primitive already existed inside read_into and simply was not reachable.
  • Paths accept BYTES. Every path parameter took &str, and copier stores paths as [u8; PATH_MAX] - as it must, because services/fs accepts any byte above 0x1f except / and 0x7f. A &str-only API forces such a caller through from_utf8(..).unwrap_or(""), which turns an unreadable name into a request for a DIFFERENT file, silently. I had already reasoned this out for DirEntry::name - raw bytes, precisely so a hostile name stays reachable by delete - and then took &str on every path anyway. impl AsRef<[u8]> accepts both, so the documented fs.read_into("/data/message.txt", ..) still reads as it does on the published page.

The same mistake, three times

delete_all waited DEFAULT_SECS, and its doc said:

Blocks up to DEFAULT_SECS, and longer for a large tree than any single-file call.

Which cannot happen. A call does not block past its own deadline; it gives up. I wrote the right intuition and then failed to give it a number - the third instance in this branch of a client deadline shorter than the worst case it waits on, after the UDP socket and the TCP transaction.

copier had already worked this out for its own hand-rolled version, with a comment worth quoting because it names the cost exactly: five seconds "was not nearly enough and said so in the worst possible way - by blaming the filesystem". Its FS_TREE_SECS is 120, which is the number SWEEP_SECS already carried, arrived at independently.

Three times is a pattern, not bad luck. The rule belongs where an author will meet it: a deadline is part of the OPERATION's contract, not a property of the caller, and every one of these was written by reaching for the nearest existing constant instead of asking what the service does.

What was deliberately NOT changed

fs_idempotent's re-send-on-Slow, and its reasoning about which operations may use it - "re-sending must not be able to produce a different outcome than sending once", a stricter and better rule than "reads are safe". The library refuses to retry on its own because it cannot know whether a caller's operation is idempotent; that judgement is copier's and stays there, with its comment carried across verbatim rather than retyped.

The three-way Fs { Ok, Slow, Failed } result also stays at the call sites, so ten of them did not churn - but Failed now NAMES the failure in the log. It used to collapse NotFound, NoFilesystem, PermissionDenied and a malformed reply into one word while fs had said which (26.7).

Verified

osdev test files 245 passed, 0 failed, including a real 45-file directory tree copied through the library (copied /many -> /manycopy (1 dirs, 45 files)). file-cap 15/0 and fs-reuse 12/0 confirm the byte-path change disturbed nothing. 13 of 13 gates; host tests 7/7.

19. Root cause: whole-second deadlines carry a hidden +1

Section 17 left SOCKET_SECS = 30 recorded as "measured, not derived - something in that path costs more than the retry budget accounts for, and I have not root-caused it". Here is the cause, and it is worth more than the constant.

udp_roundtrip retries DANCE_TRIES (6) times at DANCE_SECS (2) apiece, which reads as a 12-second budget. Each of those waits is request_with_reply_deadline_sifted, and its bound is:

#![allow(unused)]
fn main() {
let t0 = self.epoch_secs_monotonic();
...
if self.epoch_secs_monotonic() - t0 >= max_secs { return DeadlineOutcome::Timeout }
}

The deadline does NOT restart per sifted message - t0 is taken once, which was my first hypothesis and was wrong. What it does instead is arithmetic in whole seconds: epoch_secs_monotonic returns an integer count, so a "2 second" deadline elapses when the COUNTER advances by two. That is anywhere between just over 1s and just under 3s of real time, depending where in the second t0 fell. The worst case per wait is max_secs + 1.

6 tries x (2 + 1) = up to 18 seconds, not 12

Which explains the behaviour exactly: 10 and 15 both sit below 18 and produced intermittent [Error::OutcomeUnknown]; 30 sits above it and is stable. The constant is derived now, with margin, and the doc says how it was arrived at.

The rule, which is the useful part

A deadline built from whole-second differences carries up to +1s of slop, so N chained waits of S seconds bound at N x (S + 1), never N x S.

Any budget computed the obvious way is short. And short is not a small error here: it converts a slow SUCCESS into an unknown outcome, which is the one error that forbids the retry that would have fixed it. That is the same failure mode as the three deadline bugs in sections 17 and 18 - this is their cause rather than a fourth instance.

What is NOT affected, checked rather than assumed: tcp_transact takes a MILLISECOND budget (8000), so it does not chain second-granularity waits, and TCP_SECS at 20 has real margin over 8. SWEEP_SECS at 120 is a single wait on one whole-volume operation, not a chain, and services/copier arrived at the same 120 independently for its own version.

Worth knowing beyond this library

This is a property of every deadline in the system built on epoch_secs_monotonic differences, not of the standard library. Anyone sizing a budget out of a retry count and a per-try timeout is computing N x S and getting a number that is up to N seconds short.

The fourth instance, in the test harness

Section 19's rule broke once more before the day was out, and this time I caused the breakage by fixing something else. Recorded because the shape is now unmistakable.

Raising SOCKET_SECS to 30 made sock able to take thirty seconds. The harness step that reads its output waits:

#![allow(unused)]
fn main() {
let sock_out = collect_until(&buf, &mut cursor, b"gsh>", Duration::from_secs(8))
}

Eight seconds. Correct while the shell gave up after five; wrong the moment the command could outlive it. And the failure is not local - when collect_until returns early the harness is reading the wrong place in the stream, so EVERY LATER STEP cascades.

The symptom was five consecutive runs of one build giving 12, 6, 1, 11 and 50 failures. I spent two of those runs treating it as host load, which it was not: the serial log carried zero kernel panics and zero liveness wedges, and simply stopped partway. That is the signature of a harness losing its place, not a guest breaking, and reading it would have been quicker than re-running.

What decided each run was whether the DNS peer answered inside eight seconds.

So the rule holds in one more place than stated: a waiter's bound must exceed the work it waits on - and a test harness is a waiter. Four instances now: the UDP socket, the TCP transaction, the recursive delete, and the harness step that watches them.

20. services/time is correctly NOT migrated, and it names a real gap

time was the last service talking to fs and net-stack by hand, so it was the obvious sixth dogfood. It should not be one, and the reason is worth more than the migration would have been.

#![allow(unused)]
fn main() {
/// Ask `fs` for the persisted floor. Non-blocking: the answer arrives later, tagged.
fn floor_load(ctx: &ServiceContext) -> bool {
}

time never waits for a reply. It sends a request carrying a reply cap and returns immediately; the answer arrives later and is matched by tag in its main loop. That is not an accident of style - it is the clock service, and a clock that blocks on the filesystem stops being one.

gs::fs and gs::net are synchronous request/reply. Putting time on them would make it block, which is a functional regression in the one service that must never stall. The brief's own rule settles it: do not make Godspeed easier to use by making Godspeed less Godspeed.

Its net-stack use is smaller still - a fire-and-forget try_send(&[11]), the capless clock nudge, with no reply expected at all. There is nothing there for a request/reply library to remove.

The gap, stated plainly

This library has no answer for a client that cannot block. Everything in it is send-then-wait-for-this-reply. A service that must keep serving while an answer is outstanding has to do what time does: hand-roll the reply cap, tag the request, and demultiplex replies in its own loop - which is exactly the plumbing the library exists to remove, in the one case where it cannot.

That is not an argument for adding an asynchronous surface now. It has ONE caller, and a second would be needed before the shape could be designed from evidence rather than imagination (26.2). It is an argument for writing the boundary down, so the next person does not read "no repeated plumbing was found in time" - which is what the brief says - and conclude the plumbing is not there. It is there; it is just a different shape, and this library does not fit it.

Five migrations, and the sixth candidate correctly refused. recorder, the shell's tcp, dir, fcap, sock and copier all shrank and all found defects. time would have grown a bug.

2026-10-09: time's CALLS are on gs now; its SHAPE is not, and the refusal above still holds. When every service moved onto the standard library (§25), time's sends, replies, peer lookups, reacquires and clock reads went to gs::ipc, gs::cap and gs::task like everyone's. What did not move is what this section is about: it still never waits for a reply, still tags its requests and demultiplexes the answers in its own loop, and still uses no gs::fs or gs::net - which remain send-then-wait. The gap is unchanged.

21. TCP listen and accept, and the objection that was wrong

Section 20 ended by arguing against wrapping TCP listen/accept: serve had no QEMU coverage, so a migration could not be verified, and the surface would have no proven caller.

The premise was wrong, and the operator said so: create a caller from outside and connect to it.

SLIRP restricts the GUEST reaching outward - its only peer is the gateway, which is the limitation docs/ records and which I had generalised into "inbound cannot be tested either". Inbound is exactly what hostfwd is for, and the harness that already drives the guest over serial can equally open a TCP socket and be the client. One line:

-netdev user,id=n0,hostfwd=tcp:127.0.0.1:18080-:8080

The test, before the feature

osdev test shell now runs serve 8080, connects from the HOST, sends bytes and requires them back. Three assertions in increasing strength: the guest is listening, a host client's bytes make the round trip, and the guest accepted through ACCEPT's embedded capability.

That last one is why this mattered beyond coverage. LOP_ACCEPT returns an embedded connection capability, and that is the riskiest part of the socket-capability tagging added in section 16: a reply believed on a mismatched tag would hand the caller a capability to the WRONG CONNECTION - SEC-35 approached from the other side. Nothing exercised it. I had flagged that gap and then proposed to leave it open, which was wrong twice.

Then the feature

Net::listen returns a Listener; Listener::accept returns Ok(None) when nobody is waiting - an ordinary poll result, not an error - and Ok(Some(Conn)) carrying a capability to one connection. Conn has recv, send and close.

The borrow structure is the design, not an artefact:

Net  --&mut-->  Listener  --&mut-->  Conn

One connection at a time, and one tag counter owner all the way down. That is serve's actual model

  • accept one, answer it, close, accept the next - now enforced by the compiler rather than remembered by the author. It also forced one genuine improvement: the accept poll became a labelled loop that YIELDS the connection, because an Option<Conn> carried across iterations would still hold the listener borrow when the next accept() wanted it. The loop now has one job and one result.

The sixth migration

cmd_serve runs on it. What stayed is what serve MEANS: the address banner (asked of net-stack rather than remembered, so a changed lease cannot go stale), the ten-second live sign, q, the printable-only rendering of a peer's bytes, and the 250 ms poll with its reasoning. What went is the wire format - the shell's socket-invoke helper, its listener-release helper and three opcode constants deleted, 87 lines net.

Verified by the test written first: 206 passed, 0 failed, with a real host TCP client.

One diagnosis worth recording

After the dead-code cleanup the suite reported 200/6. The cleanup removed only items whose sole remaining occurrence was their own definition, and all 30 services built - so it was inert, and the serial log said so plainly: the shell never printed its listening banner, net-stack started three times, and one of its passes took 8 seconds. net-stack had restarted twice under host load. A clean re-run gave 206/0.

That is the fourth time on this branch that a load-induced cascade has looked like a regression, and the third time the serial log answered it faster than another run would have. The tell is consistent: no kernel panic, no wedge, and output that simply stops.

22. The dogfood is finished, and what it found in the last mile

The sweep is done: every fs and net-stack call in services/shell goes through the library except two groups that are kept on purpose and named below. services/copier and services/recorder were migrated earlier. The shell is the interesting one because it is the biggest client of both services and the one an operator is looking at when something goes wrong.

What the last five commits moved

CommitSitesWhat it removed
dogfood 17five STAT sites, churn resetfive independent decodings of the same eleven bytes
dogfood 18mkdir, deletetwo commands whose whole job is a mutation, reporting it as a status byte
dogfood 19five directory walksfive copies of the page loop and the 15-byte entry stride
dogfood 20seven deadline-bounded sitesfs_request_bounded and the shell's OP_READ_FILE decoder
dogfood 21churn verifythe last walk that could be collected before it reads

Plus 195 lines of helpers that had nothing left calling them.

What stays hand-written, and why

The seven fcap sites. Deliberate. §22 Test 14 is the identity test for file-as-capability, and a test that exercises the library rather than the protocol proves less about the protocol. They are the control group and should stay one.

copy <src> <dst> recursive. It calls fs again for every entry it lists - mkdir for a directory, a streaming copy for a file - and list_dir's closure holds the handle for the length of the walk, so a nested call cannot be spelled. churn verify had the same shape and was migrated by collecting names first, because its set is bounded at eight by construction; a subtree is not bounded that way, and buffering one is several kilobytes on a frame that is already tight ([[project-shell-stack-pipe]]).

The route that would close it is a paging API - hand back one page, drop the borrow, let the caller do what it likes between pages - which is DirCursor's shape, already proven in the shell. It is not written, because two call sites is not yet a reason to have one (§26.2), and this is recorded rather than half-done (§26.7). Nothing about the protocol forbids the nested call: the page reply is already in hand when the closure runs, so the tags cannot interleave. It is a borrow, not a race.

Three defects the last mile found, none of them in the code it was migrating

A tree delete cannot say "it failed". delete <path> recursive frees in batches, so a lost reply leaves the tree possibly whole, possibly half gone, possibly untouched - and the old line asserted the last of the three. Same for churn reset. Both name the command that settles it now.

A badged request had no way to say how long its client would wait. net-stack held one for a fixed 1500 ms and dropped it, while serve's accept waited twenty seconds for an answer that no longer existed. The service had diagnosed exactly this for its NAMED path and fixed it there; HOLD_MS's doc comment says so in the words the defect deserved - "a constant cannot know a client's deadline, so it stopped guessing and the client now says". It stopped one path short. Slowest serve pass: 64,063 ms before, 1,735 ms after.

The library was unsafe for a caller that serves clients. gs::call::request_within waited with a plain recv, which takes whatever lands next - so a client request arriving mid-wait was consumed, misparsed, and lost. services/recorder demonstrated it the day it stopped hand-rolling its own request: the selfcheck polls events persist status once a second, and a capture died because the shell asked it a question at the wrong moment. The kernel has CallDeadline for exactly this (CLAUDE.md §8.2), the SDK exposes it, and the recorder had been using it before the migration - so the migration traded a correct primitive for a convenient one. Fixed by using the primitive; the whole named path is now safe for a serving caller, which is what gs::cap's module header had been claiming for the library as a whole.

That third one is the most important thing the dogfooding produced, and it is worth being plain about why: it was found by a service USING the library, not by reading it. The module header that promised the property and the function that did not deliver it are 300 lines apart in the same crate and had both been reviewed.

Two gates were wrong, and both are fixed rather than worked around

Commandment IX's standard-library check required the literal DeadlineOutcome::SendFailed in gs::call. It failed a change that preserves exactly what it guards, and would have passed a file that named the variant in a comment while reacquiring on the deadline. It reads the match arm the reacquire sits in now, which is the question IX actually asks.

The harness's selfcheck window was 150 seconds. fail ends a gsh run, so while one statement in the middle of the suite was failing, only 326 of its 509 statements ever executed - and the window had been chosen against that shortened run. Fixing the failure made the suite 56% longer and the window was then under the work it waits on. Three runs gave 5/0, 5/0, 3/2; at 300 seconds, three for three.

Both are the same shape as the four deadline bugs in §19, one layer up: a bound chosen against a smaller version of the work is not a bound on the work.


23. What this branch is validated on, and what it is not

Recorded here rather than left to be inferred, because the answer is not uniform across the tree and the uneven part is the part that matters (§26.7: a limitation that cannot be closed today is written down at the place a reader would otherwise rely on the claim).

The strongest fact is the smallest diff

git diff main...HEAD -- kernel/ sdk/     ->  empty

No kernel source change and no SDK change. So nothing on this branch can have altered kernel behaviour, and every property main held, it still holds. That is not a claim resting on a test run; it is a claim resting on there being nothing to test. It takes the whole §22 surface off the risk list by construction.

What the branch DID change that runs on every port:

services/shell/src/main.rs      2490 lines
services/recorder/src/main.rs    132
services/net-stack/src/main.rs    48     (the badged header byte and client patience)
services/fs, services/copier     the rest

9,813 insertions and 2,482 deletions across 72 files in total.

Validated on hardware: x86_64 only

HP T630, on the image built from this branch:

  • selfcheck 516/0, all nine parts, one tally, zero skips, zero detail-cap lines
  • net lease, gateway and ping; tcp real SYN/RST with on-link ARP
  • serve 3/3 accepted with byte-exact echo from a peer on the LAN - an inbound connection through a minted connection capability. This is the one path QEMU structurally cannot produce, because SLIRP's only peer IS the gateway, so it had never run before this branch.

Validated by build and gate: all four ISAs

The other three ports had never been COMPILED on this branch, which made the stack-fit gate's green misleading: it checks every target that has been BUILT and silently covers no others, so it was reporting on x86 alone. All four are built now and it is no longer blind:

stack fit: deepest single frames (limit 256 KiB)
      81920 bytes (31.2%)  console: service_main
      61440 bytes (23.4%)  shell: cmd_edit
      57344 bytes (21.9%)  fs: service_main
      49152 bytes (18.8%)  net-stack: service_main
stack-fit: every frame fits the 262144-byte user stack:
  aarch64-unknown-none (28 services), armv7a-none-eabi (27),
  riscv64imac-unknown-none-elf (28), x86_64-unknown-none (30)

Zero hard errors on any port. The warning counts (372 / 385 / 393) are pre-existing categories, and the ones naming SDK functions are main's by definition since sdk/ is zero-diff.

19 of 19 checker scripts pass with all four targets present: scripts/arch_boundary_check.py, scripts/arch_seam_check.py, scripts/backlog_check.py, scripts/commandments.py, scripts/contract_check.py, scripts/dash_check.py, scripts/doc_refs.py, scripts/doc_symbols_check.py, scripts/embed_order_check.py, scripts/facts_check.py, scripts/foreign_word_check.py, scripts/line_ending_check.py, scripts/line_ref_check.py, scripts/port_scope_check.py, scripts/scaffold_check.py, scripts/service_embed_check.py, scripts/shared_surface_check.py, scripts/site_check.py, scripts/unsafe_check.py.

What is NOT validated, stated plainly

No non-x86 machine has booted this branch. A build and a stack-fit are a real bound but they are a static one, and two risks on this branch are dynamic:

  1. The shell changed 2,490 lines and is the crate with the least headroom in the tree. Stack-fit now covers it on every ISA, which is the cheap half of that question; the expensive half is that a frame the checker cannot see (a prologue form it does not match, recursion it cannot count) only shows up when the machine runs it.
  2. net-stack's wire format changed. The badged path strips two header bytes instead of one and Displaced::note reads patience from pl.get(1). That is protocol, and it sits in front of four different controllers (e1000/RTL8168, smsc95xx, GENET, dwmac). x86 exercised one of them.

An ARM boot running selfcheck and net is what would close both, because ARM is where both live. The Wyse adds a second x86 machine with different firmware and the 4K console path, which is worth having and touches neither.

Open items that are not about this branch

  • backlog/fixed/48 and backlog/49 - the userspace-reachable kernel panic class. A genuine violation of an absolute bar (§22: the kernel must never panic on user-controllable input), and the kernel is zero-diff here, so this branch neither causes it nor worsens it.
  • backlog/50 - nic-driver read 100% on the T630. One sighting, one machine, not root-caused; services/nic-driver/ is byte-identical to main.
  • backlog/51 - the display-only console status region. Designed, deliberately not built: it changes the terminal every port renders through.

Both x86 machines, and why their selfcheck counts DIFFER (2026-09-25)

Dell Wyse 5070, on the same image as the T630:

selfcheck   ran 518, failed 0, skipped 0
sock        sent 29 bytes to 192.168.4.1:53, received 94 bytes back
net         192.168.4.83, gw 192.168.4.1, ping ok, lease ok (DHCP), dns 192.168.4.1
serve       3 inbound connections from a LAN peer, every byte returned unchanged

The T630 reported ran 516 on that same binary, and both numbers are correct. This is worth writing down because the difference reads as a regression and is not one; it cost a round of investigation here and would cost the next person the same.

ran counts STATEMENTS EXECUTED, and the suite has conditional blocks. The one that bit is in 60-data.gsh:

if dir /system/scratch/churn {
    echo 'selfcheck: an earlier churn is still on disk - verifying it BEFORE it is overwritten'
    if churn verify { echo 'PASS  churn - the earlier run holds no torn file ...' }
}

The Wyse's disk carried churn files from an earlier session, so that branch ran and added exactly two statements. The T630's disk had been freshly flashed, so the block did not run at all. 516 + 2 = 518, and the extra two PASSED - the leftover data verified intact, which is precisely what that block exists to check before overwriting it.

So a machine with disk history does MORE checking, not less, and a higher count is the healthier reading. The numbers that matter are failed 0 and skipped 0; ran is a function of machine state and is not comparable across machines without knowing the state. The retry loops in 40-persist.gsh (for i in range 30) and 80-network.gsh (for i in range 4) vary the same way: their bodies are guarded, so a first-try success executes fewer statements than a retry.

This is a documentation gap rather than a defect - nothing told the operator that ran is state-dependent, and selfcheck has no utilities/ spec to say it in (audits/documentation-audit.md A7-4). Recorded here until it does.

What the Wyse adds over the T630, since a second x86 machine is not automatically new evidence: different firmware, a different storage controller, the 4K console path whose framebuffer memory type was the 596 ms -> 29-41 ms per-scroll fix, and a disk with history rather than a fresh format. The ~2,750-line selfcheck ran in 96 seconds, so the console fix is intact on this board.

sock on a second machine. Byte-identical result to the T630 - 29 out, 94 back, from the lease-supplied resolver. The udp_roundtrip fix (5716da17) is now confirmed on two boards, and it is a path that had never once completed on real hardware before that commit.

serve on a second machine. Three inbound connections from a separate LAN peer, each echoed byte for byte. QEMU structurally cannot produce this (SLIRP's only peer is the gateway), so it is two machines' worth of evidence for the one thing emulation cannot test at all.

The Pi 2 boots this branch, and both named risks are CLOSED (2026-09-25)

The section above says "No non-x86 machine has booted this branch" and names two risks that only an ARM boot could answer. That is no longer true, and this is the correction rather than a new claim.

Raspberry Pi 2 (ARMv7, BCM2836), on the branch image:

selfcheck   ran 509, failed 0, skipped 1
sock        sent 29 bytes to 192.168.4.1:53, received 94 bytes back   (twice)
net         192.168.4.84, gw 192.168.4.1, ping ok, lease ok (DHCP), dns 192.168.4.1
serve       3 inbound connections from a LAN peer, every byte returned unchanged

The one skip is correct and says so: hw-enumerator - this machine has no <a class="gs-abbr" href="../glossary.html#gl-pci"><abbr title="PCI - the device bus the kernel enumerates. BDF = Bus/Device/Function, a device's address on it.">PCI</abbr></a> to enumerate (Pi 2); not a failure. A skip that names its reason is the behaviour this suite is built for; a silent one would be a test that had quietly stopped testing.

Risk 1: the shell's stack headroom on ARM - CLOSED

services/shell changed 2,490 lines on this branch and ARM has the tightest user stack in the tree. Static stack-fit passed at 23.4% of budget on all four ISAs, but a frame whose prologue the checker cannot match is invisible to it - which is why the static pass was recorded as the cheap half of the question and a boot as the expensive half.

The Pi 2 reached the prompt, ran 509 checks across all nine parts including the pipe and record paths, then ran serve and answered three inbound connections. No fault, no wedge. The expensive half is answered.

Risk 2: the changed net-stack header in front of a different NIC - CLOSED

services/net-stack changed 48 lines, and the badged path now strips two header bytes instead of one with Displaced::note reading patience from pl.get(1). That is a wire format, and both x86 boards share an RTL8168; this board is smsc95xx over USB, a different driver and a different bus.

net took a lease and pinged, sock completed its round trip twice, and serve accepted three connections. The header change holds across three controllers now (e1000/RTL8168, RTL8168, smsc95xx).

What sock proves here specifically

29 bytes out, 94 back, to the lease-supplied resolver - byte-identical to both x86 boards. That path had never once completed on real hardware before 5716da17, and udp_roundtrip's three defects (re-transmitting instead of RX-polling, never answering an ARP for us, never pacing) were all things QEMU structurally cannot expose. Three boards, three NICs, three drivers, same answer.

The count, again, and why it is not comparable

ran 509 against the T630's 516 and the Wyse's 518. All three are correct: ran counts statements EXECUTED and the suite is full of guarded blocks, so the figure is a function of machine state and hardware, not of correctness. The Pi 2 skipped the PCI block it has no bus for and did not carry the leftover churn files the Wyse did. failed 0 is the comparable number; ran is not, and reading it as one has now produced a false alarm once.

Coverage after this run

boardISANICselfchecksockserve
HP T630x86-64RTL8168516 / 094 B3/3
Dell Wyse 5070x86-64RTL8168518 / 094 B3/3
Raspberry Pi 2ARMv7smsc95xx509 / 094 B3/3

Three boards, two ISAs, two NIC families, and serve answering a real LAN peer on every one of them

  • the single thing QEMU cannot test at all, because SLIRP's only peer is the gateway.

Still unbooted on this branch: the Pi 4 (AArch64) and the VisionFive 2 (RISC-V 64). Both build clean and both pass stack-fit, and the two risks that made the ARM boot load-bearing are now answered on ARM - but neither of those boards has run this code, and that is stated here rather than implied away.

The Pi 4 boots it too - three ISAs, four boards (2026-09-25)

Raspberry Pi 4 (AArch64, BCM2711), on the branch image:

selfcheck   ran 516, failed 0, skipped 0
sock        sent 29 bytes to 192.168.4.1:53, received 94 bytes back   (twice)
net         192.168.4.85, gw 192.168.4.1, ping ok, lease ok (DHCP), dns 192.168.4.1
serve       3 inbound connections from a LAN peer, every byte returned unchanged

Zero skips - this board has PCI where the Pi 2 does not, so the hw-enumerator block the Pi 2 correctly skipped runs here, and ran lands on 516: the same figure as the T630, for the same two reasons (PCI present, and a disk with no leftover churn files). That is a coincidence of machine state rather than a meaningful match, and is recorded as such so nobody reads 516 as a target.

A fourth NIC for the changed net-stack header. GENET, on-SoC, neither the RTL8168 of the two x86 boards nor the Pi 2's USB smsc95xx. net, sock and serve all work, so the badged two-byte header now holds across three drivers on three buses.

sock again returns 94 bytes, byte-identical to all three other boards. Four boards, four NICs, one answer, on a path that had never once completed on real hardware before 5716da17.

Coverage now

boardISANICbusselfchecksockserve
HP T630x86-64RTL8168PCIe516 / 094 B3/3
Dell Wyse 5070x86-64RTL8168PCIe518 / 094 B3/3
Raspberry Pi 2ARMv7smsc95xxUSB509 / 0 (1 skip)94 B3/3
Raspberry Pi 4AArch64GENETon-SoC516 / 094 B3/3

Three instruction sets, four boards, three NIC drivers on three different buses, and serve answering a real LAN peer on every one - the single thing QEMU structurally cannot test, because SLIRP's only peer is the gateway.

Still unbooted on this branch: the VisionFive 2 (RISC-V 64, dwmac). It builds clean and passes stack-fit; it has not run this code. Recorded rather than implied away.

A deployment fact worth keeping, because it cost a boot

The Pi card in use is a DUAL-BOOT card: it carries the Pi 2 firmware (bootcode.bin, start.elf, fixup.dat) AND the Pi 4 firmware (start4.elf, fixup4.dat), plus both kernels, and config.txt is the switch between them. A card set for one board and put in the other shows the RAINBOW SCREEN - the firmware starts, looks for the kernel the config names, does not find it, and stops.

That is exactly what happened on 2026-09-25, and the BUILD was suspected first. It was not the build: the staging bug that once caused this class (75cd691c, two boards writing their config to one path) has stayed fixed, and the two staged configs differ and name different kernels. What was missing was any way to ask a card which board it is currently set for, which on a dual-boot card is a permanent question rather than a one-off mistake.

The same check also caught a second fault the first would have masked: the Pi 4 kernel on that card was stale (3,668,928 bytes against the 3,713,984 built), so flipping only the config would have booted an old kernel and produced a result that looked valid and was not.

The VisionFive 2 closes it: FIVE boards, FOUR instruction sets (2026-09-25)

StarFive VisionFive 2 Lite (RISC-V 64, JH7110), on the branch image:

selfcheck   ran 516, failed 0, skipped 0
sock        sent 29 bytes to 192.168.4.1:53, received 94 bytes back   (twice)
net         192.168.4.86, gw 192.168.4.1, ping ok, lease ok (DHCP), dns 192.168.4.1
serve       3 inbound connections from a LAN peer, every byte returned unchanged

Zero failures, zero skips. Every machine in this project has now run this branch.

The complete hardware matrix

boardISANICbusselfchecksockserve
HP T630x86-64RTL8168PCIe516 / 094 B3/3
Dell Wyse 5070x86-64RTL8168PCIe518 / 094 B3/3
Raspberry Pi 2ARMv7smsc95xxUSB509 / 0 (1 skip)94 B3/3
Raspberry Pi 4AArch64GENETon-SoC516 / 094 B3/3
StarFive VisionFive 2RISC-V 64dwmacon-SoC516 / 094 B3/3

Four instruction sets. Four NIC drivers across three bus types. serve answering a real LAN peer on every single board - fifteen inbound connections in total, every byte returned unchanged.

That last row of the serve column is the one worth pausing on, because QEMU structurally cannot produce ANY of it: SLIRP's only peer is the gateway, so an unsolicited inbound connection from a third party had never happened in emulation even once. Fifteen of them have now happened on iron.

What the matrix actually closes

The changed net-stack wire format. services/net-stack strips two header bytes on the badged path instead of one, with Displaced::note reading patience from pl.get(1). That is a protocol change, and it has now held in front of four different drivers - RTL8168 on PCIe, smsc95xx over USB, GENET and dwmac on-SoC. This was one of the two risks that made a non-x86 boot load-bearing.

The shell's stack headroom. 2,490 lines changed in the crate with the least headroom in the tree. Static stack-fit passes on all four ISAs at 23.4% of budget, but a frame whose prologue that checker cannot match is invisible to it. Every board reached a prompt, ran its full suite across all nine parts, and served connections afterwards. That was the other risk.

sock returns 94 bytes on all five boards. Identical. On a path that had never once completed on real hardware before 5716da17, and whose three defects - re-transmitting instead of RX-polling, never answering an ARP for us, never pacing the poll - are each things QEMU is structurally incapable of exposing, for three separate reasons.

On the ran column, one last time

516, 518, 509, 516, 516. All five correct. ran counts statements EXECUTED through guarded blocks, so it is a function of hardware and disk state: the Pi 2 skipped a PCI block it has no bus for, and the Wyse carried leftover churn files the others did not. failed 0 is the comparable figure. Reading ran as comparable produced one false alarm in this session and is therefore stated here a third time.

What hardware does NOT close

The backlog/fixed/48 / backlog/49 class - a userspace-reachable kernel panic, reproduced under load - is untouched by any of this and is a violation of an absolute bar (CLAUDE.md 22: the kernel must never panic on user-controllable input). It is not caused or worsened by this branch, whose kernel diff is comments only, and the operator has already scheduled it for after this work.

backlog/52 - the shell suite flaking on sock and serve in QEMU - is also untouched. Five boards passing does not make a flaky harness deterministic.


24. gs::cap was split, and what the earlier sections call gs::cap is now gs::file

Read sections 10 to 12 with this in front of you. They record the reasoning that produced a module called gs::cap, and that reasoning is unchanged and still correct - but the NAME moved, so a reader following it into the current tree would land somewhere else.

what sections 10-12 call `gs::cap`   ->   `gs::file`   (a file held as a capability)
what `gs::cap` means now             ->   capability rights, and the operations on a capability

Why the split, and it is not a tidying

The module was 256 lines whose doc opened "A file as a capability". It held File and nothing else. So a program that wanted to ACQUIRE, DUPLICATE or DROP an ordinary capability found nothing in the module named cap and reached into godspeed_sdk - the layer this crate exists so that a program does not need. That is the mechanical reason cap-grant, holder and resource-server all imported the SDK, and it was invisible for as long as nobody asked the question the name answers.

The rights constants did not move. READ and WRITE are capability rights (CLAUDE.md 7.4), not file flags, so gs::cap::READ at an fs.open call site reads correctly and no call site changed.

What else arrived with it

ModuleItemsWhy it exists
gs::ipc14Messages. This was the gap. Nothing in gs could send one, so nine of fifteen examples had no choice but the SDK
gs::cap12acquire, duplicate, remove, self_grant - what the name always promised
gs::file9what the module actually was
gs::task7yield, sleep, uptime, the wall clock
gs::resource4mint and revoke. It already existed as mod resource; - PRIVATE - which is also why the public count read 93 rather than 94
gs::record1a re-export, deliberately - see below

130 items across 12 public modules, from 93 across 7.

Dated 2026-09-25, and both halves of that sentence need a note. The figure is a SNAPSHOT of what the split produced; gs::trace and gs::task::clock_source landed the next day, so the current surface is 149 items across 13 modules, which is what README.md publishes.

And the two numbers are not comparable, which is the more useful thing to record. This table counts gs::record as 1 - one pub use statement - while the README's method counts the 10 names that statement re-exports, because what a developer can call is a name and not a statement. Three different totals have been published across two days (93, 130, 143) precisely because nobody fixed the method first. It is fixed now, and stated in the README next to the figure so the next person can re-derive it instead of trusting it.

Recounted 2026-10-09: 164 items across the 13 modules, plus the gs::driver tier's 33. By the README's rule - free functions, constants, types, the methods on those types, and every re-export - applied to each module's own file, so the crate root's re-exports of Error and ServiceContext and its prelude are not counted a second time. The 13 EXCLUDE driver, a 14th public module: its wait, delay and irq are counted separately because backlog/71 then put that tier outside the first covered surface (since 2026-10-11 it is inside, grow-only). The same count on the 2026-09-26 tree gives 151 where 149 was published, so two of the gap are method; the other 13 are the surface growing (call::request_once, task::sleep_quantum/sleep_us/sleep_ticks, ipc::reply, ipc::recv_within_ms, the five-call ipc::exact module and its two error-type re-exports, IpcError and CapError).

gs::record is a re-export and that is a decision, not laziness

Everything else here wraps the SDK because there is something to translate: a capability to hold, an error to map onto one type, a protocol to hide. A Table has none of those - it is a bounded arena and some arithmetic, no syscall, no failure a program should hear about differently. Wrapping sixteen methods to change nothing would be the speculative layer 26.2 warns against AND a second name for one thing (Commandment III).

MMIO and DMA stay in the SDK, permanently

Superseded in part, 2026-10-02 - see docs/driver-library.md. The line drawn below is MOVED, not erased. Hardware authority still comes only through the SDK's audited layer and unsafe still lives only there (CLAUDE.md 18.1) - that half stands exactly as written. What changed is the other half: gs now has a driver tier for the safe, device-neutral MACHINERY a driver builds on (bounded waits first), so "reach for the SDK only if you are writing a driver" becomes "a driver reaches for the SDK only where gs::driver has no mechanism yet". Each mechanism is added only after it is found repeated in real drivers. The text below is kept as the reasoning that held until then.

The rule this work exists to make true is use gs; reach for godspeed-sdk only if you are writing a driver. That rule is clean precisely because MMIO and DMA are on the other side of it. A gs::mmio would either be a second name for sdk/rust/src/mmio.rs - which CLAUDE.md 18.1 designates BY NAME as one of the few places unsafe is permitted in userspace - or a move requiring a constitutional amendment. And it would erase the boundary the moment it was written: there would be no remaining reason to reach for the SDK, while drivers would still be doing something categorically different.

The rule is measured now, not asserted

13 of 15 examples name only gs. The two that do not:

  • driver-skeleton - Mmio and Dma. Correct, and the reason the SDK exists.
  • holder - its hand-rolled resource_invoke only. The standard library has that and does it BETTER: resource::invoke also stashes replies that are not ours, which is what makes it safe in a service that also serves clients (8.2). It is pub(crate) because its signature carries the stash, and making that public is an API-shape decision rather than a rename. Still owed.

Four things written from assumption, and caught by building each module as it was written

Recorded because the pattern is the lesson, not the four:

  1. derive_cap takes NO rights argument. It duplicates a capability that already holds GRANT, with the same rights. The wrapper was derive(ctx, cap, rights) with a doc explaining that rights can only be dropped - describing something the syscall does not do. It is duplicate now.
  2. Error::Denied does not exist; the variant is PermissionDenied.
  3. epoch_secs is on Datetime, not on ServiceContext.
  4. name_str is on TaskStat. A service cannot ask its own name - ctx.name() is todo!() - so that wrapper was removed rather than wrapped around a stub.

All four surfaced within a minute of cargo build. The method that catches them is building the module as it is written rather than writing the crate and then checking it.

25. Every service on the standard library, and what that found (2026-10-09)

The operator, finding the dogfood had not been done: every service should be written on gs. The one-way gate (scripts/one_way_check.py, 2026-10-05) only stopped NEW raw calls; 1085 calls with a gs equivalent were still in 28 crates. All of them moved, the gate's baseline is empty, and every crate - new or old - is held at zero.

The rule for each call was the one this document has always used: behaviour must not change. Where the library's answer was different, the library gained the missing piece rather than the call staying on the SDK. (An earlier attempt at the same work, 2026-10-01, stopped at about 190 calls with the gaps left open and was never merged; its console fix is the first commit of this one.)

The two defects it found

console leaked a reply capability on every terminal-size and scrollback request. It answered and never gave the one-shot capability back, so each query used a slot in its table for good. gs::ipc::reply answers and removes in one call, which is how it was seen and how it is fixed.

gs::call::request_within re-sent a request that had already arrived. It retries once when the send never left, and decided "never left" with a catch-all that swallowed ReplyDead - the kernel saying the request WAS delivered and its replier died holding the reply capability (CLAUDE.md 8.6). That is the "it may have happened" case this module's own header says must never be re-sent. It was, silently, whenever a peer died mid-request.

The first fix folded ReplyDead into OutcomeUnknown, and an independent review of the whole change caught what that cost: four callers had leaned on the silent retry for operations that ARE safe to repeat - block-driver's block transfers through xhci, nic-driver's frames through dwc2, counter's read - and every one of them now gave up, while their logs blamed a peer that was "silent" when it had died. So it is its own variant, Error::PeerDied: not safe to re-send blind, like OutcomeUnknown, but a different obligation - reacquire first, then ask again only if the operation is safe to repeat. Each of those callers now does that ITSELF, at the call site, where the knowledge that the operation is idempotent actually lives; control reports a restart whose outcome is unknown instead of re-sending it.

And a rule about authority that was false

gs::io and scripts/contract_check.py said printing needs console_push. It does not: the kernel's ConsoleWrite checks LOG_WRITE, which every task holds in slot 0, and the project's own test log showed a program with no CONSOLE_PUSH printing. The rule came from a Stranger Test conclusion drawn from documentation and never run (docs/stranger-test.md has the correction). It surfaced here because the gate fired on four drivers the moment their notices went through gs::io, and obeying it would have granted net-stack the authority to type commands (SEC-2). The gate now checks the true rule, and also refuses a console_push declaration nothing uses.

What gs gained, every piece a wrapper over an SDK call that already existed

AdditionPulled in by
task::sleep_quantum, sleep_us, sleep_tickssleep(1) everywhere; dwc2's measured split-transaction timing
driver::wait::ticks, ticks_per_10ms; Since::elapsed_ms, elapsed_tickstick-domain code: key repeat, console's adaptive paint, TCP timers, benchmarks
driver::irq::Irq::vectorxhci logs and compares its MSI vector
call::request_onceblock-driver and nic-driver, which own their retry and log whether the reacquire worked
Error::PeerDiedthe request arrived and the peer died: may have happened, reacquire before asking again
ipc::exactthe conformance probes and chaos's flood, which must tell EndpointDead from a stale capability
impl From<CapHandle> for Caphandles from SDK calls gs does not wrap (spawn, mint); the type never made a capability unforgeable - the kernel's check on every use does
#[inline(always)] on the receivesa Message is 4 KiB by value; the shell's stack is tight on pipe paths

The gate's table also turned out narrower than the library - console writes (gs::io, 827 of them in the shell), trace_as, metric, resource_revoke, last_recv_badge and send_peer_handle all had gs equivalents and were not counted. They are counted and converted.

What is still SDK, and why

Recorded as evidence, not as a plan (26.2):

  1. Hardware: Mmio, Dma, device_power, cpu_clock. gs::driver gaps (docs/driver-library.md). (Note 2026-10-22: no longer true of the first three. Mmio and Dma come through gs::driver::registers and arena (step 3) and device_power through gs::driver::power (step 5), and the one-way ratchet counts the SDK calls. cpu_clock is the power service's, not a driver's, and is still the SDK's.)
  2. Spawning and the supervisor's name map, which flows into spawn_with_caps.
  3. The delegated-resource invoke (resource_invoke + recv_abortable_deadline) in the shell's fcap and examples/holder. gs::resource::invoke exists and is better, but pub(crate).
  4. Request forms with no gs shape: the sifted and millisecond-bounded requests net-stack uses while it must keep client requests that arrive mid-wait, and the _into forms fs and the supervisor use to keep a second 4 KiB frame off their stacks. Section 20's gap, from the other side.
  5. Two drivers' wait loops (xhci, dwc2): call sites moved, structure did not. Irq::wait is the destination, one hardware card per board. (Note 2026-10-25: reached. Since 2026-10-22 the wait of xhci, dwc2 and ehci is Irq::wait, docs/driver-library.md step 4.)

Small differences, all deliberate

Since::passed is >= where three checks were >, one tick. On an UNCALIBRATED machine two shell lines that printed a nonsense duration now do not, and a dwc2 pass budget drains nothing where it drained one tick's worth. Two log lines print gs::Error names (Busy) where they printed IpcError names (QueueFull). asker and counter used an unbounded request and now have deadlines (26.6).

Validated on

QEMU, 2026-10-09, on the branch as committed. x86: identity 24/24; the embedded selfcheck 539/0, twice in one boot (osdev test script); shell 246/0; files 245/0; examples 11/0; reply-server, reply-dead (HANG woke with no reply after 129 ms (the service died before answering ...)), counter, resource-server, file-cap 15/0, fs-restart 11/0, trace 10/10, adv 15/15, chaos 8/8, property 10/10. RISC-V virt with a USB stick behind xHCI - the block-driver -> xhci path this work changed - selfcheck 541/0, 4 skipped. Pi 4 (raspi4b, which emulates no disk): every one of selfcheck's 241 checks that does not need storage passed. Pi 2 (raspi2b): chaos max-carnage all-services to round 18 of 20 inside the run's window, supervisor respawned 8 times, no kernel panic.

osdev test trace failed 3 checks here and on unmodified main alike - a harness race, fixed in the same branch (the test now syncs on a prompt it asked for).

Hardware: not yet. Owed on each of the five boards: boot, selfcheck, and a chaos soak, because four drivers' waits and replies changed call shape even though their timing did not. (Note 2026-10-25: this work merged in v0.23.0, and every board has since booted and run hardware cards on builds that contain it - for one, backlog/fixed/80's cards of 2026-10-10 and 11.)

The standard library brief

The requirement as written by the operator, kept as the statement of intent for feat/stdlib.

Lightly annotated: the prose is theirs, and every > STATUS block is added afterwards to say what has since been decided or built, so reading this on a phone tells you where things stand rather than only where they started. Nothing in the original has been softened - where the work diverged, the block says so.

The design report that answers this is stdlib-design.md.


Context

GodspeedOS is approaching v1. The next major goal is to make Godspeed pleasant for programmers to build applications and utilities for.

The standard library should initially live inside the godspeed-os repository because its API is being designed alongside the Godspeed v1 application ABI, service contracts, runtime, capabilities and existing utilities. Do not create a separate repository at this stage.

GodspeedOS is NOT POSIX. Do not introduce POSIX semantics, libc assumptions, Unix compatibility layers, or Linux abstractions merely because they are familiar. Godspeed has its own architecture: MISCIS kernel responsibilities, capability-based authority, no ambient authority, IPC-based services, restartable services, userspace filesystem/network/device functionality, architecture-neutral interfaces, and a let-it-fail and recovery philosophy.

The standard library must make this architecture EASY TO USE. It must not hide or contradict it.

STATUS: stdlib/rust is a crate inside this repository, depending on sdk/rust. No separate repo, no POSIX layer, no libc.

Primary goal

A programmer should be able to write a small Godspeed utility without knowing how to manually discover services, construct IPC messages, decode IPC replies, manipulate capability tables, understand supervisor internals, or know architecture-specific details.

The desired experience is approximately:

use godspeed::{self as gs};

fn main() -> Result<(), godspeed::Error> {
    let contents = fs::read_to_string("/data/hello.txt")?;
    println!("{contents}");
    Ok(())
}

This is an ILLUSTRATIVE API. Do not blindly implement it without first inspecting the repository and determining what abstractions naturally fit Godspeed.

STATUS: two parts of that snippet cannot exist, and the inspection is why.

  • fn main() has no meaning yet. Every runnable thing is a service entered at service_main(ctx) -> !, all 15 examples return !, and of the syscalls (numbered 0 to 55 as of 2026-10-09; this said 52 when written) the only one that ends a task is Kill - gated behind service_control, which no application should hold. This is the STOP condition the brief names below, and it is open: stdlib-design.md §1 sets out three options and recommends adding an Exit(status) syscall as its own reviewed change. (2026-10-25: closed. Exit (syscall 57) was added on 2026-10-17, CLAUDE.md 14.5, and a program that ends itself is written on gs::program.)
  • read_to_string cannot return an allocated String: there is no heap, deliberately (§26.6.1). The shipped shape is read_into(path, &mut buf) - the caller owns the buffer and the bound is readable in the source.

The architectural rule

The developer expresses WHAT they want. The library handles HOW that is represented through Godspeed's mechanisms. However:

THE STANDARD LIBRARY MUST NOT INVENT CERTAINTY OR AUTHORITY.

It may hide plumbing. It must not hide semantics needed to reason about authority, failure, cancellation, service restart, or operation outcome.

Phase 1: inspect before implementing

Inspect the repository first: SDK/runtime crates, startup, utility crates, console/filesystem/ networking/IPC/capability APIs, service discovery, allocator and runtime support, panic setup, error representations, architecture-specific dependencies, and existing public interfaces that can be reused. Inspect several existing utilities and determine what boilerplate is repeatedly implemented today.

DO NOT WRITE CODE YET. Produce a short design report first.

STATUS: done, and it found three things worth naming. The fs wire protocol is hand-copied between crates (FS_OK declared independently in four; the shell re-declares 21 of the filesystem's 27 opcodes). The honest failure model ALREADY EXISTS as DeadlineOutcome, but sits behind eight request_with_reply* variants whose shortest names return Option and collapse "never sent" into "unknown" - five services reached for a longer name to undo that. And every client re-implements reacquire-and-retry.

Phase 2: the minimum useful surface

Propose the smallest useful v1. Potential areas: console, io, fs, net, task, time, cap. These names are suggestions only. Do not create empty modules. Every abstraction must correspond to a demonstrated application need. Prefer small stable primitives plus composable helpers over a large speculative framework.

STATUS: shipped - 13 PUBLIC MODULES: addr, call, cap, error, file, fs, io, ipc, net, record, resource, task, trace. (This listed seven plus "a private resource"; resource is public now, and file/ipc/task/trace/record all arrived after it was written.) 164 public items across those 13 modules, 112 of them pub fn (recounted 2026-10-09 by the rule README.md states - free functions, constants, types, the methods on those types, and every re-export - applied to each module's own file; the crate root's re-exports of Error and ServiceContext and its prelude are not counted again). This said 101 and 149 on 2026-09-26; the same count on that tree gives 151, so two of the difference are method, and +13 is the surface growing since (call::request_once, task::sleep_quantum/sleep_us/sleep_ticks, ipc::reply, recv_within_ms, the ipc::exact module and two error-type re-exports). The 13 EXCLUDES a 14th public module, driver: the gs::driver tier (wait, delay, irq, 33 items) is counted separately because backlog/71 puts it outside the first covered surface. (2026-10-25: four more public modules have since arrived, memory, program, app and handover - 17 beside driver; the item counts above are the 2026-10-09 ones and were not recounted.) cap was recorded as "not built" for a day and then built: the blocker was that the file-capability protocol carried no correlation tag, which is a protocol property and was fixed in userspace. task SHIPPED, and carries the clock surface that answers "time": uptime_secs, epoch_secs_monotonic, datetime, clock_source, clock_is_set. This said both were left out for want of repeated plumbing - true when written, and the plumbing turned up.

Rust model

Do not make complete Rust std support a prerequisite. The model may use core, alloc, the Godspeed runtime, and this library. Do not distort Godspeed semantics to resemble Rust std.

STATUS: core only. alloc is not used, because there is no heap.

Capability rules

Capabilities remain explicit authority. The library may make them convenient. It must NEVER silently create or broaden authority. An application operates using capabilities granted by its launch environment. Prefer typed handles so programmers need not manipulate raw capability table entries.

The distinction must remain: requested resource != granted authority.

STATUS: held, and it is the design's load-bearing line. Every entry point takes &ServiceContext. fs::Fs::new(&ctx) grants nothing: a task whose contract lacks ipc_send = ["fs"] gets the same handle and every call fails with Unreachable. examples/stdlib-hello's contract exists to make that visible. There is no global and no print! that finds a stdout on its own - that would be ambient authority wearing a familiar name.

Failure semantics

Godspeed services are restartable. Handle it honestly. Do NOT blindly retry a non-idempotent request whose service died before acknowledgement: it may have committed, not committed, or be unknown. Investigate whether the system already represents these states. Do not invent a parallel failure model if one already exists.

STATUS: it did exist, and it was not reinvented. DeadlineOutcome { Reply, SendFailed, QueueFull, Timeout } is carried through, as Ok, Unreachable, Busy and OutcomeUnknown, plus one outcome it folds away: a request that ARRIVED and whose replier then died (the kernel's ReplyDead) is Error::PeerDied since 2026-10-09 (stdlib-design.md section 25 - before that gs::call re-sent it). Error::retry_is_safe() is the answer in one call, and it returns false for OutcomeUnknown and PeerDied on purpose - a delete that timed out, or whose service died holding it, may have deleted, and re-sending is a second delete whose failure looks like success.

Portability

Standard-library code must be architecture neutral. Do not use #[cfg(target_arch = ...)] to work around architectural differences. Adding the library must not regress the existing portability hardening.

STATUS: zero arch-conditional sites added. shared_surface_check green.

Kernel rule

DO NOT modify the kernel merely to make the library convenient. If an apparently convenient API requires a new kernel responsibility, STOP, document the missing mechanism, and explain why it appears necessary before making any kernel change.

STATUS: no kernel change, and one mechanism documented as missing - the terminating task. stdlib-design.md §1 argues it is probably not a seventh MISCIS responsibility (the reclaim, generation bump and death notification all exist; what is missing is a task saying "I am finished, here is my status") but it is a kernel change either way, so it waits for review. (2026-10-25: reviewed and built - Exit, syscall 57, 2026-10-17, CLAUDE.md 14.5.)

Unsafe rule

Do not introduce unsafe into services or ordinary abstractions for convenience. Keep any genuinely required low-level code in the smallest existing trusted boundary. Do not weaken existing #![deny(unsafe_code)] guarantees.

STATUS: zero unsafe in the library, and it needs no #[allow] at all - unlike a service, it exports no #[no_mangle] entry symbol.

Dogfooding

After implementing, migrate a SMALL NUMBER of existing utilities: at least one console-oriented, one filesystem-oriented, and one network-oriented. Use only public interfaces where practical. Do not give built-in utilities private shortcuts. The migration is part of the test. If utilities still require substantial raw IPC plumbing, identify what abstraction is missing - do NOT respond by creating giant convenience APIs.

STATUS: services/recorder migrated (filesystem and console). 680 lines to 627; raw fs/IPC plumbing from 22 sites to 2; its own filesystem call helper from 55 lines to 15 and, more importantly, from bool to Result - it used to discard the difference between a write that timed out and one that never left. The migration also DROVE the API: recorder needed create_sized, write_at and rename, which were added as typed operations rather than behind an opcode escape hatch.

Five migrations now, and each one found something review had not: recorder (filesystem and console), the shell's tcp (which exposed a lossy merge of the user's own abort into "outcome unknown"), dir (which taught the LIBRARY about page caps and partial listings), fcap (which caught the library reading a reply one byte off), and sock (which found a report that had been claiming a response nobody sent, and two deadlines shorter than the service they waited on).

And then all of them (2026-10-09): every service moved onto gs - 1085 raw SDK calls with a gs equivalent, in 28 crates, to zero, held there by scripts/one_way_check.py (stdlib-design.md section 25).

First program test

A programmer unfamiliar with Godspeed should eventually be able to write a small utility without understanding MISCIS first. MISCIS explains why the OS behaves as it does; it should not be prerequisite knowledge for printing text, reading a file or opening a connection. Create at least one minimal example application.

STATUS: examples/stdlib-hello - read a file, print it, handle the failures honestly. Its contract is the interesting half.

The architectural test

For each proposed abstraction: (1) what repeated plumbing does it remove? (2) what mechanism does it wrap? (3) does it preserve explicit authority? (4) honest failure semantics? (5) architecture neutral? (6) can the backing service restart without the abstraction lying? (7) does it belong in a standard library rather than a higher-level package? If (7) is unclear, leave it out for now.

Testing

Test the public APIs: success, invalid input, missing capability, service unavailable, service restart, cancellation, malformed response, unknown outcome. Existing selfcheck/chaos infrastructure must continue to pass. Do not reduce existing coverage or weaken assertions to make the new library pass.

STATUS: partial, and the gap is stated rather than hidden. Four host unit tests cover the error model, including that retry_is_safe is false for OutcomeUnknown. Host coverage stops there because godspeed_sdk owns the panic_handler and so does std, so a dependent crate's host test build hits duplicate lang item; the pure/SDK split follows the pattern kernel/src/clock.rs documents. The target-side tests exist now. osdev test fs-reuse holds a gs::file::File (then gs::cap::File) across a real fs kill and asserts the stale capability is refused with a NAMED error rather than a hang, a silent success or a wrong answer (12 cases, was 8). osdev test files exercises gs::fs across a real chaos kill-storm fs 2 and now requires a real listing rather than merely the absence of an error string - the guard it replaced passed on silence. osdev test file-cap covers gs::cap end to end (15 cases, was 13).

Writing them found two things: Error::service_answered() can never be true for a stale capability, because the kernel refuses it before the owning service is reached; and the library was reporting a REVOKED capability as "could not be reached", which named the wrong fault. Error:: Revoked exists because of that test.

Documentation

Document public APIs from the programmer's perspective: what the operation does, what authority it requires, what failures it can return, whether it may block, whether cancellation is supported, and what happens if the backing service restarts. Do not require reading kernel source.

Non-goals

Not porting glibc, not POSIX, not a Unix compatibility layer, not the complete Rust std, not a package manager, not the Godspeed CLI, not a marketplace, not redesigning MISCIS, not adding kernel functionality for convenience, not a giant application framework.

Future relationship with the Godspeed CLI

A separate host-side godspeed CLI is planned later (new, build, test, run, deploy). This library must NOT depend on it. godspeed-os defines the platform and public contracts; godspeed-cli will later consume them. Do not implement the CLI as part of this task.

v1 intent

v1 represents architectural stability, not feature completeness. Future versions may add a display server, mouse support, more drivers, more architectures, graphical applications, laptop support, Vigil, and package distribution - and should build on the v1 foundation without redesigning it. Be conservative about declaring APIs stable. The initial library should be small enough that we understand the semantics of what we commit to.

Deliverable process

  1. Inspect. 2. Design report. 3. Identify repeated boilerplate. 4. Propose minimal structure.
  2. Identify which contracts each API wraps. 6. Identify risks. 7. WAIT FOR REVIEW. 8. Implement the first slice only after approval. 9. Migrate representative utilities. 10. Run existing tests plus new ones. 11. Report architectural friction instead of working around it.

STATUS on 11, because it happened. Moving reacquisition into gs::call made it invisible to the IX-peer-reacquire Commandment check, which looks for the API in the service's own source - so it correctly reported recorder as having no recovery path. Baselining an exemption would have been the wrong answer. Instead reacquire_api now credits the library, AND a new IX-stdlib-delegates check asserts the library really does reacquire, on SendFailed and not on a deadline. Without that second half the property decays from "this service can recover" into "this service calls something that used to". Commandments 20 to 21 checks, red-team probes 73 to 75.

Success criteria

This succeeds when a Godspeed programmer can focus on their program rather than Godspeed's plumbing, while capabilities remain explicit, failures remain honest, restart semantics remain visible where necessary, architecture neutrality remains intact, MISCIS remains unchanged, and unsafe boundaries do not expand casually.

The desired outcome is not "Godspeed looks like Unix", nor "Godspeed looks exactly like Rust std". It is: "Godspeed is simple to program for because its own architecture has a small, coherent and honest developer interface."

Final rule

Do not make Godspeed easier to use by making Godspeed less Godspeed. Remove repetitive plumbing. Preserve the architecture.

The services, and how they reach each other

Everything above the kernel is a service: an isolated address space, a contract, a capability table, and one receive endpoint. Nothing here shares memory with anything else. Every arrow on every diagram below is an IPC endpoint - a message copied by the kernel from one address space into another, under a capability that had to be granted (§8, §7).

This page is the map. It is written from the supervisor's own IMAGES table, which is the single place a service's name, image, core, peers, privileges and device class are declared. The rows for the worked examples under examples/, which the test suites start, are left out.


How a connection is made at all

A service does not "find" another service. It is wired to it, or it asks for it by name and the kernel mints a capability - and nothing else works.

   SPAWN TIME (the supervisor wires it)
   ┌────────────┐                              ┌────────────┐
   │ supervisor │──Spawn(image, peers:["fs"])─▶│   kernel   │
   └────────────┘                              └─────┬──────┘
                                                     │ mints a SEND cap to
                                                     │ fs's endpoint, into
                                                     ▼ the new task's table
                                               ┌────────────┐
                                               │   shell    │ holds cap → fs
                                               └────────────┘

   LATER (fs restarts; the shell's cap is now stale)
   ┌────────────┐   send ──▶ EndpointDead      ┌────────────┐
   │   shell    │──AcquireSendCap("fs")───────▶│   kernel   │ name directory
   └────────────┘   ◀── fresh cap, new gen ────└────────────┘ fs → EndpointId

Two properties follow, and they are the reason the rest of this page is shaped the way it is:

  • Identity is stable, location is not (invariant 11). A restarted fs is a different task on a possibly different core with a new endpoint generation. Clients see EndpointDead, reacquire by name, and carry on. Nobody tracks where anything runs.
  • Authority is explicit (invariant 1, 3). A service can only reach a peer it was granted, or one it was spawned declaring (the peer list in its spawn request, not its contract) and reacquired by name. ACQUIRE_ANY lifts that for the operator instruments - the shell, the supervisor, the probes. There is no ambient directory to browse.

The whole system

Arrows point from the caller to the service it sends to.

                       ┌──────────────┐
                       │    kernel    │  spawns exactly one thing
                       └──────┬───────┘
                              │
                       ┌──────▼───────┐
             ┌─────────│  supervisor  │─────────┐  spawns everything else,
             │         └──────┬───────┘         │  restarts what dies
             │                │                 │
     ┌───────▼──────┐  ┌──────▼──────┐   ┌──────▼──────┐
     │   control    │  │    shell    │   │   events    │
     │ (COM2 ops)   │  │  (the user) │   │             │
     └──────────────┘  └──┬───┬───┬──┘   └─────────────┘
                          │   │   │
        ┌─────────────────┘   │   └──────────────┐
        │                     │                  │
  ┌─────▼─────┐         ┌─────▼─────┐      ┌─────▼─────┐
  │    fs     │         │  console  │      │   time    │
  └─────┬─────┘         │ (display) │      └──┬────────┘
        │               └───────────┘         │
  ┌─────▼────────┐                      ┌─────▼──────┐
  │ block-driver │                      │ net-stack  │
  └─────┬────────┘                      └─────┬──────┘
        │  (USB disk)                         │
  ┌─────▼────────┐                      ┌─────▼──────┐
  │ dwc2 / xhci  │                      │ nic-driver │
  │  USB host    │                      └─────┬──────┘
  └──────────────┘                            │ (Pi 2: through USB)
                                        ┌─────▼──────┐
                                        │   dwc2     │
                                        └────────────┘

On x86 block-driver talks to an AHCI controller through its own MMIO capability and has no USB peer at all; on the Pi 2, the Pi 4 and the VisionFive 2 the disk is a USB device, so it goes through the USB host service. Same service, same block protocol, different peer - which is the point of naming peers rather than devices.


The services

supervisor - restart authority and the image catalogue

   kernel ──spawn (the ONE direct spawn)──▶ supervisor
                                              │
        ┌─────────────────────────────────────┼──────────────────────────┐
        │ IMAGES[]: name, ELF, core, peers,   │  death notification      │
        │           privileges, device class  │  ◀───────────────────────┤
        ▼                                     ▼                          │
     Spawn(image, class, bdf) ──▶ kernel   respawn ──▶ kernel ───────────┘

Trusted, and restartable: when it dies the kernel respawns it, unconditionally and forever, and it reconciles by adopting the services still running rather than duplicating them. The only unkillable thing in the system is the kernel (§6.2, §6.3).

Peers: none. Everything reaches it, not the other way round.

shell - the user's interface, and a capability broker

   keyboard ──▶ kernel console ring ──▶ shell ──▶ console  (what you see)
                                          │
                     ┌────────────────────┼──────────────┬──────────┐
                     ▼                    ▼              ▼          ▼
                    fs              block-driver       time     supervisor
              (files, pipes)         (drives)        (clock)    (spawn/kill)

There is no stdin and no fork. A pipe A | B is the shell carrying each stage's output to the next: in-process between built-ins, and for a service stage a SEND cap to the shell's own endpoint, granted at spawn (Appendix D.3, docs/pipes.md). A killed shell respawns as a fresh prompt - the in-flight command is lost, the session is not.

Peers: fs, block-driver, time, console, events, supervisor.

fs - the filesystem, and files as capabilities

   client ──Open("/doc.txt", READ)──▶ fs ──resource_mint──▶ kernel
          ◀──────── a real kernel capability ──────────────┘
                                     │
   client ──send ON THE FILE CAP──▶ kernel ──badged (resource_id, right)──▶ fs
                                     │
                                     ▼
                              block-driver (512-byte blocks, tagged req/reply)

A file is a capability (§7.10): unforgeable, non-escalating, revocable by generation bump. fs commits metadata through a crash-consistent redo journal, so its death is a restart rather than a reboot - which is what took it out of the trusted computing base.

Peers: block-driver, events.

block-driver - blocks, and nothing above them

   fs ──[op, lba, data?]──▶ block-driver ──▶ AHCI  (x86: own MMIO cap + DMA)
      ◀──[status, data?]───              └──▶ dwc2 / xhci  (USB: over IPC)

It knows sectors, not files. Where the disk is a USB device it sits behind the USB host service, so a peer restart makes its capacity temporarily unknowable - and unknowable is answered with an error, never with "no disk". Publishing zero sectors during a peer restart is how a filesystem gets mounted against nothing.

Peers: events, plus the USB host where the disk is on USB - dwc2 on the Pi 2, xhci on the Pi 4 and the VisionFive 2.

xhci / ehci / dwc2 - the USB host controllers

   kernel ──MMIO cap + DMA arena + IRQ route──▶ xhci
                                                 │
                  ┌──────────────────────────────┼────────────────┐
                  ▼                              ▼                ▼
             keyboard ──CONSOLE_PUSH──▶ shell   hub          mass storage
                                                              │
                                                    block-driver ◀┘

Ring-0 code parsing descriptors supplied by whatever was plugged in is exactly what §4.4 forbids, so these are ordinary services holding only what their contract grants. They cannot reboot the machine

  • REBOOT lives with the shell alone (SEC-2). Where an IOMMU confines one, a compromise is bounded to its granted DMA arena and it leaves the trusted computing base (§6.4). Today that is xhci on x86 alone: ehci runs in IOMMU passthrough even where there is one, and the Pis and the VisionFive have no IOMMU at all, so there dwc2 and xhci can point their DMA anywhere and stay in the trusted computing base.

A host also serves an installed USB driver package: when a device the package names by vendor and product is plugged in, the host binds it and carries the package's transfers to it (gs::driver::bus), so the package needs no hardware authority of its own (§14.1, the 2026-10-23 amendment).

nic-driver and net-stack - the network

   net-stack ──frames──▶ nic-driver ──▶ e1000 / RTL8168 (x86, own MMIO cap)
       │                            └──▶ dwc2 (Pi 2, USB ethernet)
       │                            └──▶ GENET (Pi 4, on the SoC; the radio when the cable is out)
       │                            └──▶ dwmac (VisionFive 2, on the SoC)
       ▼
   ARP · IPv4 · ICMP · UDP · DHCP · DNS · TCP
       │
       └──▶ a socket is a capability (the same mechanism as a file)

Where a board has a radio - wifi-driver (Pi 4, VisionFive 2) or wifi-usb (a USB dongle) - nic-driver bridges frames to it when the cable is out (not on QEMU's e1000, which has no bridge), and the cable always wins.

The kernel gains nothing from networking: it routes messages, and a socket is a delegated resource capability owned by net-stack. There is no ambient network any more than there is an ambient filesystem.

The clock is not the network's: net-stack never calls the clock service, which fetches its own NTP answer through net-stack's op 12, a UDP datagram answered when the reply arrives.

Peers: net-stack → nic-driver.

console - the terminal

   any service ──text──▶ console ──▶ framebuffer (grid, cursor, scroll, ANSI/CSI)
                                       ▲
   kernel bootcon ────────────────────┘  boot + panic only, then hands over

1,172 lines of terminal emulation that used to live in the kernel. What the kernel kept is a blit that cannot format, position or scroll - because a panic halts every core including this service, so it cannot ask a service to report it (§11.4).

Peers: events only - everything else writes to it.

wifi-driver - the radio

   shell (wifi) ──▶ wifi-driver ──▶ SDIO host ──▶ the radio chip (its own firmware, uploaded at start)
   nic-driver   ──▶ wifi-driver     (frames, once the cable is out - the cable always wins)
                      ├──▶ fs     (/system/shared/wifi/keys: the keys a join earned)
                      └──▶ power  (a lease on the Arm clock while it loads the chip)

The Pi 4's onboard radio, and the VisionFive 2 Lite's AIC8800 (docs/wifi-aic8800.md: scan, WPA2 join, DHCP and ping over it, the cable taking the link back). The chip-independent half - the handshake, the key file, the wire protocol, the SDIO protocol and the Station a serve loop drives - is a library, sdk/wifi, shared with the radios to come. A respawn adopts a firmware still running; a stopped one is power-cycled cold.

Peers: fs, power.

wifi-usb - a USB WiFi dongle

   wifi-usb ──usbfn──▶ USB host (dwc2 or xhci) ──▶ the dongle it bound (a Realtek RTL8188CUS)

The driver for a WiFi dongle, separate from wifi-driver so a board with an onboard radio can run both. It holds no hardware: the USB host that enumerated the dongle binds it as the radio and answers godspeed_wifi::usbfn for that one device - who it is, its control transfers and its frames. Behind dwc2 on the Pi 2 and xhci everywhere else, it scans, joins WPA2 and carries the machine's traffic through nic-driver when the cable is out; the supervisor starts it when the host reports the dongle and stops it when the dongle leaves (docs/wifi-usb.md).

Peers: its USB host (dwc2 or xhci), and fs for /system/shared/wifi/keys.

power - the machine's power policy

   wifi-driver ──lease──▶ power ──CpuClock──▶ kernel ──▶ firmware mailbox (the Arm clock)

Holds CPU_CLOCK alone. Leases of up to 30 s keep the Arm clock at its maximum while any is open; a lease nobody returns expires on its own, so a holder that dies cannot pin the machine fast. Without a lease a governor sets the clock by load, as Raspberry Pi OS's ondemand does: every 100 ms, a core over 50% busy holds it at its maximum for 5 s. It reads each core's counters with INTROSPECT, and nothing more. It also gives a shutdown's or a reboot's warnings and its tidy-up three seconds before the deadline, reading the deadline the kernel holds; it cannot shut the machine down itself - only the shell can.

Peers: fs and copier, for a shutdown's or a reboot's tidy-up only; the leases still only answer.

audio-driver - sound

   HD Audio controller ──MSI──▶ audio-driver ──▶ CORB/RIRB ──▶ codec (power, amps, pin, converter)
                                     │
                                     └──▶ a ring of sound in its DMA arena, refilled per period

Intel High Definition Audio, x86 only, heard on the Dell Wyse 5070 and the HP T630, speaker and headphones on both. The stream interrupts as each period of the ring is played and the driver refills it then, where the controller has MSI; where it has none (the T630) the driver polls. Its DMA is confined behind the IOMMU where there is one. On its death the kernel stops the controller's DMA and releases the confinement, keyed on the device it was given rather than its name.

Peers: fs, for its settings file /system/services/audio-driver/settings.

pwm-audio - sound on the Pis

   shell (audio) ──▶ pwm-audio ──▶ DMA engine ──▶ PWM FIFO ──▶ the 3.5 mm jack
                         └──▶ fs (its settings file)

The same protocol as audio-driver, for a different device: the Pis have no codec, so each sample is a PWM duty cycle, moved by the DMA engine as the PWM asks. The kernel routes the jack's pins and starts the PWM clock when it grants the device, because both live in blocks shared with every other pin and clock.

Peers: fs, for its settings file /system/services/pwm-audio/settings.

time - the wall clock

   shell ──▶ time ──▶ net-stack (op 12: one NTP datagram, answered when it arrives)
                 └──▶ fs (persist a clock floor across boots)

Peers: fs, net-stack.

events - the observability sink

Logging does NOT go through it, and that is the design. ctx.log() is a syscall: it writes the kernel's 16 KiB ring buffer and the serial console DIRECTLY, for every service, for the life of the machine. So a chaos storm that kills this service loses no log output, and ctx.log() never blocks on it and never fails because of it.

   ctx.log()  ──syscall 5──▶  kernel ring + serial          (always, never via events)
                     └──────▶  events  (a best-effort COPY, offered after the fact)

   gs::trace::metric() / IPC trace ──▶ events ──▶ three VOLATILE stores:
                                                    192-event IPC trace ring
                                                    64-slot metric table, keyed (owner, name)
                                                    8 KiB log window

That ordering is the whole point and must not be inverted: re-pointing logs AT this service would make observing a failure depend on a service that can fail.

Everything it holds is volatile and starts empty after a restart - a re-init, not a resume. The publishers are what make that work: they resolve lazily, ask the kernel for a cap when they hold none, retry a sample after reacquiring, and republish their liveness row on the first message after the sink returns.

Read it with: events ipc, events metrics, events log, events status. Peers: thirteen services declare it as a send peer; it publishes to nobody.

recorder - the capture that outlives the screen

Spawned on demand by events persist start, never at boot, and deliberately not restarted.

Writing to disk blocks, and a blocked single-threaded loop stops draining its endpoint - so if events did this itself it would drop the very events worth capturing, on a sick disk, exactly when they matter. recorder has the identical problem and it does not matter, because nothing depends on it: the dependency points the right way.

It is not restarted on purpose. A respawned recorder would not know its target path, so it would be alive and writing nothing while status said "running" - worse than dead, because dead is visible. Instead a capture opens with a header and closes with a footer, so a file with no footer says plainly that it died.

Peers: events, fs.

ask, godspeed-pm, copier, chaos, observe - programs started on demand

None of these is started at boot and none is in MANAGED, so nothing restarts them: each is started for one job and ends, or is ended, when the job is done.

  • ask answers one question about the machine (ask "is my system healthy?"). The shell starts it, hands it the question and reads back a record table of facts, inferences and unknowns. It only reads: INTROSPECT for the task table, and requests to the supervisor, fs, the storage chain and the radio drivers on this board. A network question starts it under a second row, ask-net, whose peers are net-stack and the radio drivers, because no one row may hold more than six.
  • godspeed-pm is the machine's package manager, started by the shell for one godspeed command. It sends to the shell for the answer, fs for its settings and trust roots, net-stack for the registry's connection, time for the date a certificate is checked against, and the supervisor, which starts and stops an installed package for it. It holds no privilege.
  • copier runs one detached job without owning the prompt - background copy, a tree delete, a check, a scrub - so a long job cannot take the shell down with it. It is not restarted, because a copier that came back would not know what it was copying. It sends to fs, and to the board's audio driver for the "done" sound a finished job makes.
  • chaos is chaos max-carnage's loop. It cannot live in the shell, because the shell is one of its targets. It holds SPAWN, INTROSPECT, SERVICE_CONTROL and ACQUIRE_ANY, and the console's input so that q reaches it, and it never kills itself. Its spawn-storm ammunition, and the victim of chaos mem-pressure, is mem-pressure: it allocates up to its memory limit, checks that a refused allocation stays refused, and waits to be killed, which is how its memory comes back.
  • observe is the live task view: every task's core, state, memory, restarts, queue depth and CPU use. It holds INTROSPECT and nothing else. It runs under three rows, observe, observe-now and observe-live, one binary that each row starts in a different mode.

hw-enumerator - hardware discovery in userspace

   supervisor ──"which device is class 0x0C0330?"──▶ hw-enumerator
              ◀──────────── BDF ─────────────────────    │
                                                    PCI_CFG: read ONE
                                                    config register,
                                                    select+fetch atomic.
                                                    No write. Ever.

It holds the narrowest hardware capability in the system. It cannot write configuration space at all - that would be write access to every BAR of every device on the bus.

Peers: events only - the supervisor asks it, never the other way round.

control - the operator channel

   host ──COM2──▶ control ──▶ supervisor   (KILL / RESTART, for tests and operators)

Peers: supervisor.


What a restart looks like from the outside

The single most important behaviour to understand, because every service depends on it:

   t0   shell ──send──▶ fs                         works
   t1   fs dies. kernel bumps its endpoint generation, drains the queue,
        clears its name from the directory
   t2   shell ──send──▶ EndpointDead               the stale cap fails loudly
   t3   supervisor respawns fs (possibly on a different core), which
        re-registers its name
   t4   shell ──AcquireSendCap("fs")──▶ fresh cap  and carries on

Between t1 and t3 the name does not resolve. A client that treats that window as "the peer is gone forever" rather than "ask again" is the shape of most of the bugs this system has had.

Reacquiring the endpoint is necessary but not sufficient (§14.3). Anything derived from the dead instance - an open-file capability, a socket, a cached capacity - was issued by an instance that no longer exists. It must be re-derived, not remembered.

The utilities

The commands you type. Every one of them is documented here, and the documentation is the source of truth the implementation is held to - utilities/0_conventions.md is the shared contract all of them obey, and a utility that breaks it is a bug in the utility.

Why they look the way they do

A GodspeedOS utility is not a Unix utility with the corners knocked off. The primitives Unix builds on do not exist here: no fork, no exec, no inherited file descriptors, no ambient stdin, no environment inheritance, no signals. What replaces them is capabilities.

   UNIX                              GODSPEEDOS
   ls /data | grep .txt              dir /data | match .txt
   ────────────────────              ──────────────────────
   fork + exec + pipe(2)             the shell carries each stage's output
   fd 1 inherited by the child       on; a SERVICE stage gets an endpoint
   ambient access to /data           `dir` holds a cap to `fs`, or it
                                     cannot read anything at all

Three consequences you will feel immediately:

  • rm -rf / cannot exist. A utility can only touch what it was granted. There is no ambient authority to leak through a fork.
  • Words, not flags. write append /f.txt text, not write -a. A flag is a character to memorise; a word is readable a year later. (0_conventions.md §1.)
  • Every utility answers version and help. Not as a courtesy - as a contract, checked.

The full set

Each links to its full specification: what it is, what it may do, what it may not do, and how it fails.

Files and directories

dirlist a directory
cdchange current location
pwdprint the folder you are in
homego to your home folder
readprint a file's contents
writecreate, overwrite, append, or prepend a file
mkdircreate a directory
copycopy a file or a whole subtree
moverelocate a file
renamerename a file or directory in place
deleteremove a file, directory, or whole subtree
sealfreeze a file's content, permanently (no unseal)
churnhammer the filesystem for N seconds so a power cut lands somewhere
findsearch the tree for a name
treeprint the directory hierarchy
editthe full-screen editor (a bounded piece table - any file size)
fcapopen a file as a real kernel capability
drivesmanage attached disks

Text and records

echowrite text
docsthe manual: what the system is, and what you can rely on
paginateread long output a screenful at a time (pipe stage)
scrollbackread back what has scrolled off the screen (also: PgUp)
matchkeep the lines that match (the grep-equivalent)
counthow many lines, words, and bytes
sortorder the lines
first / lastkeep the first or last N lines
where / select / to / fromtyped record pipelines

The system

statuswhat is running, and where
observethe live full-screen view
capswhat authority a service actually holds
whatiswhat a name is: built-in, script, pipe stage, or service
tracelive kernel state: what is stuck, who can reach whom
eventswhat was recorded: IPC ring, metrics, logs, capture to disk
memmemory
coresthe cores that came up
hardwarethis machine's devices, and what drives each
askplain questions about this machine, answered from what its services know
godspeedthe native package manager: its settings, and the registry reached over verified TLS
downloada file from a web server, saved here: https:// checked, http:// said to be unchecked
spawncapdiagnostic: spawn a service and prove the capability to it routes
uptimehow long since boot
datethe wall clock
audiosound: what is playing, the volume, a test tone
aboutwhat this system is
versionversion, of anything
clearclear the screen

Lifecycle

spawnstart a service
killstop one
restartstop and start, possibly on another core
chaoskill things deliberately, and see what survives
rebootrestart the machine, now or after a delay
poweroffnot provided - and the page says why
shutdownturn the machine off, now or after a delay (hardware-verified on all five boards)
backgroundrun long work as a job, so the prompt stays usable

Networking

netam I on the network?
wifiwhat wireless networks are there, and join one
socka UDP socket as a capability
pingcontinuous ICMP echo
tcpone TCP transaction: connect, send, read, close
serveaccept one TCP connection and echo what arrives

Scripting

runexecute a script of commands
assertverify a result or output (the test verb)
resultthe previous command's result (Ok / Err)
waitwait
repeatrun a command again and again
fmtformat a script to the GodspeedOS standard

The shared contract

Conventionsthe fourteen rules every utility above obeys

One page worth reading even if you skip the rest

poweroff documents a utility that does not exist. It was built, tested, and removed - the firmware's ACPI \_PTS method could not be executed without an AML interpreter, and adding one to reach a power state was not a trade worth making. The page records what was tried, what stopped it, and what would have to change.

That is the house style: a limitation that cannot be closed is written down, never quietly left (§26.7).

Utility Conventions (shared)

Status: Canonical. These rules define how every GodspeedOS utility behaves. They were first written inside 1_observe.md §3 and hoisted here once a second utility existed (per the note in that section - pull the abstraction into existence, do not build a framework speculatively, §26.2).

Each utility has its own numbered doc in this folder (1_observe.md, 2_date.md, …). This file holds only what is common to all of them.


1. The rules

  1. Every utility has help. A bare utility name with no actionable args, or an explicit <util> help, prints usage. With a fresh (non-POSIX) vocabulary, the system must teach its own verbs at the point of use.

  2. Every subcommand has help. <util> <subcommand> help describes that subcommand specifically (e.g. observe now help).

    At every depth, and checked. Below the first word the answer is the block for the word above - wifi debug trace help prints wifi debug's block, which names trace with one line - because a leaf's one line IS its help and a block per leaf would repeat it. Amended 2026-09-29: an audit of every word Tab could reach found 22 first-level words with no help at all (chaos kill-storm help read help as a service name and refused it with the list) and nothing at depth three. The answers live in one place, sub_help in services/shell/src/main.rs - a command with its own per-word help (events, trace, chaos) is reached from there by a delegating arm, so <util> <word> help has exactly one answer whichever way the words arrive. scripts/subcmd_help_check.py reads SUBCMD_FIRST and sub_help and fails on any word Tab offers that answers nothing; it runs on every build.

  3. help is the word - the only form. No flags, no synonyms. There is exactly one way to ask for help: the word help. No -h, no --help, no hidden aliases. A tolerated-but-undocumented synonym would itself be a hidden, unsaid rule - the silent behaviour the system forbids (§26.4, §26.5). -h is simply unknown:, and that response teaches the real word.

    help <word> and docs <word> are not an exception to this, and the distinction is worth stating because it looks like one. <util> help gives one command's detail and remains the only way to ask for that. help dir does something else: it opens the browsable list already scrolled to dir, which is what man actually means - find this in the manual. It exists because the browser LISTS the commands, so reading dir there and typing help dir is an expectation this interface creates; and because the alternative was worse than either reading, since the argument used to be discarded in silence. A word that matches nothing says (no match) rather than sitting at the top looking like a hit.

  4. Subcommands are words, never single-letter flags. observe now, not observe -n. A word means the same thing across every utility; flag letters collide and drift (-n = "now" here, "number" there). This is the ls -Sslah wall-of-letters problem GodspeedOS rejects. Typing economy is a shell-ergonomics concern (completion, history - future), not a reason to abbreviate vocabulary.

  5. Every utility has a version, reported by <util> version. Per utility, not per subcommand - subcommands evolve with their parent and inherit its version.

  6. help output carries the version header. Line 1 of any help/usage output is <util> <version>, so the version is always one keystroke away.

  7. Utilities report raw facts; they do not editorialize. Health verdicts, policy, and recommendations belong to purpose-built utilities (e.g. ask, 60_ask.md), not bolted onto a metrics or info command.

  8. Vocabulary is not POSIX. GodspeedOS has no fork/exec/errno/signals, so the user-facing words do not borrow POSIX names that would imply that heritage. Concretely: the clock's epoch subcommand is date epoch ("seconds since 1970"), not date unix; there is no time clock (in Unix time measures command duration). Pick words that say what the thing is in this system.

  9. Every utility and subcommand tab-completes. The name completes from the command list; each subcommand keyword completes at its position (net a -> net arp, net ver -> net version, ping c -> count). Rule 4 makes words the vocabulary; completion is what makes typing words as cheap as flags. A subcommand that does not complete is one the user cannot discover without reading the source. Wire it in the shell's completion tables (SUBCMD_FIRST / the per-command case) the same commit that adds the subcommand.

    Declare whether your arguments are file paths (same commit). When a token is not a recognized keyword, Tab falls through to file-path completion - it lists the current directory (dir /x<tab>, read /doc<tab>). That is correct only for utilities whose arguments are paths. A utility whose arguments are service names, numbers, or fixed keywords - never paths (chaos, kill, spawn, restart, ping, net, drives, observe, date, uptime, ...) must be added to NO_PATH_CMDS in the shell (services/shell/src/main.rs, beside complete_tab), so Tab at one of its argument positions does nothing instead of offering unrelated files. (The bug this prevents: chaos max-carnage all-services <tab>, landing on the rounds argument, listed the root directory and offered /.gsh_history, where the history then lived.) The default is path completion; opting out is explicit and per-command - a path-taking utility (dir, read, write, mkdir, find, tree, copy, ...) is simply absent from NO_PATH_CMDS and keeps its file completion. So when you add a utility: if its args are paths, do nothing; if they are not, add it to NO_PATH_CMDS.

    version and help complete for free - do not list them. Every utility answers <util> version and <util> help (rules 5-6), so complete_keyword appends them to every command's first-argument completion automatically. Do not add version/help to a SUBCMD_FIRST entry (they are deduped in). A utility that takes no positional first argument (an info command: about, version, mem, uptime, status, ...) has the universal version/help as its only first-arg subcommands - add it to INFO_CMDS (beside NO_PATH_CMDS) so those two complete and Tab never falls through to a filesystem listing. So a new utility is exactly one of: a path command (in neither list), a keyword command with specific subcommands (SUBCMD_FIRST + NO_PATH_CMDS), or an info command (INFO_CMDS). Pick one; version/help come along in every case. 10a. A bare letter is a control ONLY where letters are not text. Full-screen surfaces divide cleanly in two, and the apparent inconsistency between them is forced rather than an oversight:

    • Read-only views - help, docs, paginate, the console's scrollback - take q and Esc to leave, because nothing there is typing and a letter is free to mean something.
    • Typing surfaces - edit - cannot. A bare q must insert the letter q, so leaving and saving need a modifier: ^Q and ^S.

    NAVIGATION IS THE SAME EVERYWHERE, and that is the part that must not drift: arrows move a line, PgUp/PgDn a page, Home/End the ends.

    Name the two keys that work, not the cluster. A status line reading [arrows] claims all four when only up and down do anything, and left/right are free to mean something else later. It reads [up/down].

    In WORDS, because the console cannot draw the symbols. render::cell_for_codepoint passes ASCII through and maps eleven box-drawing characters; everything else becomes a literal ? (deliberately - visible, never silently dropped). So \u2191 and \u2193 would print as ?? on a framebuffer while rendering correctly over serial, which is worse than either. Words also match rule 4: a word means the same thing everywhere, and symbols are what that rule exists to avoid. edit already matches help, docs, paginate and the scrollback view key for key on all five. Somebody "fixing" edit to quit on q would break the only rule that actually distinguishes them, so the rule is written down rather than left to be inferred from two examples that look contradictory.

  10. Nothing traps the user: every wait says [q] quit, by 4 s, and honours it. (Rewritten 2026-10-13 with the operator, backlog/fixed/89, audits/2026-10-13-quit-audit.md.) Any command a person waits on that can run past 2 s says [q] quit - aimed at 2 s, and never later than 4 s on the slowest board, because the shell's clock counts whole seconds and a reply may already be on its way - and q/Q/ESC brings the prompt back within 3 s, saying truthfully what happened: stopped, still running underneath, or outcome unknown. [b] background stands beside it where the work can run detached - a separate program, holding no console authority, whose output fits a transcript - and a command with no [b] says nothing about [b]. So copy <file> <dst>, `delete

    recursive`, `drives check` and `drives scrub`, typed at the prompt, START as the job `background` would start and the console attaches to it: their notice is `[q] quit [b] background`, and `b` leaves them running as job N (`55_background.md`). In a script, a pipe, or while a job is already running they run in the shell and offer `[q]` alone. Waits on the person (a confirm, `input`, a full-screen program with its own quit key) are exempt.

    The shell is the observer. It starts a COMMAND clock at Enter, and every wait the command makes reads it through gs::call::Watch - a gs::fs handle lent .watching(ctx) (the shell's ctx.fs()), gs::call::request_watched, recv_quittable - so the notice is per command, a command made of many fast requests is covered as well as one slow request, and q stops the command at its next request. A script, a wait and selfcheck stop on it too. (tcp, serve and sock use a gs::net handle with its own notice: quittable, on the request's clock rather than the command's.) A wait the watch cannot see is a defect to fix; the few that cannot trap anyone - under 2 s, one pass of a loop that reads q, after a q, or between commands - say so beside them (// quit-ok:), and scripts/quit_wait_check.py refuses any other. osdev test quit measures every kind of wait on an fs built to answer slowly (slow-replies) and writes the table to build/tests/quit_report.md.

    The rule as it read before 2026-10-13, kept as history: Anything that blocks or waits is escapable with q. If a utility can sit waiting - on a peer service, on the network, on a long sweep - then q/Q/ESC MUST abort it and return to the prompt, and a wait of more than a moment advertises [q] quit. A command that can wedge the shell with no way out is forbidden (§26.7: loud + escapable over silent + stuck). The primitive is ServiceContext::request_with_reply_abortable (send once, poll q while waiting); never block an interactive command on a bare request_with_reply to a peer that can be slow. The fs-backed interactive commands (dir/cd/read/find/tree and the file-reading filters match/count/sort) use the delayed-hint variant: a gs::fs::Fs handle lent a notice (Fs::with_notice / Fs::noticing), which waits through gs::call::request_within_notice. Silent on a fast reply, it advertises [q] quit only once the wait lingers past gs::call::NOTICE_AFTER_SECS (2 s) - the just-in-time form of the advertisement above, so a snappy op stays quiet.

    (Note 2026-10-09: of that list, only dir's text listing lends the notice today. cd, read, find, tree and the file-reading filters go through gs::fs::Fs handles built with no notice (fs_stat_r, fs_read_at, filter_read, the walks in cmd_find/cmd_tree), so they are bounded at gs::call::DEFAULT_SECS but cannot be ended with q - a code defect against this rule, recorded in each utility's own spec.)

    (Note 2026-10-12: the advertisement is now AUTOMATIC on the shell's receive waits, at the operator's standing rule - "for every request that takes time, there should automatically be a [q] to quit". A pipe stage's drain (drain_service_with, which ask also goes through) and a file-cap reply wait through recv_quittable in services/shell, which prints [q] quit once nothing has come for gs::call::NOTICE_AFTER_SECS, once per wait. The SDK gives the timing (ServiceContext::recv_abortable_deadline_noticing, the receive-side twin of the request's request_with_reply_qhint) and the shell the words, because a task with no prompt has nothing to print. A new receive wait in the shell gets it by going through that helper. Since 2026-10-13 the request waits that used plain request_with_reply_abortable (fs_request, fs_raw - drives, drives flash, reset, label - and ns_abortable) go through ask_with_quit_notice, and the re-waits after an overtaken reply (fs_take_tagged, ns_take_tagged) through recv_quittable (backlog/84 S6). Not yet covered: the fs handles listed in the note above - covered since 2026-10-13, backlog/fixed/89.)

    *(Note 2026-10-13: and the radio's and audio driver's asks. wifi_ask and audio_ask - every wifi and audio verb, wifi hardware among them, which asks each radio twice and could sit for 12 s silent - wait through ask_sifted_quittable (the SDK's request_with_reply_keyhint_sifted_keeping), and wifi_ask's wait for answers the driver still owes says it too; q there prints stopped by q rather than "not answering". observe now's wait for its frame and date sync say [q] quit as well. Still silent, and why: the waits on a kernel Call

    • recorder and copier (8 to 12 s), time and the ns_deadline path to net-stack (each at its caller's deadline), and the 2 s spawn request to the supervisor. A task blocked in a Call cannot read the console, and the noticing form that can takes whatever reply lands next, which is what the Call is there to prevent: giving them [q] needs a reply-matched wait that returns to the caller between slices, which does not exist yet. Fixed later the same day: all four are under the command's watch (ask_watched, hw_ask), backlog/fixed/89.)*

    Worked example, and the reason "never block on a bare request_with_reply" is written as an absolute. The whole networking surface broke this rule on one helper. ns_request - what tcp, sock and serve's listen all go through - used a bare request_with_reply, which parks the shell INSIDE the syscall where it cannot poll the console. On a Dell Wyse a tcp that net-stack never answered froze the prompt: no echo, no q, and the machine had to be power-cycled. A deadline was not the fix and adding one first was not enough - it made the freeze 20 seconds instead of forever, and 20 seconds of a dead prompt is still a dead prompt. Only polling q while waiting returns the machine to its operator. The events channel (trace_ask) had the same shape and was worse for it, since events failures is the instrument you reach for WHEN something is wedged; an instrument that can hang on the thing it is measuring takes the prompt with it.

    (Superseded 2026-10-13, backlog/fixed/89: these requests now go through ask_sifted_quittable, say [q] quit past 2 s and stop the wait on q. Kept as the record of why they once could not.) Recorded exception: the wifi radio power requests. wifi radio on, wifi radio off, wifi radio off hard and the power request at the start of wifi radio powercycle block in a kernel Call with no q, because a shell blocked in one cannot read the console. Each is BOUNDED at 15 s (MAX_SECS in wifi_radio and wifi_radio_hard_off, OP_MAX_MS in wifi_radio_powercycle), and the usual wait is two to three seconds. The long waits that follow

    • the watch after a restart of the driver - keep q. 56_wifi.md section 2 records this per verb.
  11. Quitting stops the TASK, not just the shell. When a utility is escaped (rule 10), the escape must abort the actual WORK the utility set in motion - not merely stop the shell from waiting on it. If the utility handed a long job to a peer service and the escape only stops the shell listening, the peer keeps grinding and the very next command blocks behind it: the shell looks free but is not. So a command that drives a multi-step peer operation (a sweep, a batch) drives it step-by-step from the shell, so q ends the loop and the peer is only ever mid-ONE step. Worked example: net scan began as one batch net-stack op (sweep all 254 hosts); pressing q unblocked the shell, but net-stack kept sweeping, so a second net scan hung waiting for it. The fix made the shell drive the sweep one host at a time (op 7 -> per-host op 6). Escaping the wait is not enough; escape the work.

  12. Output is a pipeable structure. A utility's result is data, not decoration: a producer emits either a typed record Table (docs/records.md, so | where / | select / | to json compose) or plain labelled lines (so | match / | count compose). Piping is the composition model; output that cannot flow onward is a dead end.

    Which utilities start a pipe, and why the rest do not (counted 2026-10-02 against the shell's UTILS and its three producer lists, is_producer_builtin, is_record_producer and is_pipe_producer_service). Of the shell's utilities (its UTILS list), 28 start a pipe (two, wifi and churn, for some verbs only): the record sources (status, dir, find, caps, drives, observe now, uptime, events, trace, jobs, and wifi list), the text sources (echo, read, tree, input, about, version, whatis, mem, cores, date, net, ping, sock, help, pwd (added 2026-10-13), wifi's other reports, churn verify), and the roster service; plus random and tcp (made sources that day) and the greet example, which are not in UTILS. Seventeen more are pipe STAGES or SINKS rather than sources - match, count, sort, first, last, where, select, to, from, sum, min, max, avg, paginate, write, assert, result - and result/assert refuse to start a pipe with a sentence that names the idiom they belong to. The other 22, each for a reason rather than an omission (the library scripts, serve and gpio are not in UTILS and are listed with them):

    Not a pipe sourceWhy
    run, selfcheckOrchestrators: they run their own sub-pipelines, so capturing one nests a pipe inside a pipe on a 256 KiB stack - a hardware-proven crash. They write their own output with save <path> instead
    health, online, size, busiest (the library)Scripts, run by the interpreter: the same class as run, refused inside another script for the same stack reason. Their contents pipe directly - busiest IS status | sort reverse mem
    docs, scrollback, edit, bare observeFull-screen: a pipe cannot carry a screen you scroll. scrollback save and observe now are the capturable forms
    clear, wait, repeat, cd, home, mkdir, copy, move, rename, delete, seal, spawn, kill, restart, reboot, background, foregroundActions: the value is the effect, and what they print is a confirmation, not data
    wifi scan, join, leave, forget, radio ...Actions; join also reads a passphrase from the console, and piped its prompt would vanish. Refused with a sentence naming the reports
    churn <seconds>, churn tear, churn resetActions; churn verify is the report and pipes
    chaos, fcapExercisers whose value is what they prove while running; chaos writes its report with save
    serve, gpioserve runs until stopped; gpio drives pins - the one read it offers is a single line on the Pi 2 only

    (Note 2026-10-09: the count above is dated. audio and hardware have since joined UTILS and both are in is_producer_builtin, so UTILS now holds 68 names and 29 of them start a pipe; the 17 stages and the 22 non-sources are unchanged.)

    (Note 2026-10-25: recounted. UTILS holds 82 names and 34 of them start a pipe - ask, godspeed (its reports only), audio (its reports only) and hardware joined, and random and tcp are in UTILS now, as are serve, gpio, spawncap and spawnwired. The 17 stages are unchanged; the other 31 are not sources, shutdown and download among the actions.)

  13. If it does not fit the common pipes, write still captures it. Any producer's output snapshots to a file with | write <path> (redirection is | write; there is no >, see 19_write.md). So even a utility that is not a record source is never trapped on screen - its bytes always have somewhere to go.

  14. Multiple same-type targets are a COMMA-separated list, never spaced. kill ehci,xhci,fs, spawn ping,pong, restart fs,events, delete /a,/b, mkdir docs,tmp, fmt a.gsh,b.gsh, chaos max-carnage nic-driver,net-stack - one argument, comma-delimited. NOT spaced (kill ehci xhci fs): the shell tokenizes a line to a small fixed arg count (MAX_ARGS = 8), so a spaced list silently caps at ~3 targets, while a comma-list is a SINGLE token and is not capped by it (a list takes at most 16 items, LIST_MAX; a longer one is refused before anything runs). Comma is also the one uniform rule - the same separator on every command - so the user never has to guess which verb wants which shape. Each target runs the command's normal single-target path (same guards, same per-target report); a failure on one does not abort the rest; the set is bounded. A single target with no comma behaves exactly as before. The keyword all-services (kill only) is the whole system set expressed as one target.

Help output shape (normative)

Every usage row carries both a placeholder signature and a real example - the placeholder teaches the grammar, the example teaches what real input looks like (not everyone knows what <path> should be filled with):

<util> <version> - <one-line description>

usage:
  <util>                        <default behaviour>
      e.g. <real example>
  <util> <subcmd>               <what the subcommand does>
      e.g. <real example>
  <util> version
  <util> help

<util> <subcmd> help prints the same shape for that subcommand, without the version / help footer (help_block in services/shell/src/main.rs).

version output is the name + version number, then the creator credit:

<util> <version>
Copyright (C) 2026 Bankole Ogundero and the GodspeedOS contributors.

2. Built-in vs standalone-service utilities

Two implementation shapes exist, and each utility's doc states which it is:

  • Shell built-in. Runs inside the shell's own protection domain, using caps the shell already holds. Cheap and simple; appropriate for trivial output commands (echo, clear) and read-only info that the shell is already authorised for (mem, cores, about, date, status, caps). The shell's authority is not widened for these - they only read or print.

  • Standalone service brokered by the shell. A separate task the shell spawns, holding a contract-scoped least-authority cap set and nothing more. Used when the utility must not run alongside dangerous authority. observe is the canonical example: it needs only to read metrics, so it is its own service holding an introspection cap - it cannot kill or restart anything by construction, not by being careful (§3.1, §26.9). See 1_observe.md §7.

The dividing question is least authority: if a command would otherwise execute in the same domain as spawn/kill/restart, prefer a standalone service.


2a. Adding a utility: the eight sites, and where its spec lives

Written down because it was learned by failing. Implementing one verb (wifi, 2026-09-27) needed eight registrations in services/shell/src/main.rs, and the two that were missed were reported by two different checkers rather than by any list - which works, but late. The knowledge lived only in the checkers that enforce it.

SiteWhat it gives youWhat fails if you miss it
SUBCMD_FIRSTtab completion of the first argumentrule 9; Tab falls through to a directory listing
SUBCMD_SECONDcompletion one level deeper, where the surface has onerule 9, silently
NO_PATH_CMDSTab offers keywords, not paths, where no argument is a patha path menu for a keyword position
UTILSthe <util> help / <util> version interceptrules 1 and 5
the command dispatchthe verb runs at allnothing runs
the producer dispatch<util> | write <path> captures itrules 12 and 13
a help_block arm<util> help prints somethingcaught by util_help_coverage_problems
a Row in the help listinganybody can DISCOVER the verbcaught by facts_check - "an omission ships the feature to nobody who was not watching it being built"

And where the spec goes, which is not a preference. A spec under utilities/ asserts that the shell answers that verb, and X-user-vocabulary (Commandment X) enforces it in both directions:

  • A spec here for a verb the shell does not answer fails. Implement it, delete it, or document the ABSENCE (14_poweroff.md is the worked example, and the head must say "not provided" or "removed").
  • A verb the shell DOES answer with no spec here also fails - a verb discoverable only by reading the source is complexity pushed onto the user (CLAUDE.md 26.11).

So a surface designed before its verb exists belongs in docs/ and moves here the day the shell answers it. docs/tcp-design.md against 48_tcp.md is that split; docs/wifi.md against 56_wifi.md is the same, and the file moved three times before anyone wrote this paragraph.

Do not reach for utility_vocab_debt to make either direction quiet. A baseline entry instead of a fix is what CONTRIBUTING.md names as weakening a gate.


3. Conformance status (as-built, honest)

As of 2026-06-14, every utility conforms. Each one implements its own <util> help (usage with a real example per row) and <util> version (number + "Copyright (C) 2026 Bankole Ogundero and the GodspeedOS contributors."), and each real subcommand (date epoch, observe now, drives flash / label / reset) has its own <util> <subcmd> help.

The shell built-ins are driven by a single help_block helper (the format lives in one place, so all of them render identically), with <util> help / <util> version intercepted uniformly in the command dispatch; observe (a standalone service) was already spec-first conformant. The earlier gap - built-ins documented only by the top-level help list - is closed.

The last-open item is now closed too: the top-level help command conforms. Bare help is still the categorised command list, but its first line now carries the version header (rule 6: help 0.4.0 - GodspeedOS shell commands), and help help / help version resolve like any other utility's. So every command the shell dispatches - including help itself - self-documents and reports a version.

Utility Spec: observe

Utility: observe - system metrics viewer Version: 0.4.0 (UTIL_VERSION, services/shell/src/main.rs) Status: BUILT and hardware-verified. services/observe/ ships, in three modes from one binary (observe, observe-now, observe-live). Written spec-first, per the utility-spec-first rule; the build-order and "live view coming soon" scaffolding below is history, not a plan - the live view ships (cmd_observe_live) and that string exists nowhere in services/. Last updated: 2026-06-03


1. Purpose

observe answers one question: what is the system doing right now? It surfaces the per-task and per-core metrics the kernel already tracks (state, core, memory, queue depth, restarts, CPU time, uptime) plus a system summary (uptime, per-core CPU%, RAM).

observe reports raw metrics. It does not render a verdict - "is everything OK?" is answered elsewhere: by ask (60_ask.md) and the health library script (see §9). Keeping the two apart is deliberate: observe = what is happening; the verdict is a separate utility's.


2. Invocation surface

CommandMeaning
observeContinuous live-refreshing view. Repaints in place until you press q.
observe nowStatic one-shot frame. Prints once, returns to gsh>.
observe helpUsage for the utility (modes, subcommands, version header).
observe now helpUsage for the static subcommand (one row: print a single metrics frame and return).
observe versionPrints observe 0.4.0.

observe (bare verb) means ongoing observation - that is why the live view is the default and the static one is the modified form. The word after the verb picks the cadence: nothing = ongoing, now = a single instant. No flags, no interval math, no --.

2.1 observe now as a record producer (typed pipes)

Piped, observe now is a record producer (docs/records.md, utilities/31_records.md): it emits the task roster plus the metric status omits - ticks, each task's cumulative run_ticks (timer ticks spent running since boot). That column is what distinguishes observe (how busy) from status (who's alive):

observe now | sort reverse ticks     the native "top": busiest tasks first
observe now | where core=1           tasks on a core, with their cpu-time
observe now | select name ticks      just the cpu-time view
observe now | to json                a snapshot frame as JSON

ticks is a snapshot-honest value - cumulative ticks, not an instantaneous %. A true rate needs two samples, which only the live view has (and the live view's per-task "CPU%" is in fact its core's utilisation, so it would not sort meaningfully per task anyway). Only observe now is pipeable. Bare observe is the live, screen-owning loop - it never yields a discrete stream, so observe | … is a loud refusal (observe: the live view can't be piped - use 'observe now | …'), never a silent hang. This is the general rule: a live-loop utility's snapshot form is the record producer; its live form is not (docs/records.md).

Build-order note: observe now (static) is built first - it needs no kernel or console change. Until the live view exists, bare observe prints live view coming soon - try 'observe now'. observe's meaning never changes later (it is "the live view" from day one); it only stops being unfinished.


3. Utility conventions

The general rules that apply to every GodspeedOS utility - help is the only help form (no -h/--help), subcommands are words not single-letter flags, per-utility version, raw-facts-only (no editorializing), non-POSIX vocabulary - now live in 0_conventions.md. They were hoisted there once a second utility existed, exactly as this section originally anticipated (§26.2: pull the abstraction into existence, don't build it speculatively). observe follows all of them; what remains below is observe's own normative help output.

Help output shape (normative)

observe 0.4.0 - live system metrics view (records when piped)

usage:
  observe                       full-screen live view; q quits
      e.g. observe
  observe now                   one-shot metrics frame
      e.g. observe now
  observe now | <verb>          piped: records + a 'ticks' (cpu-time) column
      e.g. observe now | sort reverse ticks
  observe version
  observe help

4. observe now - static frame (build first)

4.1 Layout

As built (services/observe/src/main.rs, print_state; values illustrative):

--------------------------------- legend ---------------------------------
TASK scheduler slot | NAME service name | CORE cpu core | STATE task state
MEM_USED/LIMIT/% memory in use (binary+stack+alloc) / limit / % of limit
RESTARTS deaths recovered (not clean re-runs) | QUEUE/LIMIT inbound depth / max
CPU% core share since boot | UPTIME since the service last (re)started
------------------------- system state (14 live) -------------------------
UPTIME: 0d 00:12:41
CPU: not measurable from a single snapshot - it is a share of elapsed time, and this is the first sample (run the live view for rates)
RAM: 18 MiB used / 4 GiB total (0%)
TASK NAME             CORE STATE      MEM_USED/LIMIT/%      RESTARTS  QUEUE  CPU%  UPTIME
0    supervisor       C0   BlockRecv   512 KiB/64 MiB/  0%  0         0/16     0%     12m
1    events           C0   BlockRecv    96 KiB/ 8 MiB/  1%  0         0/16     0%     12m
2    shell            C0   Running       2 MiB/64 MiB/  3%  0         0/16     0%     12m

The live view adds a two-line title bar (observe - live ... (q to quit) over a ==== rule), replaces the CPU: line with per-core shares (CPU: C0 38% C1 9% ... total (12%)), and labels CPU% since last snapshot.

4.2 Per-task columns

ColumnSource (TaskStat)Notes
TASKthe slot index 0..223scheduler slot, stable for task lifetime
NAMEname / name_len15 wide; a longer name is cut and marked +
COREcorepinned core (§9.1), shown C<n>
STATEstateReady / Running / BlockRecv / BlockSend / Dead
MEM_USED/LIMIT/%mem_used / mem_limitKiB below 1 MiB, else MiB; % of limit
RESTARTSrestart_countdeaths recovered (§7.5, §14.2)
QUEUEqueue_depthinbound IPC queue depth, 0-16, shown as N/16; ! when full
CPU%run_ticks deltathis task's share of elapsed time; 0 with no baseline (one-shot)
UPTIMEuptime_secssince the service last (re)started, largest unit

Only slots with valid == true are listed, and an observe instance only when it is the one rendering (Running).

4.3 Summary line

FieldSourceNotes
livecount of valid slotsin the system state (N live) rule
UPTIMEgs::task::uptime_secswall clock since boot
CPU: CN%inspect_core_active_ticks(N) delta over elapsed timelive view only; a one-shot has no baseline and says so (see §5.3)
RAM used / totalinspect_kernel_total_frames() - inspect_kernel_free_frames() × 4 KiB, inspect_kernel_total_frames()free >= total prints RAM: ACCOUNTING INCONSISTENT with the raw counts

No kernel changes are required for the static frame - every value above is an existing introspection syscall the shell already has authority to call.


5. observe - continuous view (build second)

5.1 Behaviour

Repaints the §4 frame in place on a fixed cadence (default 1 s) until the user presses q. Any other key is ignored.

5.2 Exit behaviour (decided)

On q: stop refreshing, leave the last frame on screen, print gsh> beneath it. The display does NOT vanish. Rationale: (a) the framebuffer console has no alternate-screen buffer to restore from, so htop-style "restore previous screen" is not a capability that exists; (b) the final frame persisting on the TV is strictly better for hand-transcription - you quit, then read at your own pace, with the prompt right there confirming the shell is back.

5.3 CPU% - cumulative vs instantaneous

The live view shows instantaneous CPU% - the delta in active ticks (per core) and run ticks (per task) between successive frames, as a share of elapsed time - so the numbers reflect current load. It keeps the previous frame's counts on its stack.

The static frame has no previous frame. It says CPU: not measurable from a single snapshot ... instead of a per-core line, and its per-task CPU% column reads 0. (The legend's since boot label for the one-shot is a code defect: no since-boot share is computed.)

5.4 Console prerequisite (kernel/console work - gates the live view)

(Resolved.) The terminal - now the console service (docs/console-service.md 9) - honours ESC[2J (erase display) and ESC[H (cursor home), which is exactly what makes the live view and clear work today. What follows is the original reasoning, kept because it explains the shape of the live loop. The first concrete piece of the live view was therefore a console control (e.g. interpret 0x0C form-feed as clear+home, or an explicit escape), not the metrics. observe then: clear+home → paint frame → poll the console input ring for q for ~1 s → repeat. The q keypress rides the same console input ring that the USB keyboard pushes into (closing the loop with the xHCI work).


6. Data sources (summary)

All present today in sdk/rust/src/service_context.rs:

  • task_stat(slot) -> TaskStat - per-task: valid, state, core, mem_used, mem_limit, name, restart_count, queue_depth, run_ticks, uptime_secs.
  • inspect_core_count(), inspect_core_active_ticks(c), inspect_core_total_ticks(c).
  • inspect_kernel_free_frames(), inspect_kernel_total_frames().
  • gs::task::uptime_secs, and gs::driver::wait::ticks / ticks_per_10ms for the elapsed-time base of the CPU shares.

7. Architecture: standalone utility service (DECIDED 2026-06-03)

observe is a standalone utility service brokered by the shell - NOT a shell built-in.

Rationale (the spirit of GodspeedOS):

  • Least authority - the decisive one. The shell holds spawn/kill/restart authority; it is one of the most dangerous userspace authorities. observe needs only to read metrics. A built-in would run the metrics code in the same protection domain as kill/restart authority. As its own service, observe holds an introspection cap and nothing else - it cannot kill or restart anything by construction, not by being careful (§3.1 no ambient authority, §3.3 authority is explicit, §26.9 authority stays visible and scoped).
  • The shell's role. Appendix B.3 defines the shell as a capability broker: it holds authority and hands scoped caps to children it spawns. Brokering a least-authority observe is the shell doing its job; being observe is the shell stepping out of its role.
  • Isolation & restartability (§2.4, §3.6): observe crashing cannot take down the shell or its authority. Matches the utilities-as-services vision (Appendix D) and reuses the existing observe service rather than duplicating its logic in the shell.

§26.2 (don't build speculative infrastructure) does not argue for a built-in here: the service is not speculative (it already exists on hardware), and the least-authority requirement is real today, not hypothetical.

Cost accepted: more plumbing (spawn-on-demand, console output, and - for the live view - a console-ownership/input handoff for the q keypress) and a spawn-per-invocation (~ms, fine - performance is third, §20).

Introspection capability (resolved 2026-06-03). Introspection is now gated by INTROSPECT_RESOURCE (READ): TaskStat and the system-state InspectKernel queries require it; self-state and the TSC stay ambient. observe is granted the cap at spawn (name-gated, like shell), so its least-authority story is now literal - it holds the introspection cap plus a console cap, never the shell's spawn/kill/restart. Done on branch feat/introspect-cap; see docs/introspection-capability.md.

Note 2026-10-09. "Name-gated" is no longer how the grant is made. The kernel keys no grant on a service name (CLAUDE.md §12.3, 2026-10-03 amendment): the supervisor's spawn table carries INTROSPECT in the privilege word of the observe, observe-now and observe-live rows, and the kernel mints exactly that from the spawn request (§13.6).


8. Capabilities required

observe runs as a standalone service (§7), so its contract declares exactly what it needs and nothing more:

  • an introspection capability (INTROSPECT) - read-only access to the inspect_* / task_stat surface;
  • log_write to render its frame (printing is log_write, not console_push);
  • for the live view, the console flag on its spawn request (SPAWN_FLAG_REQ_CONSOLE); the painter turns echo off while it paints. It reads no input: the shell polls for q and kills the painter.

It does NOT hold spawn/kill/restart - that authority stays with the shell. The shell asks the supervisor to spawn it, and the kernel mints these caps from the supervisor's spawn request, not from the contract (CLAUDE.md §13.6, §14.1).


9. Out of scope / non-goals

  • Health verdicts. ask (60_ask.md) and the health library script answer is-everything-OK; status is the task list (7_status.md).
  • Filtering/sorting/search (top-style P/M sort keys). Not v1.
  • Per-task drill-down (cap list, held resources). A later observe <name> subcommand could add this; not now.
  • Historical/graphing. observe is point-in-time, not a time series.
  • Serial-specific rendering. Output targets the framebuffer console; serial is best-effort (logs may interleave across cores).

10. Open questions

  1. Live-view refresh cadence: fixed 1 s, or observe <interval> to tune it later? (Deferred - now vs bare covers the static/live split; interval tuning is a possible future addition, not v1.)

Resolved

  • Help form (2026-06-03): the word help is the only form - no -h, no --help, no tolerated synonyms. An undocumented synonym would be a hidden, unsaid rule, which the system forbids; -h is simply unknown: and the response teaches the real word (§3 rule 3). Subcommands stay words - no single-letter flags (§3 rule 4).
  • Architecture (2026-06-03): standalone utility service brokered by the shell, not a built-in (§7) - least authority + the shell's broker role.
  • Typing economy (2026-06-03): addressed by future shell ergonomics (tab-completion, command history), NOT by abbreviating the utility vocabulary. Not to be relitigated per-utility.

11. Build order

  1. Console: clear+home control - only needed for the live view; static does not need it. (Gates step 3, not step 2.)
  2. observe now static frame - as the brokered standalone service (§7), §4. The shell spawns observe in now mode; it prints one frame via its introspection + console caps and exits. Wire observe now, observe version, observe help, observe now help, and bare observe → "live view coming soon
    • try 'observe now'". QEMU-verify (screendump).
  3. observe live view - §5, after step 1. Delta CPU%, q-to-quit, persist on exit.

Utility: date

Utility: date - wall-clock date and time Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

date answers what time is it? The clock belongs to the time service, which takes it from the hardware real-time clock where the board has one, from the network (NTP) where it does not, and otherwise from the floor it last recorded on disk; date asks time and says which of those the reading came from.

2. Invocation

CommandMeaning
dateFull timestamp with weekday and its source, e.g. Sat 2026-06-06 23:45:08 (rtc, scale unknown).
date epochSeconds since 1970-01-01, the number only, e.g. 1780789511.
date syncAsk time to fetch the time from the network now, wait for it (bounded at 10 s; past 2 s the shell offers [q] quit, as for every wait), then print the timestamp. If time refuses its NTP setting (4.1), it says so at once. time also syncs on its own.

Default = the human stamp; the word after the verb (epoch) picks the machine form. The subcommand is epoch, not unix - GodspeedOS is not POSIX, so the vocabulary does not borrow that name (0_conventions.md §1 rule 8). epoch says exactly what it is: seconds since the 1970 reference point.

3. Output

gsh> date
Sat 2026-06-06 23:45:08  (rtc, scale unknown)
gsh> date epoch
1780789511

Date is ISO-style YYYY-MM-DD; time is 24-hour HH:MM:SS. The weekday is computed from the date (Howard Hinnant's days_from_civil), not read from the RTC's own weekday register, which is unreliable.

The suffix names the source, and states the scale only where it is known:

  • UTC (ntp, synced <age> ago) - set from the network, which serves UTC.
  • UTC (carried over from the last boot - AT LEAST this late; 'date sync' for the true time) - advanced from the floor on disk on a board with no RTC; a lower bound.
  • (rtc, scale unknown) - the hardware clock, which firmware may keep in local time or UTC.

With no source at all date prints the clock is not set: ..., says whether the network link is up, and shows the last recorded floor (if any) labelled as a floor, not a reading.

4. Data source

  • The time service, over IPC: OP_NOW returns the epoch seconds, the source, the sync age, and whether the NTP setting is refused (byte 18); OP_SYNC (for date sync) asks it to query the network. Each request is bounded at 2 s (time_rpc).
  • time itself reads the RTC through InspectKernel query 11 (packed date/time, ungated - wall-clock time is task-neutral hardware info, like the TSC clock, query 3).
  • SDK: Datetime::from_epoch_secs and Datetime::weekday() do the leap-year-aware arithmetic.

4.1 The NTP server is a setting

/system/services/time/ntp names the server time asks: one IPv4 address, # starting a comment. An address, not a name, because a name would need a DNS lookup first. A missing file is written with the default, 162.159.200.123 (time.cloudflare.com), so the file always shows the server in effect.

gsh> write /system/services/time/ntp 192.168.4.1
gsh> date sync

time reads the file when it starts, and again at every date sync, so an edit takes effect at the next date sync with nothing restarted. A file that does not hold exactly one address - a host name, two addresses, nothing - is refused and its line named in the log, and no network time is asked for until it is fixed: a typo is never quietly replaced by the default. Only with no disk to read at all (no answer from fs within the first ~30 s) is the default asked, and the log says so.

5. Capabilities

  • A send capability to time. No kernel query is made by date itself.
  • log_write to print the line (printing is log_write, not console_push).

6. Non-goals (deliberate - §26.2 minimal surface)

  • No clock-setting. date reads; it never writes the RTC.
  • No format strings. Two fixed forms only - not a formatting mini-language.
  • No timezones. The value assumes the RTC reads UTC; if the hardware clock is local time, date epoch is offset by the timezone. v1 has no timezone database.

This keeps date from sprawling into "a full-blown application."

7. Conformance

Conforms: own date help / date version (with real examples), plus the subcommand help date epoch help, per 0_conventions.md.

Utility: mem

Utility: mem - physical memory usage Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

mem answers how much physical RAM is in use? - one line, system-wide. It reports raw facts and renders no verdict (0_conventions.md §1 rule 7).

2. Invocation

CommandMeaning
memPrint one physical-memory summary line and return.

3. Output

gsh> mem
mem: 2048 KiB used / 4096 MiB total (0.04% used, 4094 MiB free)

The percentage is computed in hundredths with integer math, so the microkernel's tiny footprint shows as e.g. 0.04% rather than rounding to 0%.

4. Data source

Frames are 4 KiB pages, from the kernel frame allocator via introspection:

  • inspect_kernel_total_frames() - total frames (× 4 KiB = total).
  • inspect_kernel_free_frames() - free frames.
  • used = total − free; KiB = frames × 4; MiB = frames ÷ 256.

5. Capabilities

  • INTROSPECT (READ) - the frame-count queries are gated (InspectKernel 4/5). The shell holds this cap, so the built-in can call them.
  • Console output to print the line.

6. Non-goals

  • No per-process memory. That is the job of status (per-task MEM column) and observe. mem is the system total only.
  • No history or graphing. Point-in-time.

7. Conformance

Conforms: own mem help / mem version (with a real example, per 0_conventions.md); listed by the shell's top-level help under System. See 0_conventions.md §3.

Utility: cores

Utility: cores - CPU core count Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

cores answers how many cores came up? - the number of CPUs the kernel brought online at boot (§9, §11.2). SMP is static in v1, so this is fixed for the system's lifetime. cores ticks adds one diagnostic: how fast each core's scheduler is ticking.

2. Invocation

CommandMeaning
coresPrint the ready core count and return.
cores ticksSample each core's quantum count for 5 s (paced by the wall clock) and print quanta per second and the raw sampled count per core.

3. Output

gsh> cores
cores: 4
gsh> cores ticks
sampling 5s (RTC-paced)...
core  quanta/s  sampled   (quantum = timer tick OR yield)
  C0     100     500   (BSP - keeps the normal period)
  C1     100     500

The ticks numbers above are illustrative. A quantum is a timer tick or a yield, so an idle core that slows its timer can show well under 100/s. At most 16 cores are sampled.

4. Data source

inspect_core_count() → InspectKernel query 8 (smp::core::ready_count()). cores ticks reads inspect_core_total_ticks(core) → InspectKernel query 7 (scheduler::core_total_ticks) before and after the sample.

5. Capabilities

  • INTROSPECT (READ) - queries 7 and 8 are gated; the shell holds the cap.
  • Console output to print the line.

6. Non-goals

  • No per-core detail. Per-core CPU% and placement live in observe / status, not here. cores is the headline count only.

7. Conformance

Conforms: own cores help / cores version (with a real example, per 0_conventions.md); listed by the shell's top-level help under System. See 0_conventions.md §3.

Utility: about

Utility: about - system identity and credits Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

about answers what is this system? - a one-shot identity card: what the OS is, which build, how many cores it is running on, and who made it.

2. Invocation

CommandMeaning
aboutPrint the identity block and return.

3. Output

gsh> about
GodspeedOS: a capability-based microkernel
  Small enough to understand. Rigorous enough to trust.
  Version 0.22.0 x86_64 (f7a6946a)
  Running on 4 core(s).
  Copyright (C) 2026 Bankole Ogundero and the GodspeedOS contributors.

ASCII only - the framebuffer console's font is ASCII, so no em-dashes or other non-ASCII glyphs (an unsupported codepoint renders as ? on the TV - visible, never silently dropped).

4. Data source

Static identity text; the same version, architecture and build SHA version prints (43_version.md §4); and inspect_core_count() (InspectKernel query 8) for the core count.

5. Capabilities

  • INTROSPECT (READ) - for the core count (query 8); the shell holds it.
  • Console output to print the block.

6. Non-goals

  • No version/build metadata sprawl. about is an identity card, not a build manifest. Per-utility versions are reported by each utility's own version (0_conventions.md §1 rule 5), not aggregated here.

7. Conformance

Conforms: own about help / about version (with a real example, per 0_conventions.md); listed by the shell's top-level help under System. See 0_conventions.md §3.

Utility: caps

Utility: caps - capability viewer Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

caps answers what authority does a service hold? - it lists the capabilities in a task's table. Authority in GodspeedOS is explicit and inspectable (§26.9); this is the command that surfaces it.

2. Invocation

CommandMeaning
capsList this shell's own held capabilities (default).
caps <service>List the named service's held capabilities.

No argument shows the shell itself - a service can inspect its own authority like any other (authority is explicit, not hidden).

3. Output

gsh> caps
caps for shell:
  RESOURCE (target)  RIGHTS (read/write/send/recv/grant/revoke)
  spawn             write
  introspect        read
  service_control   write
  ...

Each row is one held cap: the resource it targets and its rights spelled out (read, write, send, recv, grant, revoke). The kernel's fixed resources, ids 1-18, are named (1=log_write, 2=spawn, 3=console_read, 4=console_push, 5=introspect, 6=service_control, 7=resource_mint, 8=reboot, 9=acquire_any, 10=net_device, 11=gpio_device, 12=usb_disk, 13=set_clock, 14=fire_irq, 15=image_spawn, 16=pci_cfg, 17=device_power, 18=cpu_clock, from AUTHORITIES in the SDK); any other id prints as endpoint#N. That includes 19, the kernel's POWER_OFF resource, which the table does not name, so the shell's own caps shows it as endpoint#19. A task holding nothing prints (none); a name with no live task is caps: no such live service and an Err.

3a. As a record producer (typed pipes)

Bare caps prints the list above; in a pipe it is a record producer (docs/records.md, utilities/31_records.md) emitting a typed table with columns resource (the target name) and rights (the spelled-out right words), so authority is queryable as data:

caps events | where rights contains send     services events can send to
caps | where resource=spawn         does this shell hold spawn? (one row if yes)
caps shell | select resource        just the resources, no rights column
caps events | to json               events's authority as JSON

4. Data source

task_caps(slot) / query_cap_rights over the introspection surface, resolving the service name to its scheduler slot first. The record form (build_caps_table) decodes the same task_caps reply into resource/rights rows.

5. Capabilities

  • INTROSPECT (READ) - reading another task's cap table is gated; the shell holds the cap.
  • Console output to print the list.

6. Non-goals

  • No editing. caps reads authority; it never grants, narrows, or revokes. Mutating authority is the kernel's job, driven by contracts and IPC transfer (§7.6), never by a viewer.

7. Conformance

Conforms: own caps help / caps version (with a real example, per 0_conventions.md); listed by the shell's top-level help under Services as caps [service]. See 0_conventions.md §3.

Utility: status

Utility: status - live task list Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

status answers what tasks are alive right now, and where? - a compact table of every live scheduler slot. It reports raw facts and renders no health verdict (0_conventions.md §1 rule 7).

Naming note. status is a raw task list, not a health verdict. "Is everything OK?" is answered by ask (60_ask.md) and the health library script, so the two stay distinct (raw facts vs verdict).

2. Invocation

CommandMeaning
statusPrint the live-task table and return.

3. Output

gsh> status
slot  name          core  state      mem     queue  restarts
0     supervisor    0     BlockRecv  131072  0      0
1     events        0     BlockRecv  65536   0      0
2     shell         0     Running    262144  0      0
3     xhci          1     Ready      98304   0      0
...

(Slot numbers and values illustrative.) Every valid scheduler slot is listed, at most REC_MAX_ROWS (a longer table ends with status: more than N rows shown (bounded)). STATE is one of Ready / Running / BlockRecv / BlockSend / Dead; mem is bytes.

4. Data source

task_stat(slot) for each slot 0..255 (valid slots only): name, pinned core, state, memory in use, queue depth and restart count. (CPU% is observe's.)

4a. As a record producer (typed pipes)

status is the first record producer of the structured-pipe subsystem (docs/records.md, utilities/31_records.md). Bare or piped it is the same typed table - columns slot / name / core / state / mem / queue / restarts - so the record verbs operate on real fields:

status | where mem>0                  only tasks holding memory
status | where state=BlockRecv | select name core
status | sort reverse mem | to json   ordered desc, rendered as JSON
status | where name=shell | to yaml

The bare status renders that table as a grid (build_status_table, then to_grid), so the console and the pipe show the same columns; select projects whichever are wanted.

5. Capabilities

  • INTROSPECT (READ) - task_stat discloses any task's state and is gated; the shell holds the cap.
  • Console output to print the table.

6. Non-goals

  • No health verdict (that is ask's, 60_ask.md).
  • No full metrics. CPU% and the live view belong to observe.

7. Conformance

Conforms: own status help / status version (with a real example, per 0_conventions.md); listed by the shell's top-level help under Services. See 0_conventions.md §3.

Utility: echo

Utility: echo - print text Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

echo prints the rest of its line back to the console, verbatim. The simplest utility - useful as a console-output sanity check and a building block in scripts and pipes (echo hello | write /h.txt).

2. Invocation

CommandMeaning
echo <text>Print <text> (the remainder of the line) and a newline.

The argument is the whole remainder of the line after echo , trimmed - not split into words - so spacing inside the text is preserved as typed. One surrounding pair of quotes is stripped (echo "hi" prints hi).

3. Output

gsh> echo hello from the shell
hello from the shell

4. Capabilities

  • Console output only. echo reads no kernel state and holds no authority beyond printing.

5. Non-goals

  • No escape sequences / flags (no -n, no -e). It prints the text as given. $name expansion is done by the shell before echo runs, not by echo itself; the shell refuses to echo a variable holding a secret value (gsh: refusing to echo a secret value - it stays off the console).

6. Conformance

Conforms: own echo help / echo version (with a real example, per 0_conventions.md); listed by the shell's top-level help under Console as echo <text>. See 0_conventions.md §3.

Utility: clear

Utility: clear - clear the screen Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

clear wipes the console and returns the cursor to the top, then the shell reprints gsh> beneath. For tidying the screen after scrolling output.

2. Invocation

CommandMeaning
clearClear the screen and home the cursor.

3. Behaviour

Emits the ANSI sequence ESC[2J (erase display) + ESC[H (cursor home). The console service honours both, and so does a serial terminal, so both output surfaces clear. The shell loop reprints the prompt afterward.

4. Capabilities

  • Console output only.

5. Non-goals

  • No alternate-screen restore. The console has no alternate-screen buffer; clear erases in place. (This is the same reason observe leaves its final frame on screen rather than restoring the prior screen - 1_observe.md §5.2.) The console's scrollback (54_scrollback.md) captures a line only as it scrolls off the top, so clear neither empties it nor adds the lines it erases to it.

6. Conformance

Conforms: own clear help / clear version (with a real example, per 0_conventions.md); listed by the shell's top-level help under Console. See 0_conventions.md §3.

Utility: spawn

Utility: spawn - start a service Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).

Part of the shell's service-control trio with kill (11_kill.md) and restart (12_restart.md). These three are where the shell exercises its capability-broker role (Appendix B.3): it holds the authority and acts on the user's behalf.


1. Purpose

spawn asks the supervisor to start a named service. The supervisor holds every service image (step C, docs/service-ownership.md) and sends the kernel a spawn request carrying the service's privileges, send peers, memory limit, core and device class; the kernel builds the cap table from that request (never from the contract, CLAUDE.md §13.6), places the task on a core (§14.1), and adds it to the run queue.

2. Invocation

CommandMeaning
spawn <name>Start the service named <name>.
spawn <a>,<b>,...Start each named service in turn (comma list, no spaces, at most 16 names; one failure does not stop the rest).
spawn (no name)Prints usage: spawn <svc> | <svc>,<svc>,... (e.g. spawn ping,pong).

3. Behaviour & guards

  • The supervisor resolves <name> to an image it holds. A name it cannot spawn prints spawn failed (unknown service?): <name> and is an Err, never a silent no-op (invariant 12); success prints spawned: <name>.
  • Singleton guard: spawning a service that is already live is refused by the shell (already running: <name>) - there is one instance per name.
  • supervisor is refused (Not applicable. The supervisor is the restart authority ..., Err(Denied)), and so are the observe variants, which run from the observe command instead.
  • Placement follows the contract (or round-robin); a contracted-but-unavailable core is rejected with PlacementInvalid (§9.2).

4. Capabilities

  • SPAWN (WRITE, resource 2) - held by the shell (broker) and supervisor. A service without this cap cannot start other services (§3.1).
  • A send capability to supervisor, which the request travels over. Supplying an image is IMAGE_SPAWN, which only the supervisor holds; the shell names a service, it never hands over code (CLAUDE.md §14.1, step C amendment).
  • Console output for the result / error line.

5. Non-goals

  • No cap-delegation arguments. A general "spawn child X with caps A, B" surface (for pipes / scripting) is future work (Appendix D); today spawn starts a service with the authority the supervisor's spawn request for it carries.

6. Conformance

Conforms: own spawn help / spawn version (with a real example, per 0_conventions.md); listed by the shell's top-level help under Services as spawn <svc>[,svc,...]. See 0_conventions.md §3.

Utility: kill

Utility: kill - stop a service Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).

Part of the shell's service-control trio with spawn (10_spawn.md) and restart (12_restart.md).


1. Purpose

kill terminates a running service: mark it Dead, bump its endpoint generation (invalidating outstanding caps), drain its queues, and reclaim its frames (§14.4, §14.5). It is for misbehaving services and as the first half of restart - not a graceful-shutdown mechanism.

2. Invocation

CommandMeaning
kill <name>Stop the running service named <name>.
kill <a>,<b>,...Stop each named service (comma list, no spaces, at most 16 names; a refused or absent one does not stop the rest). supervisor goes after the others and shell last.
kill all-servicesStop every service the supervisor keeps running (its MANAGED set), then the supervisor, then this shell - a recycle of everything that is restarted, only the kernel surviving. What the supervisor starts for one job (ask, recorder, copier, observe) is not in that set. The set is the supervisor's own MANAGED list, asked at the moment of the command (supcmd::MANAGED, the list ask reads), so a service added to the system is in it without anyone editing the shell. If the supervisor does not answer, nothing is killed and the shell says so. If it names more services than the command holds (32 besides the supervisor and the shell), nothing is killed either: kill all-services: the supervisor runs N services and this command holds M - nothing was done (backlog/84 S4).
kill (no name)Prints usage: kill <svc> | <svc>,<svc>,... | all-services ('help kill' for detail).

3. Behaviour & guards

  • service_control capability required. kill (syscall 8) validates the SERVICE_CONTROL resource before doing anything; without it the call returns CapNotHeld. This closes the §3.1/§14.4 ambient-authority hole - before it, any service could kill any other. Held only by the supervisor and the services it delegates it to: the shell, chaos, control and the test probes. See docs/service-control-cap.md.

  • Results are one line each: killed: <name>, not running: <name> (an Err), or kill failed: <name> (an Err). The observe variants are refused with a pointer to the observe command.

  • There is NO TCB guard, and that is deliberate. Nothing here is refused on trusted-root grounds, because the non-restartable set is {kernel} alone (§6.2, §6.3 - Path C / Phase 6). fs and block-driver are freely killable and the chaos suite kills them by the thousand; killing the supervisor is answered by the KERNEL respawning it, which the shell says out loud. init and registry do not exist to protect - init was removed in Phase 5 and the registry service retired in Phase 4.

  • shell is the one special case, and it is not a refusal either. Killing the shell from the shell self-kills and the supervisor respawns a fresh prompt (the in-flight command is lost - a re-init, not a resume). xhci and ehci are not guarded; the shell's own source notes they USED to be.

    Corrected 2026-09-26. This section promised a kernel TCB guard refusing init / supervisor / registry / block-driver / fs and a shell guard refusing xhci / ehci / shell. Neither exists. CORE_SERVICES holds one name - "supervisor" - and the guard reads is_core_service(name) && name != "supervisor", so the branch can never fire. A document that invents a safety net is worse than one that admits there is none, because it invites the experiment.

4. Capabilities

  • SERVICE_CONTROL (WRITE, resource 6) - the gating authority.
  • Console output for the result / error line.

5. Non-goals

  • No graceful shutdown. kill is immediate; the target gets no cleanup codepath. Clean shutdown, if ever needed, is a separate mechanism.

6. Conformance

Conforms: own kill help / kill version (with a real example, per 0_conventions.md); listed by the shell's top-level help under Services as kill <svc>[,svc,...] | all-services. See 0_conventions.md §3.

Utility: restart

Utility: restart - restart a service Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).

Part of the shell's service-control trio with spawn (10_spawn.md) and kill (11_kill.md). restart = kill then spawn.


1. Purpose

restart stops a service and starts it again - the v1 update/recovery mechanism (live code update is permanently rejected, §2.5/§16). A restarted service may come back on a different core: identity is stable, location is not (§11, §14.2).

2. Invocation

CommandMeaning
restart <name>Kill <name> then spawn it again (placement re-evaluated).
restart <name> <core>Restart and place it on the given core (dev-mode override).
restart <a>,<b>,...Restart each (comma list, no spaces, at most 16), each placed per its own contract; a core argument is ignored for a list.
restart (no name)Prints usage: restart <name> [core].

3. Behaviour & guards

  • Placement is re-evaluated from scratch (§9.2): a contract-specified core re-applies (and may fail with PlacementInvalid if unavailable); otherwise a fresh round-robin core is chosen. The previous core is not remembered - so a service can transparently move cores across a restart.
  • The optional <core> argument is the supervisor's placement_override (§14.4), exposed for dev-mode use; it is subject to the same strict placement rules. The shell does not check for a dev mode, and a <core> that is not a number is ignored (the restart proceeds unplaced).
  • The shell's restart is the SDK's ServiceContext::restart: a kill, then a spawn through the supervisor. A kill that finds nothing to kill is tolerated, so a service that was not running is simply spawned; any other refusal of the kill ends the restart as a failure. Results print restarted: <name> or restart failed: <name> (an Err).
  • Same guards as kill (it is the kill half): requires SERVICE_CONTROL. There is NO trusted-root refusal, and the shell refuses only supervisor, shell and the observe variants - xhci and ehci pass straight through. See 11_kill.md §3, corrected 2026-09-26; this said the trusted root was refused and that xhci/ehci were guarded, and neither is true.
  • Client recovery is the client's job (§14.3): a client of the restarted service sees EndpointDead / CapRevoked on its next call and must reacquire by name through the kernel name directory (AcquireSendCap) - the registry service was retired in naming Phase 4. The kernel does not rebind for it.

4. Capabilities

  • SERVICE_CONTROL (WRITE) for the kill half and SPAWN (WRITE) for the spawn half - both held by the shell.
  • Console output for the result / error line.

5. Non-goals

  • No live/in-place update. Restart-with-new-binary is the only update path (§2.5). restart does not patch a running service.

6. Conformance

Conforms: own restart help / restart version (with a real example, per 0_conventions.md); listed by the shell's top-level help under Services as restart <name>[,name,...] [core]. See 0_conventions.md §3.

Utility: reboot

Utility: reboot - reset the machine, now or after a delay Status: Built. As-built reference. The delayed forms built 2026-10-15 and QEMU-verified (osdev test shutdown); hardware-verified on all five boards, 2026-10-15: on each a plain reboot fell due about 3 s after it was set and the board came back to gsh>. The Pi 4's first card cut its last line at the reset watchdog; the wait that fixed it (09ae3177) shows the lines whole on the Pi 2 and on the Pi 4's re-card. Design: docs/shutdown.md (S6). Shape: shell built-in (see 0_conventions.md §2). The shell holds REBOOT; the KERNEL holds the deadline; power warns and tidies up.


1. Purpose

reboot restarts the whole machine - a hardware reset, not a service restart. It takes the same forms as shutdown (64_shutdown.md): in 3 seconds, after a delay you name, or at once. A countdown gets the same tidy-up a shutdown does, so a plain reboot flushes the disk first, which the old immediate reset never did.

gsh> reboot
reboot in 3s   [q] quit   [b] background

2. Invocation

CommandMeaning
rebootReboot in 3 s, counting down.
reboot <delay>Reboot after <delay>: 5, 5s, 5m, 5h; a bare number is seconds; at most 24h.
reboot nowPrint rebooting... and reset at once: no countdown, no tidy-up.
reboot statusWhether one is pending, and how long is left.
reboot cancelStop a pending reboot.
Ctrl+Alt+Del (USB keyboard, at the shell prompt)Reset at once, as reboot now does.

3. Behaviour

  • The same deadline as a shutdown. reboot <delay> sets the machine's one pending action in the kernel (PowerOff, syscall 56, op 3), so it survives the death of this shell, of power, of every service. The kernel logs every set and cancel with who asked, and anyone can read how long is left and that it is a reboot.
  • One deadline, one action. While a shutdown is pending, reboot <delay> is refused and names it, and the other way round: neither can quietly replace the other. shutdown status and shutdown cancel name a pending reboot rather than pretending there is nothing.
  • Earliest wins. A second reboot for LATER than the pending one is refused; one for sooner moves it forward.
  • Under a minute, the countdown runs in the foreground on one line with [q] quit and [b] background. q cancels the reboot (rule 11); b gives the prompt back and the reboot goes on. A minute or more gives the prompt back at once.
  • Warnings and the tidy-up come from power, as for a shutdown: when it is set, at 1 h, 30 m, 15 m, 5 m and 1 m before, each of the last ten seconds, and three seconds out a copy in flight is cancelled and fs flushes and stops taking writes. Then the kernel resets the machine (reboot: the pending reboot fell due).
  • jobs lists a pending reboot above the jobs, and in its last minute background refuses to start a job.
  • reboot now prints a final rebooting... line, then invokes the Reboot syscall (18). Nothing goes between the command and the reset. A refused reboot (a caller without the REBOOT capability) is reported rather than hung on (ServiceContext::reboot).

Ctrl+Alt+Del is routed through the shell (CLAUDE.md §6.4, SEC-2 follow-up). The USB keyboard drivers (xhci, ehci) recognise the chord (either Ctrl + either Alt + Delete) in the HID report with godspeed_sdk::hid::is_ctrl_alt_del, checked for keyboard devices only (a mouse button byte can alias the modifier bits), and only signal it: they push hid::CTRL_ALT_DEL_SIGNAL (0x80, a byte no typed key produces on its own) onto the console stream. The shell, which holds REBOOT, sees that byte at its prompt and resets at once, as reboot now does. Only a 0x80 standing alone is the chord: inside a UTF-8 character typed over serial or pasted it is a continuation byte, and the shell decodes such a character whole (a curly quote, E2 80 9C, rebooted the machine until 2026-10-11). The drivers hold no REBOOT capability and cannot reset the machine themselves. So the chord works at the shell prompt; while a full-screen app such as edit owns the console the shell is not reading, so quit it first. The Pi 2's dwc2 driver does not signal the chord. It does not prompt.

4. Output

Text at the prompt. reboot status is one sentence; the rest are actions (rule 12).

5. Capabilities

  • REBOOT (resource 8), held by the shell alone; the kernel refuses the Reboot syscall (18) and the reboot operations of PowerOff (56) to anyone without it.
  • Console output, the keys for the countdown, and a send to power to cue it - a cue that carries no authority, since power only re-reads the kernel's deadline.

6. Non-goals

  • Turning the machine off is shutdown (64_shutdown.md), on the same deadline.
  • No "reboot into X". No boot-target selection - it is a plain reset. (Limine handles boot; reboot does not negotiate with it.)
  • No clock time (reboot at 03:00), for the reason docs/shutdown.md 7 gives for shutdown.

7. Conformance

Conforms: own reboot help / reboot version (with a real example, per 0_conventions.md); listed by the shell's top-level help under Power, beside shutdown; rule 10 ([q] quit and [b] background on the countdown) and rule 11 (q stops the reboot). See 0_conventions.md §3.

Utility: poweroff - not provided (considered and rejected)

Status: Considered, implemented, tested on hardware, and removed. Its successor is built (QEMU- and hardware-verified 2026-10-14, all five boards): shutdown (utilities/64_shutdown.md, docs/shutdown.md, 2026-10-14), which halts where a board cannot cut its own power rather than claiming it did. Why this doc exists: so the next person doesn't re-attempt it expecting it to be easy. It isn't, and the reason is instructive.


The tempting symmetry that isn't real

reboot is one line - outb(0x64, 0xFE) pulses the CPU reset line via the keyboard controller. So a poweroff feels like it should be just as small. It is not, because reboot never powers anything down.

  • reboot is a reset. The reset line snaps the CPU and chipset back to their power-on state; the CPU restarts at the firmware reset vector and POST → bootloader → OS runs again. Power is delivered the entire time. It's a warm reset, and it's trivially simple and universal precisely because the reset line is dumb, hardwired silicon that needs no firmware cooperation.
  • poweroff actually cuts power. There is no equivalent hardware line for "turn off." The only standard mechanism is ACPI sleep state S5, which is a full power-management protocol, not a pin.

So the two are not symmetric operations. One is a wire; the other is a protocol.

Note 2026-10-09. "One line" is no longer literal. The x86 reset (arch::x86_64::hardware_reset) now tries the 0xCF9 reset control register first (the Goldmont+ Wyse has no 8042, so 0x64 <- 0xFE alone was a no-op there), then the 8042 pulse, then a triple fault; the ARM and RISC-V ports reset through their own firmware or SoC mechanisms. Every one is still a reset, not a power-down, so the argument stands.

What S5 actually requires

Entering S5 is "write SLP_TYPa | SLP_EN to the PM1a_CNT I/O port" - but real firmware gates that write behind ACPI machine language (AML) you must run:

  1. The SLP_TYPa value lives in the DSDT's \_S5 object (we can byte-scan it).
  2. The machine must be in ACPI mode (SCI_EN=1) - an SMI handshake.
  3. Crucially, most firmware requires the \_PTS (Prepare To Sleep) control method to be executed first - AML the firmware uses to do platform-specific prep (often via the embedded controller / chipset) that gates the sleep.

This is why Linux uses ACPICA - a complete AML bytecode interpreter (tens of thousands of lines). It evaluates \_S5, runs \_PTS/\_GTS, and only then writes the sleep register. "Write a port" is the last step of a much larger job.

What we verified on the T630 (AMD GX-420GI)

A full minimal implementation was built and tested on real hardware:

  • The FADT + DSDT \_S5 parse was correct: PM1a_CNT=0x804, SMI_CMD=0xb2, SLP_TYPa=5.
  • The machine booted in legacy mode (SCI_EN=0); the SMI enable handshake worked (SCI_EN went 0 → 1).
  • The S5 write executed correctly (read-modify-write, preserving SCI_EN).
  • The machine still did not power off - confirming the firmware gates the power-down on \_PTS, which cannot run without an AML interpreter.

It worked under QEMU (which implements no \_PTS gate) and refused on the T630.

Why it was removed rather than shipped

Two project principles, both decisive:

  • A command must do what it says (mechanical honesty, §26.5/§26.7). A poweroff that prints "powering off…" and then leaves the machine running - working only under emulation - is a dishonest command. Better none than a half-one.
  • No ACPICA in a tiny, fully-understood kernel (§4.4, §26.11). A full AML interpreter is a multi-tens-of-thousands-of-line subsystem that would blow the "30-minute whiteboard" budget for the sake of one shell verb. That is exactly the kind of hidden complexity §26 tells us to refuse.

If it is ever revisited

It needs a real (if minimal) AML interpreter able to evaluate \_S5 and execute \_PTS/\_GTS - a deliberate, separate undertaking, not a shell built-in. Until then, the Power category holds reboot and shutdown, and shutdown says it halted where the firmware gates S5 on \_PTS (the T630). Typed at the prompt, poweroff is not a command: the shell answers unknown: poweroff and names shutdown (FOREIGN_HINTS).

Utility: shutdown - turn the machine off

Utility: shutdown - turn the machine off, now or after a delay Status: Built 2026-10-14, QEMU-verified (osdev test shutdown) and hardware-verified on all five boards: the Wyse powers off, the T630, the Pis and the VisionFive halt. Design: docs/shutdown.md. Shape: shell built-in. The shell holds POWER_OFF; the KERNEL holds the deadline; power warns and tidies up.


1. Purpose

shutdown turns the machine off: in 3 seconds, after a delay you name, or at once. It is a polite power cut. In its last three seconds power cancels a copy in flight and has fs flush and stop taking writes, then the power goes off whether or not every step answered, because GodspeedOS recovers from a power cut anyway (docs/shutdown.md 2).

gsh> shutdown 5
shutdown in 5s   [q] quit   [b] background

2. Invocation

shutdown              shut down in 3 s
shutdown <delay>      shut down after <delay>: 5, 5s, 5m, 5h; a bare number is seconds; at most 24h
shutdown now          at once: no countdown, no tidy-up
shutdown status       whether one is pending, and how long is left
shutdown cancel       stop a pending shutdown

3. Behaviour

  • The deadline is the kernel's. shutdown sets the machine's one pending power-off in the kernel (PowerOff, syscall 56), so it survives the death of this shell, of power, of every service; and it is gone at a reboot. The kernel logs every set and cancel with who asked, and anyone can read how long is left.
  • Earliest wins. A second shutdown for LATER than the pending one changes nothing and says so: the pending one is kept, and its time named - nothing can postpone a shutdown already set. One for sooner moves it forward.
  • One deadline, one action. A pending reboot <delay> (13_reboot.md) rides the same deadline, so while one is pending shutdown <delay> is refused and names it, and shutdown status and shutdown cancel say a reboot is pending rather than that nothing is.
  • Under a minute, the countdown runs in the foreground on one line, rewritten each second, with [q] quit and [b] background. q cancels the shutdown (rule 11). b gives the prompt back and the shutdown goes on.
  • A minute or more gives the prompt back at once: shutdown in 5m - shutdown cancel stops it.
  • Warnings come from power: when it is set, at 1 h, 30 m, 15 m, 5 m and 1 m before, then each of the last ten seconds. A restarted power reads the kernel's deadline and carries on.
  • jobs lists a pending shutdown above the jobs.
  • In the last minute no new job starts: background refuses and names the shutdown.
  • shutdown now warns that a running job will be cut, then turns the machine off.
  • The last line is the truth: the machine is off, or the kernel says every core is halting - it is safe to switch the machine off where the board cannot cut its own power (docs/shutdown.md 5).

4. Output

Text at the prompt. shutdown status is one sentence; the rest are actions (rule 12).

5. Capabilities

  • POWER_OFF, which this shell alone holds, beside REBOOT: ending the session is the operator's. A second holder - a power button - is a decision recorded in COMMANDMENTS.baseline.toml, never a door.
  • Console output, the keys for the countdown, and a send to power to cue it - a cue that carries no authority, since power only re-reads the kernel's deadline. It also says whether copier is running, so the tidy-up asks for it only when a copy can be in flight.

6. Conformance

Conforms: shutdown help and shutdown version with real examples; listed by the shell's top-level help under Power, beside reboot; rule 10 ([q] quit and [b] background on the countdown) and rule 11 (q stops the shutdown). halt and poweroff are hints that answer "try shutdown".

Utility: background, jobs, foreground - work that outlives the prompt

Status: Built + QEMU-verified (osdev test jobs, 36/0, QEMU with a real disk). Shell built-ins backed by the copier service. Trails CLAUDE.md; does not amend it. The design reasoning, including the option that was rejected, is docs/job-control-design.md.

One file for three names, deliberately. They are one mechanism with three surfaces: a way in, a table, and a way back. Splitting them would put the state model in three files, and a state model described in three places is the drift this repository keeps having to repair. Precedent: 30_first-last.md, 31_records.md.


1. What it is

A long command should not own the prompt for its whole life.

gsh> background copy /big.bin /backup/big.bin
[backgrounded] job 1 - `foreground 1` brings it back

gsh> jobs
JOB  STATE    PROGRESS  COMMAND
1    running   38%      copy /big.bin /backup/big.bin

gsh> foreground 1
[q] quit   [b] background
copying... 41%
job 1 done

2. Invocation

CommandMeaning
background copy <src> <dst>start the copy detached; print its job id; return to the prompt
background delete <path> recursiveremove a whole subtree detached
background drives checkcheck the volume detached; foreground replays the verdict
background drives scrubread-only CRC sweep, detached
background churn <seconds>sustained write traffic, detached - the prompt stays yours
jobsthe table: id, state, progress, command
jobs quit <job>stop a job without attaching to it first
foreground <job>attach the console to a running job, or report a finished one
q, while attachedstop the job, not just the view (conventions rule 11)
b, while attacheddetach it again, leaving it running

Typed at the prompt, four of these run as jobs anyway (2026-10-14, backlog/fixed/89): copy <file> <dst>, delete <dir> recursive, drives check and drives scrub start as the job background would start, and the console attaches to it as foreground does. Nothing changes until 2 s; then the notice is [q] quit [b] background, so the key the conventions promise (rule 10) is there exactly where it can work. b leaves the job running and gives the prompt back:

gsh> drives check
  [q] quit   [b] background
[backgrounded] job 3 - `foreground 3` brings it back

q stops it where it can be stopped: a copy is cancelled between its chunks and its incomplete destination removed. A check, a scrub or a tree delete is ONE request to fs, which cannot be stopped part-way, so q says that and leaves it running as job N. A job that ends while attached prints what it found, in the shell's own words - copier writes the lines the shell would have printed, after one line naming the job (copying, deleting a subtree, drives check - walking the volume) - and its result is the command's. In a script, a pipe, a function, or while a job is already running, the command runs in the shell as before and offers [q] alone: a script's drives check must not let the script run on past it.

jobs quit exists for the reason background does when b already would (§2 of the design note): making somebody attach to a job in order to stop it is the interface forgetting what they just told it. It is not kill copier - killing the service skips its cleanup, so a half-written destination survives as a full-size file with an undefined tail. It routes through the job's own cancel, which removes it. A job is not the service that happens to be running it.

3. Two commands run detached, and the test is not "is it slow"

background is not a modifier that can be put in front of anything. The question a candidate has to pass is:

is this command's value its EFFECT, or its OUTPUT?

A detached job does not print to the prompt (§4): what it says goes into its transcript (§4a) until somebody asks for it, so a command whose whole product is a report has nowhere useful to put it. That sorts the candidates cleanly:

commanddetached?why
copy <src> <dst>yesthe effect is the file; progress is a count, not a screen
delete <path> recursiveyesfs does the whole walk in ONE operation, so the job is one request and a wait
drives checkyesone fs request, and its report lives in the transcript (§4a) until asked for
drives scrubyesthe same shape as check - one request, one verdict - which is what made it nearly free
churn <seconds>yesthe effect is thousands of transactions on disk. It is also the command somebody most wants the prompt back during: it holds the console for its whole run, so a ten-minute churn is ten minutes of a blind machine
selfcheck, chaos, runno, permanentlynot an output problem. These drive other shell built-ins through the shell's own dispatch; a service cannot call into it, and there is one console input ring with one reader. See §3a - the obvious way to make them detach was designed, costed and REJECTED
findnot yetthe shell walks directories itself, so the service would need that walk. The transcript already solves its other half
copy <src> <dst> recursivenoan interrupted WALK leaves a prefix of a tree - a different permitted-outcome question, and one nothing here answers
delete <path> (no recursive)noone metadata edit; it would be over before jobs could list it

Two different blockers, and they are worth keeping apart. "Where does the output go" was solved by the transcript below and is what let drives check in. "Who can run the command" is untouched by it, and is why selfcheck stays out however good the buffer gets.

Refused by name, leading with the reason, and naming what to type instead:

gsh> background selfcheck
background: `selfcheck` not supported for it runs inside the shell - a job runs as a detachable separate service.
  detachable services: copy <src> <dst>, delete <path> recursive, drives check, drives scrub, churn <seconds>

That list is built from the table the dispatch reads, not written into the sentence. An earlier version was a hand-kept string with a comment asking the next person to update it - the same drift facts_check.py exists to catch elsewhere - so adding a kind now updates the message on its own, and osdev test jobs asserts the advertised list names every verb that works.

Refusing is the honest answer rather than the incomplete one (§26.2: the preferred state of an unneeded feature is "not implemented; will be implemented when a test requires it"). background chaos in particular would be a storm nobody can watch or stop, which is worse than no answer.

CHURN HAS A REAL PERCENTAGE, and that sharpens the rule. The column shows a number when something MEASURES it, not when the job happens to be a copy: churn's bound is a duration, so elapsed-over-total is measured. A finished churn reads 100%, because the clock stops at the full duration rather than at the last second sampled before the deadline - a completed run sitting at 91% reads as one that stopped short.

Only the duration form detaches. churn verify, churn tear and churn reset are one-shot and stay at the prompt.

THE TEAR PATTERN LIVES IN sdk::churn, NOT IN EITHER CALLER. Churn's whole purpose is that a torn file is detectable: every byte encodes the generation that wrote it (byte[k] = (gen + k) mod 251), so a file holding a mix of two writes breaks the relation at the exact byte where the tear happened. Detaching churn put the WRITER in services/copier and left the CHECKER in the shell, in different crates that deliberately do not share headers - and if those two expressions ever disagreed, churn verify would report NONE torn while no longer able to recognise a tear at all. A safety check that passes because it broke is worse than no check, because somebody trusts it. So the pattern moved to the SDK, where there is one of it and both sides name the same function.

3a. NOT ALL COMMANDS ARE BACKGROUNDABLE, and that is the settled answer

This is worth stating flatly, because the shape of the feature invites the opposite assumption: that the list above is a work queue and everything reaches it eventually. It is not.

The obvious way to detach selfcheck and run is to spawn a SECOND, throwaway shell to run them and capture its output. That was designed and costed - about two days, and none of it hard - and then rejected on security, which is backlog/fixed/41:

A service whose job is to execute whatever command arrives on its endpoint must hold the authority to execute ANY command. Its power is then exercisable by whoever can send to it, rather than by whoever is at the console. That is a confused deputy: it acts on instructions without being able to know whether the instructor was entitled to what is being asked. And the protection degrades from a property of the SERVICE (it holds fs and nothing else, true no matter what else changes) to a property of the whole GRANT TABLE (it holds everything, and nobody dangerous can currently reach it

  • one grant away from false, with nothing failing when it becomes false).

It is also the exact failure the capability model exists to prevent: authority flowing from IDENTITY rather than from possession, which is invariant 3.

So the honest statement is not "these cannot detach yet". It is: a command whose execution needs the shell's own authority cannot become a background job without giving that authority to a service that takes orders, and that trade is refused. find is the one genuine "not yet" in the table above - it needs a directory walk and nothing more.

A recursive delete shows no percentage, and does not invent one. fs owns the walk, so nothing here can say how far it has got. The column shows -; 0% would read as stuck and 100% as finished, and both would be fabrications.

JOB  STATE    PROGRESS  COMMAND
1    done     100%      copy /fill3.bin /copy.bin
2    stopped    0%      copy /fill3.bin /cancelme.bin
3    done        -      delete /tree recursive

It also cannot be cancelled once started, and says so rather than pretending. The tree delete is one blocking fs request, so while it runs the service is not reading its endpoint; a cancel waits behind it and arrives after the job has ended. There is no half-measure available, because fs owns the walk.

4a. The transcript: where a detached job's output lives

A job holds no console. Its output goes into a fixed 4 KiB ring inside the job service, and foreground pulls it out when somebody asks. Nothing is ever pushed, so a job does not write over a prompt somebody is typing at. (That is by convention - the job never calls gs::io - not by capability: copier holds log_write, and printing is log_write. See §4.)

gsh> background drives check
[backgrounded] job 4 - `foreground 4` brings it back

gsh> jobs
JOB  STATE    PROGRESS  COMMAND
4    done        -      drives check

gsh> foreground 4
job 4 is done
  drives check - walking the volume
  check: 6 files, 5 dirs, 0 bad; 11352 blocks used, 21416 free (bitmap + free count rebuilt from the tree)
  check: the free count already agreed with the tree - nothing was repaired
  check: ok - filesystem is consistent

The verdict is RENDERED here, not passed through. fs answers a check or a scrub with COUNTS - [files:u32, dirs:u32, bad:u32, ..] - and the first version of this wrote that body straight into the ring on the principle that the filesystem owns what its verdict says. That principle is right about the FACTS and wrong about their presentation: raw little-endian integers printed as characters put , on the screen. It survived a 48-check suite because the check path happened to return an empty body on the test volume and a fallback sentence printed instead; the scrub returned real numbers and showed the bug. The assertions were widened to require the rendered words, since an assertion that cannot tell prose from binary is not testing the replay.

It is bounded and it says when it dropped. 4 KiB of fixed storage, no heap, no growth (§26.6.1). A job that says more ages out its oldest LINES - not bytes, so a replay never begins mid-word - and the replay opens with the count:

  ... 612 earlier byte(s) dropped - the transcript is 4 KiB and this job said more

A bounded buffer that silently truncates is worse than no buffer: it hands somebody a partial drives check that reads as a complete one, which is precisely the silent failure invariant 12 forbids.

There is no jobs output <id> verb. foreground <id> already means "attach the console to this job"; replaying what it said IS attaching. A second word for it would be a second way to say foreground (§26.2).

Only the newest job's transcript exists. The service runs one job at a time and clears the ring when the next starts. foreground on an older finished job reports its outcome and stays silent rather than replaying a different job's text under the right job's heading - a lie that would read perfectly.

Read once, then copier is ended (2026-10-11). copier is not a service, and it used to stay up until reboot. The shell now ends it after the command that finishes its work: once the job has stopped running and its transcript has been replayed by foreground - or was empty, so there was nothing to collect. A later foreground on that job says the text was shown when it was first read back and is not kept after that. The shell learns that a job ended only when jobs or foreground asks, so a job that finishes while nobody looks keeps copier until then.

4. What a job can reach

The copier service holds fs, the board's audio driver (audio-driver on the PCs, pwm-audio on the Pis, for the sound a finished job makes) and its log. It has no console_push, so a detached job cannot inject keystrokes into the shell's input. It CAN write to the console - every task can, through log_write - and stays off the prompt by convention: it never calls gs::io. (This said a capability stopped it; printing needs only log_write, so none does. Corrected 2026-10-09, with the contract's own comment.) It cannot spawn, reboot, or reach the network.

The honest limit of that claim. The design note describes minting a READ capability for the source and a WRITE capability for the destination and handing over exactly those, so a job could reach nothing else for its whole life. That is not what this does: the shell's spawn takes a name and nothing else, and per-invocation capability delegation is listed as future work in 10_spawn.md §5. So the bound is the contract's (fs, entire, plus the audio driver) rather than the two paths'. It is a real reduction from the alternative - a job running inside the shell's loop would hold the SHELL's authority, which includes spawn and reboot - and it is not the bound the design claimed. Recorded here rather than left to be discovered (§26.7).

5. States

Four words from the service, and one the shell adds:

statemeans
runningin progress
donethe job finished - for a copy, the whole file was copied
failedit stopped on an error; the reason is printed with the row
stoppedq cancelled it
lostthe shell's own verdict: copier is gone, so the job did not finish and how far it got is unknown

lost is a separate word from failed on purpose. failed claims knowledge of a failure that was observed; when the service dies mid-job nobody observed anything, and saying failed would assert more than is known. It is also not left as running, which would be a row that never changes again.

ALIVE-AND-SILENT IS NOT GONE, and conflating them was a real bug caught by adding the second job kind. The job service is single-threaded, so while it is inside one long fs request - which a recursive delete is, entirely - it reads no messages and answers no status. Treating "did not reply" as "is not running" would mark a job lost at precisely the moment it is doing its work. There are three cases: an answer, a service that is busy (the row keeps its last true figures), and a service that is actually gone (lost).

6. An interrupted copy DELETES its destination

fs allocates a file's whole extent up front, so an interrupted copy would otherwise leave a file of the right size whose tail is undefined content. That is worse than no file: dir shows the expected byte count, read returns something, and nothing says the tail is garbage. So a cancelled or failed job removes what it made, and says that it did. If the removal itself fails, that is reported too - a failed recovery is still a failure (§26.7).

7. Bounds

  • Eight rows, fixed. The table does not grow and does not queue (§26.6). With all eight rows live a ninth background is refused, naming what is running. Otherwise the ninth takes the row of the oldest finished job, which is a record that may not have been read - deliberately, because keeping finished rows forever is not a bound but a leak: it refused the ninth job of a session permanently to protect a row that had usually already been read. A LIVE row is never taken.
  • One job at a time. A second background copy while one runs is refused, naming the job that is still going. A queue is unbounded growth wearing a small word.
  • Finished rows are kept so a job that ended while nobody was looking is still reportable.

8. jobs is a producer

One row per line, so it filters like everything else:

jobs | where state=running

There is no jobs running subcommand. Rule 12 makes a utility's output a pipeable structure, and a bespoke positional filter for one table would be a second way to say what where already says.

jobs quit <id> is a subcommand and that is not a contradiction. The rule is about FILTERING - selecting rows is where's job and must not be reinvented per table. quit does not select anything; it acts on one job. The house already splits these the same way: drives is a producer and drives check acts, events is a producer and events persist start acts.

9. Non-goals

  • No wait. Nothing has needed one.
  • No & suffix. background <cmd> is the word; a sigil would be a second way to say it, and POSIX vocabulary besides (rule 8). There is no fork, no exec and no process groups here, so borrowing bg/fg would borrow a model this system does not implement.
  • No nesting. background background x is refused, not defined.
  • No job control over services the shell did not start. kill and restart address those by name; two kinds of row would make the table mean two things.
  • No detached subtree copy. background copy <dir> <dir> is refused: an interrupted walk leaves a prefix of a tree, which is a different permitted-outcome question and one nothing here answers.
  • No detached run or selfcheck. Not an output problem: a .gsh script drives shell built-ins through the shell's own dispatch, and no buffer helps with that.
  • Applications do not need this. An application IS a service, and spawn <name> already returns immediately while it runs - status, kill and restart manage it. background exists only to give shell BUILT-INS, which have no service of their own, somewhere to run. The real gap is that spawn takes a name and nothing else, so an application cannot be given arguments or invocation-scoped capabilities (10_spawn.md §5). Note that a detached recursive DELETE is fine for the opposite reason - fs performs that walk itself, in one operation, so this feature never holds a half-finished tree.
  • No cancelling a running recursive delete. It is one blocking fs request; a cancel arrives after it has ended. Refusing to pretend is the whole of §3's last paragraph.
  • No second job while one runs. Refused, naming the job that is still going, rather than queued.

Utility: drives - manage attached disks

Status: Built + QEMU-verified (step 3 = the data primitives: flash / label / list, as a shell built-in over fs - osdev test drives 7/7). The boot-layer (drives godspeed install / update / default) and the multi-drive selectors (use / use default) are reserved and specified here so the vocabulary is coherent from the start, but are later steps (see §8). This doc is the user-facing utility surface; the architecture/rationale lives in docs/drives.md (multi-drive model, addressing, default-flag) and docs/prime.md (the boot layer). Trails CLAUDE.md; does not amend it.


1. What it is

drives manages attached disks from the shell: list them, format (flash) a raw one as GSFS, name it, and - later - choose the working drive and the boot drive. It is the user-facing front end to fs + block-driver.

Because GodspeedOS boots from a removable image and disks are pluggable, the system needs an explicit, user-initiated way to act on a disk - which is also what keeps it honest: the constitution forbids silent reformatting (§3.12), and a human running drives flash (and confirming [y/N]) is the opposite of silent.

2. Two layers: a data drive is not a bootable drive

The single most important distinction this utility draws:

  • drives flash makes a drive a GSFS data drive - a place to hold files. That is all it does. The drive is not bootable; it carries no kernel, no bootloader.
  • drives godspeed install makes a drive a bootable GodspeedOS - it stamps the ESP boot region (Limine + kernel, A/B slots) and a GSFS world onto it (GodspeedOS Prime, docs/prime.md). This is a separate, heavier operation.

The boot-layer verbs live under a godspeed sub-namespace (drives godspeed install / update / default) because their object differs from the data verbs'. In the data layer the object is the drive - you flash the drive, label the drive - so flat verbs read right. In the boot layer the object is GodspeedOS, installed onto a drive, so the namespace names it and a bare drives install ("install the drive?") never happens.

Conflating them is the mistake to avoid: formatting a data disk and installing an OS are different acts with different consequences, so they are different verbs. Likewise there are two distinct "defaults" (§5), one per layer - never one ambiguous default.

3. Addressing a drive - index | label | index:label

Every subcommand that picks a drive takes the same drive selector:

  <drive> ::= 0          index        (location - the SATA port; changes on replug)
            | data       label        (identity - stored in the GSFS superblock)
            | 1:data     index:label  (always unambiguous)

This is identity over location (invariant 11): the label is the drive's identity (stable across replug), the index is its location (the port). You normally use the label alone; you prefix the index only to disambiguate.

Ambiguous label → loud, teaching refusal (§3.12, §26.7). If two drives share a label, a bare-label command does not guess - it refuses, lists the matches, and prints the exact disambiguated commands to run:

gsh> drives flash data
  drives: 'data' is ambiguous - 2 drives are labelled 'data':
    0:data   16 MiB   GSFS
    1:data   32 MiB   GSFS
  run one of:
    drives flash 0:data
    drives flash 1:data

A raw (unformatted) drive has no label yet - flashing is what names it - so a raw drive is addressable only by index (its sole honest handle). While exactly one disk is attached, <drive> defaults to that disk, so the index can be omitted entirely.

As built (2026-10-10): one drive, and the selector is checked against it. The shell addresses drive 0 only (drive_sel_ok). An index other than 0 is refused (drives: no drive N - only drive 0 is attached), and a word selector must be drive 0's own label, read from fs: drives flash typo data answers drives: no drive labelled 'typo' - drive 0 is labelled 'data' (or select it as 0) and erases nothing. Until 2026-10-10 any word was accepted unread, so a mistyped label selected drive 0 and, after the [y/N], erased it. The ambiguous-label refusal shown above is the multi-drive design (step 4, §8) and is not built.

3.1 There is no mount (and why)

A POSIX reflex says "you flash a disk, then you mount it." GodspeedOS has no mount, deliberately. In Unix, mount grafts a filesystem into the one global directory tree (/mnt/usb) - it exists because Unix has a single unified namespace you must splice external filesystems into. GodspeedOS has no such tree: each drive is its own independent GSFS, addressed as [index:]label/path. There is nothing to graft into, so there is nothing to mount.

A drive's availability is its physical presence:

  plug in a raw drive → `drives` shows it as raw
  flash it            → available at once (addressable by index/label)
  unplug it           → it disappears from `drives`
  replug it           → it reappears

When you address 1:data/notes.txt, fs reads that drive's superblock on demand and walks the path - no "load into fs" step, no mount table, no bounded mount-slots. "Where am I working" is a single current location (drive + directory), moved by cd (a file-command utility, §3.2), not a mount. This note exists so the concept is not reintroduced by reflex (cf. 14_poweroff.md).

3.2 There is no drives use either - cd is the one location pointer

A second POSIX reflex: "set the current drive, then cd within it." That is also two pointers where one suffices. GSFS is hierarchical, so a current directory already exists; the honest model is that where you are is one address - drive + path - moved by a single verb. cd takes a full drive-qualified address, so it subsumes "pick a drive" entirely:

  cd 1:data/projects    current = drive 1 (data), dir /projects
  cd /etc               current drive, absolute path
  cd ../bin             relative
  cd archive            switch to the 'archive' drive's root

cd is not an unwanted import - a "where am I" pointer is inherent to any hierarchical namespace (DOS and Windows have cd too), and real directories were a deliberate GSFS choice. cd is a file-command utility (its own doc, step 3-file-commands), not a drives subcommand - drives manages drives; cd/dir/read navigate within and across them. The one thing the old drives use default would have added - persisting a starting drive across boots (a session cd does not) - is deferred (§26.2): decided when file commands + multi-drive exist, not invented now.

4. Labelling is optional, and separate from flashing

flash formats; label names. They are deliberately separate verbs:

  • drives flash 1 → working GSFS storage immediately, addressed by index. No name forced.
  • drives label 1 archive → give it identity later, when identity starts to matter.
  • drives flash 1 archive → the one-shot convenience (format and name).

An unlabelled drive is not broken - it genuinely has no identity yet, so addressing it by index (location) is honest, not a fallback. Forcing a name at flash time would be imposing policy; GodspeedOS adds identity deliberately (like the boot/default flags), never by compulsion.

5. The boot default (later - Prime)

There is no default in the first cut because, with one data drive and no install, there is nothing to choose. The one settled persistent default is the boot default:

DefaultVerbLayerMeans
Boot defaultdrives godspeed default <drive>bootwhich installed GodspeedOS the machine boots (ESP/bootloader level; docs/prime.md)

A persistent working-location default - booting straight into a particular data drive, which a session cd does not survive - is the only thing the dropped drives use default would have added. It is deferred (§3.2, §26.2): decided alongside the file commands and multi-drive, not invented now. (When it lands it is the data layer's concern, kept distinct from the boot default - you might boot off drive 0 yet work on drive 1.)

6. Command set

CommandEffectPersists?Step
driveslist every drive: index, label, status (GSFS or raw), size, and free space when formatted-3
drives flash [drive] [label] [force] [yes]format the drive as a GSFS data drive (asks [y/N] - it ERASES); optional label; usable at once. fs REFUSES a disk whose block 0 holds a partition table or boot sector; the word force formats it anyway. The word yes answers the [y/N] in advance (the warning still prints), as for seal; like force it is recognised anywhere, so neither can be a labeldata: yes3
drives label <drive> <name>name / rename a drive - rewrites the superblock (duplicates allowed, §3)data: yes3
drives reset [drive] [force]un-format a drive back to raw (asks [y/N] - it ERASES the GSFS marker); the inverse of flash. NOT a secure wipe (data blocks remain) - a quick clean slate, mainly for re-testing the raw→flash pathdata: yes3
drives check [drive]verify (fsck): rebuild the bitmap/free count, report CRC failuresdata: yes3
drives scrub [drive]read-only integrity sweep: verify every block's CRC, change nothing-3
drives godspeed install <drive>install bootable GodspeedOS onto the drive (Prime)yes6
drives godspeed update <drive>A/B kernel update of an installed driveyes6
drives godspeed default <drive>which installed GodspeedOS the machine bootsyes6
drives godspeedlist installed GodspeedOS drives / help-6
drives versionprint the version-3
drives helpprint usage-3

Drive contents (dir / read / write / cd / mkdir) are their own utilities, not drives subcommands - they operate on paths within a drive, addressable as [index:]label/path or /abs / rel on the current drive (docs/drives.md §4.1).

6.1 The list is a record producer (typed pipes)

Bare drives prints the table above; piped, the list view is a record producer (docs/records.md, utilities/31_records.md) emitting columns index / label / status / size_mib / free_mib (free_mib is empty for a raw, unformatted drive). So the inventory is queryable as data - useful once multi-drive (step 4) lands:

drives | where status=GSFS          only formatted drives
drives | where free_mib>0           drives with space left
drives | select label free_mib      a compact free-space view
drives | to json                    the inventory as JSON

Only the list is a record producer; the action verbs (flash/label/reset/godspeed …) are unchanged - they mutate, they don't emit records.

7. Implementation shape: a shell built-in sending to fs (as built)

Decided at build time (step 3b). The earlier plan here was a standalone service like observe. Building it surfaced why that's the wrong shape for storage commands, and drives ships as a shell built-in instead. Recorded honestly, not silently.

drives (and the file commands) are shell built-ins that send the drives/file API to fs over IPC; fs holds and enforces all disk authority. The shell gains only a single narrow ipc_send = ["fs"] cap (plus its own endpoint for the reply-cap pattern) - not any new disk authority of its own. Three reasons the built-in is right here, where observe went standalone:

  • fs is the enforcing authority, not the shell. A SEND cap to fs is not a dangerous capability - it can only ask fs, which validates every request. Adding it to the shell creates no new dangerous combination; the shell's spawn/kill/restart caps are unchanged. (Contrast observe, which needed isolation because it would otherwise hold introspection alongside the lifecycle caps.)
  • Per-command services would be absurd. A standalone read/write/dir/… each needs console + fs caps + a way to receive its arguments - ten services where one narrow send-cap on the shell suffices. The least-authority win is illusory once fs is the gatekeeper.
  • The [y/N] confirm is trivial in-shell - the shell already owns the console and the read loop; no console-handoff dance is needed.

If least authority ever demands it, the escape hatch is a single standalone files broker the shell forwards command lines to - not one service per verb. Not needed now (fs is the gate), recorded so the option is on the table.

After a flash the drive is usable immediately - no reboot. A reboot only proves persistence (the bytes survive a power-cycle); it is never part of the workflow.

8. Build order (mirrors docs/drives.md §8)

  1. Hierarchical GSFS - done (inodes + directories + path walking; docs/persistence.md).
  2. drives data primitives (step 3, this cut): flash / label / list, on the single attached drive - proves the format-over-IPC + raw-tolerant-fs loop, and lets the OS format its own SSD (which unblocks on-hardware persistence verification). Needs: block-driver capacity request (IDENTIFY sectors → size the filesystem); fs raw-tolerant (serve the drives API even with no filesystem) + in-OS format().
  3. File commands - dir / read / write / cd / mkdir on the current drive; cd is the single current-location pointer (drive + dir).
  4. Multi-drive - enumerate all SATA disks; per-drive block IPC; cd [index:]label/path cross-drive addressing read on demand; duplicate labels disambiguated by index; (deferred) a persistent working-location default.
  5. Boot layer (Prime) - drives godspeed install / update / default; ESP boot region + A/B kernel slots (docs/prime.md).

9. help / version (convention shape, 0_conventions.md)

drives 0.4.0 - manage attached disks (records when piped)

usage:
  drives                        list attached drive(s)
  drives | <verb>               piped: records index/label/status/size_mib/free_mib
  drives flash [drive] [label]  format a drive as GSFS (ERASES)
  drives label [drive] <name>   name / rename a drive
  drives reset [drive]          un-format a drive back to raw
  drives check [drive]          verify (fsck): rebuild bitmap/free, report CRC failures
  drives scrub [drive]          read-only integrity sweep: verify every block's CRC, report (changes nothing)
  drives version                print the version
  drives help                   print this message

(Each row is followed by a real example in the shell's own output, help_block.) Every subcommand has its own help: drives flash help, drives label help, drives reset help, drives check help, drives scrub help.

Conformance note (honest, per 0_conventions.md §3). drives is being built spec-first against the conventions, so it implements its own help / version and per-subcommand help from the start (like observe, unlike the older shell built-ins).

Utility: dir - list a directory

Status: Built + QEMU-verified (osdev test files 222/0, osdev test shell 174/0) - a shell built-in over the fs LIST_DIR API, on hierarchical GSFS (docs/persistence.md). Read-only. Trails CLAUDE.md; does not amend it. Shared addressing/current-location rules live in 17_cd.md and docs/drives.md §4.1.

Renamed from ls (2026-09-17). Typing ls is not an error you have to decode: the shell names the word for you, once, and does not run anything (FOREIGN_HINTS in services/shell).


1. What it is

dir lists the entries of a directory - each name, whether it is a file or a directory, and (for files) the size. With no argument it lists the current directory (17_cd.md); with a path it lists that directory. dir is read-only: it never changes the disk.

Why dir and not ls, and not list

ls is an abbreviation of "list", chosen for a teletype. It came to Unix from Multics, and the reason it is two letters is that early Unix was typed on a Model 33 at ten characters a second, where every keystroke was physical work - the same pressure that produced cp, mv, rm and creat. It was never chosen because it described the operation well, so replacing it is not departing from a well-chosen name; it is supplying precision that never existed.

It was also the last POSIX abbreviation in this vocabulary. Everything around it is already its own word - read, write, move, rename, delete, copy, find, tree, match, count, first, last - where POSIX would say cat, mv, rm, cp, grep, wc, head, tail. ls was the odd one out, inconsistent with its siblings rather than merely with the model.

list was rejected, and the reason is specific to this shell. "List" is a generic verb, and this shell lists drives, caps, cores and events. A bare list would be genuinely ambiguous here in a way it would not be on a system that only ever lists one thing. The original name's vagueness was inherited, not a reason to keep it.

dir names its subject rather than the operation performed on it, and the argument for it owes nothing to DOS: mkdir already teaches the word. Somebody who has typed mkdir /docs has been told that the word for this thing is "dir"; dir /docs then shows the thing mkdir made. The pair is coherent on its own terms. And unlike ls, it is transparent - a reader who has never seen it reads "directory", where ls is opaque unless you already know Unix. That is the actual discriminator: not length, but whether the name explains itself.

It can be concrete because GSFS has exactly one kind of container. PowerShell could not be - having to cover a filesystem, a registry and a certificate store, its noun had to generalise, which is how a rename aimed at clarity arrived at Get-ChildItem. There are no providers here, so there is no abstraction to leak into the name.

So the three short names kept are dir, cd and mkdir, and each is kept for the same reason rather than for brevity: it says what it does.

Four columns, and why there is no fifth

dir shows name, type, size and when it changed. There are no words to turn any of it on; that is the listing.

This is not ls -l made default. ls -l is opt-in on Unix because it adds mode bits, link count, owner and group - four columns of POSIX bookkeeping. None of them exist here, so removing them is not a choice this made: there is nothing to remove. What is left is four columns, and four columns is not a wall.

There is no permissions column because permissions are not a property of a file. Authority here is a capability, and a capability is held by a HOLDER - so "who can read this?" has no per-file answer to put in a column. The question does not live on the file; it lives on whoever holds a cap to it, and caps is what answers it. This is the one place the capability model visibly changes what a familiar command can even mean.

Sealing is the exception that proves it, and why seal sits in the TYPE column rather than in a column of its own: a seal genuinely IS a property of the file, recorded on disk, constraining what anyone may do to it regardless of what they hold. It also costs no width, because sealing is file-only (fs refuses "only a file can be sealed"), so TYPE stays single-valued.

A directory shows - for size. A directory occupies blocks, but reporting those answers a question nobody asked and would not equal the sum of what is inside it.

unknown in MODIFIED is a real answer, not a missing one - see below.

2. Usage

dir 0.4.0 - list a directory (records when piped)

usage:
  dir                       list the current directory
  dir <path>                list the directory at <path>
  dir bytes                 sizes as an exact byte count, not KiB/MiB/GiB
  dir [path] | <verb>       piped: emits records name/type/size
  dir | select … / sort …   project / order the listing
  dir version               print the version
  dir help                  print this message

<path> = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)

The word mixes with a path in either order: dir bytes /projects and dir /projects bytes are the same command. Words, not flags (0_conventions.md rule 4) - there is no -lh, because a flag is a thing you have to have been told.

Example:

gsh> dir /
/
  NAME                  TYPE       SIZE  MODIFIED
  canary.txt            file       41 B  unknown
  a.[2Jb.txt            file       49 B  unknown
  .gsh_history          file      664 B  2026-09-17 14:09
  fz                    dir           -  unknown
  clock.last            file       10 B  unknown
  5 entries

gsh> dir bytes /
/
  NAME                  TYPE       SIZE  MODIFIED
  canary.txt            file         41  unknown
  a.[2Jb.txt            file         49  unknown
  .gsh_history          file        651  2026-09-17 14:09
  fz                    dir           -  unknown
  clock.last            file         10  unknown
  5 entries

Both captured from one boot of osdev test fs-fuzz, not written by hand, on 2026-09-17 - before the layout moved the shell's history and the clock floor off the root into their folders (docs/filesystem-layout.md); the root of a disk flashed since holds system and users. Three things in them are worth pointing at:

The count is LAST, and it is the number of entries actually printed. It used to lead, next to the path. A directory larger than one reply block arrives in several pages, so the total is not known until the walk ends - and the obvious fix, walking once to count and again to print, produces two answers that can disagree, because the directory may change between them. A header contradicting the rows beneath it is precisely the wrong answer this format exists to avoid. Counting what was rendered cannot disagree with itself, and it puts dir in line with find and tree, which have always summarised at the end.

a.[2Jb.txt is a filename holding a raw ESC [ 2J - a clear-screen sequence - baked onto the disk by that suite. It renders with . where the control bytes are, so listing it cannot scroll itself off the screen. That sanitising is why the name column can be trusted, and it is a reason not to decorate names with markers.

.gsh_history differs between the two listings because the shell appended the command in between. That is the file honestly changing, not the two renderings disagreeing.

The size column is right-aligned INCLUDING its unit

8 B and 1.4 MiB end at the same column. Aligning the digits and letting the units straggle still reads as ragged, which is the subtler half of the problem; the blunter half is that a Rust Display implementation silently ignores the width it is given unless it calls f.pad(), so {:>9} did nothing at all until the renderer was changed to render into a buffer and pad it.

unknown is a real answer, not a missing one

A MODIFIED column reads unknown when the filesystem genuinely records no time for that entry - a file written before the volume recorded times at all, or one written while the machine had no idea what time it was. It is never shown as 1970, because a wrong date is worse than an absent one: a date you can see is a date you will act on. See docs/gsfs-next.md §3.

Sizes line up as a column, which needed more care than it looks: a Rust Display implementation silently ignores the width it is given unless it asks for it, so {:>10} did nothing until the renderer was changed to pad explicitly. A column of ragged numbers is not a column.

2a. As a record producer (typed pipes)

dir is a record producer (docs/records.md, utilities/31_records.md): bare it prints the text listing above, but in a pipe it emits a typed table with columns name / type / size / sealed (type is file/dir; size is the byte count for files and empty for directories; sealed is true/false, so a sealed file stays type=file in a query rather than reading seal as the text listing does). The table holds at most 64 rows (REC_MAX_ROWS). So the structured verbs operate on real fields instead of re-parsing text:

dir | where type=file               only files
dir /docs | where size>0            non-empty files
dir | select name size              keep two columns
dir | where type=file | sort reverse size   biggest files first
dir | to json                       the listing as JSON

This is the structured replacement for the old plan to bolt name/size sorting onto dir itself (§5) - sorting and filtering are the pipe's job (where/select/sort <col>), not flags on dir. A text filter (match/count/first/last) on dir output is a loud, guided error (it is records, not text - use where, or to json to get text first).

3. Addressing & current location

<path> is the standard file address: a [index:]label/path on any present drive, a /absolute path on the current drive, or a relative path from the current directory. The current location is one drive+directory pointer moved by cd (17_cd.md).

4. Implementation

Read-only, so dir is a shell built-in that sends ListDir to fs (op 14) over a narrow ipc_send=["fs"] cap and formats the reply. fs enforces; the shell's authority is not widened beyond reading (0_conventions.md §2). Two code paths share that one fs call: cmd_dir formats the text listing (bare dir), and build_dir_table parses the same reply into the name/type/size/sealed table for the record pipe (is_record_producer routes a piped dir to it before the text producers are consulted).

Both walk the reply through a DirCursor, because one reply block carries only about twenty entries: the request says which entry to resume at and the reply says where to continue, so a directory of any size is listed in full (backlog/fixed/33). The count dir prints is therefore the number of entries it actually rendered, and it is printed LAST - the total is not known until the walk ends, and a header that can disagree with the rows under it is the wrong answer this whole mechanism exists to remove. An empty directory prints (empty). The walk is bounded at gs::fs::DIR_PAGE_MAX (512) pages; a directory larger than that ends with an INCOMPLETE - ... line under the count rather than a count that reads as the whole truth.

(The paging now lives in the library: both paths call gs::fs::Fs::list_dir, which drives the cursor. The shell's own DirCursor remains only for copy ... recursive.)

5. Later (separate doc so it can grow)

  • dir extents (a word, never -x - 0_conventions.md §4): the on-disk placement - first block, block count, and whether the file is fragmented (ITYPE_FILE_FRAG). Deliberately NOT called dir raw, which fails the same test ls failed: raw what? Not built - nothing has asked for it outside a test harness, and §26.2 says a feature is pulled into existence rather than anticipated.
  • A recursive form is already tree, which exists. (Sorting and filtering by name/size are done - the record pipe's job, §2a, not words on dir.)

6. Conformance

Conforms: dir help (usage with a real example per row) and dir version (number + creator credit) per 0_conventions.md (the shared help_block helper).

Also conforms to rule 10 (0_conventions.md §1.10): the listing goes through a gs::fs::Fs handle lent the command's watch (gs::call::Watch, since 2026-10-13, backlog/fixed/89), so once the COMMAND has run 2 s (gs::call::NOTICE_AFTER_SECS) it prints [q] quit, and q/Q/ESC returns to the prompt with no error (a fast listing prints nothing). This replaced a bare request_with_reply, which rule 10 forbids for an interactive command. The piped form (build_dir_table) uses the same handle, so dir | ... says [q] quit too.

Utility: cd - change current location

Status: Built + QEMU-verified (osdev test files 11/11) - a shell built-in; the current location lives in the shell session, which resolves relative / . / .. paths to an absolute path before calling fs (validates the target via STAT). On hierarchical GSFS (docs/persistence.md). Trails CLAUDE.md; does not amend it.


1. What it is

cd moves the current location - the single pointer that says where you are. Because GSFS is hierarchical, "where you are" is one address: a drive + directory. cd takes a full drive-qualified path, so it is the only location pointer - there is no separate "current drive" verb (no mount, no drives use; see 15_drives.md §3.1/§3.2 for why those POSIX reflexes were dropped). cd is kept as a verb (universal, not cryptic).

2. Usage

cd 0.4.0 - change current directory

usage:
  cd <path>           move to <path>
  cd                  go home, /users/default - the same as `home` (in a script, $home)
  cd ..               go up one folder (`..` works in any path: read ../notes.txt)
  cd -                move to the previous directory
  cd version          print the version
  cd help             print this message

<path> = [index:]label/path | /abs | rel | ..   (see docs/drives.md §4.1)

Examples:

cd 1:data/projects   current = drive 1 (data), dir /projects
cd /etc              current drive, absolute path
cd ../bin            relative, up one
cd archive           the 'archive' drive's root

3. The current location (session state)

The current location lives in the shell session - the shell prefixes it onto relative paths before sending requests to fs, so fs always receives a resolved address. A session starts in its home, /users/default/ (docs/filesystem-layout.md 6), which drives flash makes. The prompt is a plain gsh> and does not show it; pwd does (62_pwd.md). The session also remembers the previous location (a one-slot history) so cd - can toggle back. Both are session state: they do not persist across boots in the first cut. A persistent "start here" working default is deferred (15_drives.md §5/§3.2) - decided alongside multi-drive, not invented now.

4. Implementation

A shell built-in: it resolves the target, Stats it through fs (op 12) to confirm it exists and is a directory, and updates the session pointer. Read-only on disk. cd - reuses the saved previous absolute path and runs the same stat-validated switch (so a since-deleted previous directory errors loudly rather than silently failing); each successful cd saves the directory it left, so cd - twice returns you where you started. A successful cd prints the new location; cd with no argument goes home, and cd / to the root, which is never stat'd because the root always exists.

5. Later (separate doc so it can grow)

  • cd with no argument is done: it goes home, /users/default/ (changed 2026-10-13 from /, with the layout, docs/filesystem-layout.md 6). There is no ~: in a script, home is the reserved word $home, which cannot collide with a folder named home. At the prompt $home is not expanded - variables are a script feature, and a typed line is not a script - so cd alone is the way home there. .. goes up one folder, in cd and in every path (read ../notes.txt). It is kept as it is, not renamed (cd up was considered, 2026-10-13): Unix, DOS and Windows all spell it .., so it is already known, and a word like up could be the name of a real folder where .. cannot. Tab-completion of path components is done too (complete_path, 0_conventions.md rule 9).

6. Conformance

Conforms: cd help (usage with a real example per row) and cd version (number + creator credit) per 0_conventions.md (the shared help_block helper).

Conforms to rule 10 (0_conventions.md §1.10) since 2026-10-13 (backlog/fixed/89). It found 2026-10-09 that the fs handle was lent no notice; every fs handle the shell makes is now lent the command's watch (gs::call::Watch), so the command says [q] quit once it has run 2 s, and q, Q or ESC leaves the location unchanged (osdev test quit measures cd /q/tree/a on a slowed fs).

Utility: pwd - print the folder you are in

Utility: pwd - print the folder you are in Status: Built 2026-10-13, QEMU-verified (osdev test files). As-built reference. Shape: shell built-in (see 0_conventions.md section 2).


1. Purpose

pwd prints the whole path of the current folder - the one a bare name like notes.txt means, and the one cd moves. The prompt is a plain gsh> and does not show it, so this is how you ask.

gsh> pwd
/users/default
gsh> cd /system/services
/system/services
gsh> pwd
/system/services

A session starts in its home, /users/default (docs/filesystem-layout.md section 6), and a bare cd goes back there.

2. Invocation

CommandMeaning
pwdPrint the current folder's whole path.
pwd | write here.txtIt is a text producer, so it pipes like echo.

pwd takes no argument; given one, it says so and does nothing.

3. Why the Unix word

Godspeed's words are its own (dir, read, delete), and the shell answers a Unix word with a hint rather than running it (FOREIGN_HINTS, CLAUDE.md Appendix B.4). pwd is the deliberate exception, for the reason cd already is one: cd was kept because everyone knows it (17_cd.md section 1), and pwd is its other half - anyone who knows one knows the other. Its earlier stand-in, cd ., asked a reader to know that . means "this folder", which is a Unix convention too, and one that names nothing.

4. Behaviour

Prints the session's current folder, which the shell holds itself (17_cd.md section 3). It asks fs nothing, so it answers at once - with or without a disk - and it cannot say whether the folder still exists: a folder deleted from under the session is reported by the next command that uses it.

5. Capabilities

  • Console output only.

6. Conformance

Conforms: pwd help and pwd version (with a real example, per 0_conventions.md); listed by the shell's top-level help under Storage; a pipe source (rule 12).

Utility: home - go to your home folder

Utility: home - go to your home folder Status: Built 2026-10-13, QEMU-verified (osdev test files). As-built reference. Shape: shell built-in (see 0_conventions.md section 2).


1. Purpose

home moves you to your home folder, /users/default - where a session starts and where a bare name like notes.txt is written (docs/filesystem-layout.md section 6).

gsh> cd /system/services
/system/services
gsh> home
/users/default

2. Invocation

CommandMeaning
homeGo to /users/default, and print it.

home takes no argument; given one, it says so and does nothing.

3. Why a word, when a bare cd already does it

A bare cd goes home, and still does. But it means different things on different systems - "go home" on Unix, "print where I am" on Windows - so a reader who knows one guesses wrong about the other. home cannot be read two ways, and it is the form help advertises. It is a command, not a folder name: cd home still enters a folder called home, if there is one. In a script, the home folder as a path is $home (docs/scripting.md), so cd $home/notes works where home alone would not.

When users exist (phase 3 of the layout), home is the signed-in user's folder; home goes there unchanged.

4. Behaviour

The same as cd /users/default: the folder is checked with fs and the move is refused loudly if it does not exist - a disk formatted before the layout has no home folder, and drives flash gives it one. An action, not a pipe source (0_conventions.md rule 12).

5. Capabilities

  • fs, for the one check that the folder exists, and console output.

6. Conformance

Conforms: home help and home version (with a real example, per 0_conventions.md); listed by the shell's top-level help under Storage.

Utility: read - print a file's contents

Status: Built + QEMU-verified (osdev test files 11/11) - a shell built-in over the fs STAT_FILE and READ_AT API, on hierarchical GSFS (docs/persistence.md). Read-only. Trails CLAUDE.md; does not amend it.


1. What it is

read <path> prints a file's contents to the console. It is the counterpart to write (19_write.md), and the replacement for POSIX cat - whose name ("concatenate") describes a different operation (joining several files) that nobody means when they just want to see one file. read says exactly what it does: read this file out.

Reading one file is the whole job. Joining multiple streams is a pipe concern (Appendix D.3), not an overloaded read command - so read takes a single path.

2. Usage

read 0.4.0 - print a file

usage:
  read <path>         print the contents of <path>
  read version        print the version
  read help           print this message

<path> = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)

3. Behaviour & bounds

read stats the file through fs (STAT_FILE, op 12) to learn its size, then streams it with READ_AT (op 26) in IO_CHUNK pieces (3556 bytes) and writes the bytes out. A file travels in message-bounded chunks (§8.5: 4 KiB max IPC message; §2.5: no shared memory), so a large file is a sequence of copied reads - the honest, bounded data path (docs/persistence.md §6.1). A missing trailing newline is supplied. Reading a directory is a loud error, not a silent dump: read: <path> is a directory - dir lists it. A missing file is read: not found: <path>. A read that fails part way prints read: storage error.

4. Implementation

Read-only, so a shell built-in sending STAT_FILE and READ_AT to fs over a narrow ipc_send=["fs"] cap. fs enforces. File-as-capability has landed (docs/persistence.md §7, 35_fcap.md), but read still addresses the file by name; presenting a per-file READ cap instead is not done.

5. Later (separate doc so it can grow)

  • Paging for long output is done, as a pipe stage rather than a word on read: read <path> | paginate (52_paginate.md).
  • A hex/binary view for non-text files.
  • Range reads (offset + length) once a real need pulls them in (§26.2).

6. Conformance

Conforms: read help (usage with a real example per row) and read version (number + creator credit) per 0_conventions.md (the shared help_block helper).

Conforms to rule 10 (0_conventions.md §1.10) since 2026-10-13 (backlog/fixed/89). It found 2026-10-09 that the fs handle was lent no notice; every fs handle the shell makes is now lent the command's watch (gs::call::Watch), so the command says [q] quit once it has run 2 s, and q, Q or ESC stops the command at its next request (osdev test quit measures read /q/big.txt on a slowed fs).

Utility: write - create, overwrite, append, or prepend a file

Status: Built + QEMU-verified (osdev test files, osdev test script) - a shell built-in over the fs WRITE_FILE / WRITE_NEW / WRITE_AT / READ_AT API, on hierarchical GSFS (docs/persistence.md). Mutating; inline content (write <path> <text>) with append and prepend modes, and the pipe sink (<producer> | write [append|prepend] <path>). GSFS0003 reclaims freed blocks, so overwrite no longer leaks. Trails CLAUDE.md; does not amend it.


1. What it is

write <path> [content] puts content into a file, creating it if absent and replacing its contents if present. With no content, it creates an empty file. POSIX has no write command - it puts content in files via shell redirection (echo hi > file), which leans on fork/fd machinery GodspeedOS doesn't have. Our "redirection" is the capability-mediated pipe (docs/pipes.md, Appendix D.3): <producer> | write <path> - | write is the redirect, so there is no > operator (a second syntax for one mechanism - see docs/pipes.md "Why there is no >"). write is both the inline primitive and that pipe sink.

Why there is no touch. touch's real job is "update a file's modification timestamp"; people abuse it to make an empty file. GSFS now records a modification time (dir's MODIFIED column), but fs sets it itself when a file's content changes and no verb sets it on its own, so that purpose still has no referent here - and "make an empty file" is just write <path> with no content. One honest verb; no name that lies about its job.

2. Usage

write 0.4.0 - create, overwrite, append, or prepend a file

usage:
  write <path>                     create an empty file
  write <path> <text>              create/overwrite with text
  write append <path> <text>       add text to the end (create if missing)
  write prepend <path> <text>      add text to the front (create if missing)
  <producer> | write [append|prepend] <path>   save piped output to a file
  write version                    print the version
  write help                       print this message

<path> = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)

content is the rest of the line (quote it to keep spaces). Plain write always overwrites; the additive behaviour is the explicit keyword append/prepend, never punctuation. Piped content (about | write /about.txt) goes through the same sink.

Why append/prepend lead instead of trailing. Every other modifier sits at the end (mkdir <path> parents, delete <path> recursive). write can't: its content is the free-form rest of the line, so a trailing word would be swallowed as content. The keyword therefore comes right after the verb, and only counts when followed by whitespace - so write appendix.txt … is still a plain write to a file named appendix.txt.

3. Behaviour

Overwrite is deliberate and announced (wrote /path (N bytes)), never a silent clobber (§26.7). append/prepend add to the end / front (appended N bytes to /path), creating a missing file. With no arguments at all it prints usage: write [append|prepend] <path> [content]. A write whose answer never came (a deadline, or fs dying holding it) is reported as write: OUTCOME UNKNOWN - <path> MAY HAVE BEEN written, not as a failure, and is not re-sent. The parent directory must exist (no implicit creation). prepend is honestly a full-file rewrite - there is no insert-at-front in the filesystem, so it costs the same as rewriting the file (stated, not hidden, §26.7).

4. Implementation

A narrow ipc_send=["fs"] built-in; fs (WriteFile, op 10) holds the disk authority and enforces. With file-as-capability (docs/persistence.md §7) write would present a WRITE cap. Append/prepend are shell-side and add no new fs surface (fs_stream_combine): they stream [old|new] (append) or [new|old] (prepend) to a temp file - reading the intact original via ReadAt while writing WriteNew/WriteAt chunks - then atomically replace the target (Delete + Move). Constant memory (one chunk scratch), any file size; the same streaming the pipe write sink and copy use. This lifts the old 4 KiB read-modify-write ceiling.

5. Later (separate doc so it can grow)

  • Confirm-on-overwrite for an existing file (mirrors flash's [y/N]), if wanted.

6. Conformance

Conforms: write help (usage with a real example per row, incl. write /docs/todo.txt "buy milk") and write version (number + creator credit) per 0_conventions.md.

Utility: mkdir - create a directory

Status: Built + QEMU-verified (osdev test files 11/11) - a shell built-in over the fs MKDIR API, on hierarchical GSFS (docs/persistence.md). Mutating. Trails CLAUDE.md; does not amend it.


1. What it is

mkdir <path> creates a directory. The parent directory must already exist; creating an already-existing name is a loud error, never a silent no-op (§3.12). mkdir is kept as a verb - a universal contraction, one of the two short ones we keep (cd / mkdir).

2. Usage

mkdir 0.4.0 - create a directory

usage:
  mkdir <path>        create the directory <path>
  mkdir <path> parents   create missing parent dirs too
  mkdir <a>,<b>,...   create several directories (comma-separated)
  mkdir version       print the version
  mkdir help          print this message

<path> = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)

A comma list has no spaces and at most 16 items (LIST_MAX); a longer one is refused before anything is created.

3. Behaviour & bounds

mkdir allocates a directory inode and a directory block, then adds an entry to the parent (fs op 13; parents is op 18). GSFS bounds it (§26.6) with a fixed inode count overall; running out is a loud, defined error, not silent growth. (A directory is no longer one 16-entry block - see §5.) No POSIX permission bits (authority is by capability, §3.3).

Success prints created <path>. A request whose answer never came is reported as mkdir: OUTCOME UNKNOWN - <path> MAY HAVE BEEN created, not as a failure.

4. Implementation

Mutating, so the same least-authority shape as the other writers (19_write.md §4): fs holds the disk authority (Mkdir, op 13) and enforces.

5. Built / later

  • mkdir <path> parents - done. Creates every missing parent directory in one call (a word, never -p, per 0_conventions.md §4); fs walks the path component by component, creating what's missing. Idempotent; errors only if a component is in the way as a file. Plain mkdir <path> stays strict (parent must exist) - parents is opt-in.
  • Later: on GSFS0003 directories already grow without bound (the old 16-entry limit is gone - docs/persistence.md §6.4), so that item is moot.

6. Conformance

Conforms: mkdir help (usage with a real example per row) and mkdir version (number + creator credit) per 0_conventions.md (the shared help_block helper).

Utility: copy - copy a file or a whole subtree

Status: Built + QEMU-verified (osdev test files) - the file form and the recursive directory form, as a shell built-in. On hierarchical GSFS (docs/persistence.md). Trails CLAUDE.md; does not amend it.


1. What it is

copy <src> <dst> duplicates a file - same drive or across drives (copy 0:data/a 1:backup/a), since every drive is addressable by [index:]label/path. It replaces POSIX cp; the full word is barely longer and not cryptic.

2. Usage

copy 0.4.0 - copy a file or a whole subtree

usage:
  copy <src> <dst>              copy file <src> to <dst>
  copy <src> <dst> recursive    copy directory <src> and everything under it
  copy version                  print the version
  copy help                     print this message

<src>,<dst> = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)

3. Behaviour

copy <src> <dst> reads src and writes dst (read + write underneath), so cross-drive copy is just read-here, write-there - the no-shared-memory data path (§2.5, docs/persistence.md §6.1). Success prints copied <src> → <dst> (N bytes). src is left untouched.

copy <src> <dst> recursive duplicates a whole directory subtree. It is the safe default's opt-in: a plain copy of a directory is refused with copy: source is a directory (use 'copy <src> <dst> recursive'), so the large operation only happens when you ask for it (mirrors mkdir … parents, delete … recursive). Refuses to copy a directory into its own subtree (copy /a /a/inner recursive) - that would never terminate - and refuses the root. The destination directory must not already exist. A file that fails to copy prints copy: skipped (copy failed): <path> and the walk continues; the closing line is copied <src> → <dst> (N dirs, M files), where M counts only the files that copied.

4. Implementation

Mutating, so the least-authority shape of the other writers (19_write.md §4): it stats the source (STAT_FILE, op 12), allocates the destination at full size (WRITE_NEW, op 24), then streams IO_CHUNK pieces with READ_AT (op 26) and WRITE_AT (op 25), so a file of any size copies through one fixed buffer. The recursive form adds no new fs surface - copy already lives in the shell, so it walks the source subtree with the same bounded PathStack find uses (§26.6): pop a source dir, recreate it under dst (Mkdir, op 13), copy each file, push each subdir. Loud if the tree is wider than the walk's pending-dir cap (§3.12), exactly like find.

5. Later (separate doc so it can grow)

  • Copy-from/to a pipe. The capability pipe has landed (docs/pipes.md), and read <src> | write <dst> already copies through one; a copy pipe stage is not built.
  • Overwrite confirm, mirroring write/flash, if wanted.

6. Conformance

Built spec-first against 0_conventions.md: implements its own copy help / copy version.

Utility: move - relocate a file

Status: Built + QEMU-verified (osdev test files, 222/0). Same-drive move is a relink - only the directory entries change, no data copied and (so) no reclamation needed; fs treats a same-directory move as a rename. (Cross-drive move = copy + delete is later, with multi-drive.) Trails CLAUDE.md; does not amend it.


1. What it is

move <src> <dst> relocates a file to a new path - a different directory, or a different drive (move 0:data/a 1:archive/a). It replaces POSIX mv, but only the relocation half: renaming a file in place is a separate verb, rename (23_rename.md). POSIX's mv secretly does both; GodspeedOS keeps them distinct because they are different acts (§26.5).

2. Usage

move 0.4.0 - relocate a file or directory

usage:
  move <src> <dst>    move <src> to <dst>
  move version        print the version
  move help           print this message

<src>,<dst> = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)

3. Behaviour

Built and shipping. This section used to read "why it's gated" and explained that move was deliberately withheld because GSFS had no block reclamation, so a move would strand the source's blocks. Reclamation landed (docs/persistence.md §6.11) and move shipped with it; the section described a state that had not been true for some time.

A same-directory move is a rename (re-point the entry). Across directories it is an dir_add into the destination followed by a dir_remove from the source, both inside one journal transaction, so a crash leaves the file in exactly one of the two places and never in neither or both. Moving a directory moves its whole subtree with it; nothing is copied, because nothing needs to be.

Open capabilities to the moved path are revoked (revoke_open_subtree), including for every descendant when a directory moves. A capability names a path, the path no longer names that file, and a holder that kept using it would be a confused deputy (§7.10, SEC-5).

A directory cannot be moved into itself, or into its own subtree

move /a /a/b would write an entry inside /a pointing at /a, then unlink /a from its parent. The subtree becomes a cycle that the root cannot reach - and drives check rebuilds the free bitmap by walking the tree, so those still-occupied blocks would be marked free and handed to the next allocation, which overwrites live data. MAX_TREE_DEPTH keeps a walk from hanging on it, so it would show up as a leak that turns into corruption rather than as a hang.

It is refused in two places, deliberately. move itself refuses before sending (move: cannot move into itself), which gives the better message and costs a round trip. fs refuses it too, because a check in the caller is a convention and only a check in the owner is an enforcement: fs owns the tree, and owns the bitmap rebuild that depends on the tree being acyclic. Leaving that to the client means the next client - a script, another service, a refactor of this one

  • inherits an obligation nobody told it about.

fs proves the predicate on every boot (fs: path guard selftest PASS), including the case a sloppy prefix test gets wrong: /ab is not inside /a, and moving it there must succeed.

4. Failure

fs now sends the REASON back with the failure rather than only logging it, so these are its words, not a guess made at the prompt:

what happenedwhat you see
the source does not existmove: failed - source not found
the destination already existsmove: failed - dest exists
the destination's parent does not existmove: failed - dest path not found
the destination's parent is a filemove: failed - dest not a directory
moving a directory into itself or its subtreemove: cannot move into itself (the shell's own check, before the round trip)
storage is not availablestorage unavailable - do NOT run 'drives flash' (data may be intact; awaiting storage recovery)
no answer came back (a lost reply, or fs died holding it)move: OUTCOME UNKNOWN - ... it MAY HAVE SUCCEEDED. Not re-sent - ...

Success prints moved <src> → <dst>.

This table used to say the first two were indistinguishable - "the same line; fs distinguishes them in its log". That was true and it was the wrong place to leave it: the reason was already in hand at the moment of failure and was being thrown away at the reply. See docs/persistence.md §6.17 and send_res! in services/fs. A client that has not been taught to read the reason still gets the old wording, because the reason is appended after an unchanged FS_ERR byte.

5. Later (separate doc so it can grow)

  • Cross-DRIVE move, which is genuinely copy-then-delete rather than a re-point, and therefore needs a progress report and an interruption story of its own.

6. Conformance

Conforms: own move help / move version (with a real example, per 0_conventions.md).

Utility: rename - rename a file or directory in place

Status: Built + QEMU-verified (osdev test files 15/15) - a shell built-in over the fs RENAME op, which edits the directory entry in place (no reclamation needed). On hierarchical GSFS (docs/persistence.md). Trails CLAUDE.md; does not amend it.


1. What it is

rename <path> <newname> changes the name of a file or directory, in the same directory - it does not move it. This is the half of POSIX mv that is purely a name change; relocation is move (22_move.md). Splitting the two is the explicit GodspeedOS way (§26.5), and it mirrors drives label, which renames a drive - same idea, one level up.

2. Usage

rename 0.4.0 - rename an entry in place

usage:
  rename <path> <newname>   rename <path> to <newname>
  rename version            print the version
  rename help               print this message

<path>    = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)
<newname> = a single name component (no '/')

3. Behaviour

rename rewrites the directory entry's name in place - no blocks are read or freed, so it is cheap and reclamation-free. <newname> is one component: fs refuses a slash or a control byte (rename: failed - bad new name) - to move across directories, use move. Renaming to a name that already exists in the directory is a defined error (rename: failed - name already exists), not an overwrite. Success prints renamed <path> → <newname>; a request whose answer never came prints rename: OUTCOME UNKNOWN - ... and is not re-sent.

4. Implementation

Mutating, least-authority shape of the writers (19_write.md §4): fs edits the parent directory block (RENAME, op 15) and persists it.

5. Later (separate doc so it can grow)

  • Case/charset rules for names, once a real need surfaces.
  • Bulk rename / patterns - only if a genuine use pulls it in (§26.2).

6. Conformance

Built spec-first against 0_conventions.md: implements its own rename help / rename version.

Utility: delete - remove a file, directory, or whole subtree

Status: Built + QEMU-verified (osdev test files) on GSFS0003 - reclamation is intrinsic (delete clears the entry and frees its blocks in the bitmap). Removes a file or an empty directory by default; delete <path> recursive removes a non-empty directory and everything under it. Trails CLAUDE.md; does not amend it.


1. What it is

delete <path> removes a file or directory. It replaces POSIX rm - rm ("remove") is cryptic; delete says it plainly. It is destructive, so it is loud about what it does (§26.7): there is no trash, no silent undo - a delete is a delete.

2. Usage

delete 0.4.0 - remove a file, empty directory, or whole subtree

usage:
  delete <path>             remove the file/empty dir <path>
  delete <path> recursive   remove directory <path> and everything under it
  delete <a>,<b>,...        remove several (comma-separated; recursive applies to all)
  delete version            print the version
  delete help               print this message

<path> = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)

A comma list has no spaces and at most 16 items (LIST_MAX); a longer one is refused before anything is removed.

3. Behaviour

delete <path> removes the directory entry and frees the data blocks in the bitmap (GSFS0003 reclamation is intrinsic). A non-empty directory is a loud error by default - no accidental tree wipes - and the error names the opt-in (delete: failed - directory not empty (a non-empty directory needs 'delete <path> recursive')).

delete <path> recursive removes a non-empty directory and its whole subtree. The safe default is deliberate (mirrors mkdir … parents, copy … recursive): the destructive operation only happens when you spell it out. There is no trash and no undo (§26.7).

The root (/) is refused. Success prints deleted <path> (or deleted (recursive) <path>). A request whose answer never came is reported as OUTCOME UNKNOWN - the path MAY HAVE BEEN removed, or for a recursive delete may be PARTLY removed - and is not re-sent. A recursive delete can also run detached: background delete <path> recursive (55_background.md).

4. Implementation

Mutating, least-authority shape of the writers (19_write.md §4). Plain delete is fs Delete (op 16): unlink the entry, free its blocks. Recursive delete is fs DeleteTree (op 19): unlink the entry from its parent, then free the entry and every descendant with a depth-bounded post-order subtree walk (§26.6) - capped (MAX_TREE_DEPTH) and refused loudly past it, with small per-level stack frames (the 512-byte directory block is dropped before recursing). Path-length limits (PATH_MAX, the u8 wire path_len) bind well before the depth cap, so the cap is a backstop, not the everyday limit.

5. Later (separate doc so it can grow)

  • A [y/N] confirm for a recursive (or single-file) delete, mirroring flash's destructive-confirm (15_drives.md), if wanted.
  • delete + move are the two clients that prove the reclamation allocator frees correctly.

6. Conformance

Conforms: own delete help / delete version (with a real example, per 0_conventions.md).

Utility: find - search the tree for a name

Status: Built + QEMU-verified (osdev test files) on GSFS0003. A shell built-in that walks the directory tree client-side via the fs LIST_DIR op. Trails CLAUDE.md; does not amend it.


1. What it is

find <pattern> [path] searches a subtree - the whole filesystem (/) by default, or a given starting path - for entries whose name matches <pattern>, and prints each match's full path. The match mode is chosen by the pattern itself:

  • Plain word → substring. find report matches report.txt, 2026-report, etc.; find .txt lists every .txt. The friendly default.
  • Contains * or ? → glob (anchored to the whole name): * matches any run of characters (including none), ? matches exactly one. find *.txt matches names ending in .txt; find f? matches f1…f9 but not f10.

It is whole-filesystem enumeration: the one operation that reads across the entire tree rather than a single path or directory.

find 0.4.0 - search the tree by name (substring/glob; records when piped)

usage:
  find <name>            matches names containing <name>
  find <glob>            glob match: * = any run, ? = one char
  find <pattern> <path>  search only under <path>
  find … | <verb>        piped: records name/type/path
  find version           print the version
  find help              print this message

<path> = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)

Example:

gsh> find *.txt
/docs/inside.txt
/docs/sub/note.txt
find: 2 match(es)

As a record producer (typed pipes)

Bare find prints the matching paths (above); in a pipe it is a record producer (docs/records.md, utilities/31_records.md) emitting a typed table - columns name / type / path / size - so each hit's structure is filterable:

find *.txt | where type=file        only file hits (not matching directories)
find report | select path           just the paths
find * /docs | where type=dir       directories under /docs
find *.log | to json                the hits as JSON

This is the structured form of the planned type filter (§5): where type=file / where type=dir replaces a -type-style flag.

2. How it works - a tree walk (the tree is the index)

find is the disciplined realisation of "enumerate everything": it walks the directory tree (GSFS0003's tree is the index - docs/persistence.md §6.4), done client-side in the shell via repeated LIST_DIR calls. Each directory's entries are listed; a match is printed as it's found (results stream, no buffering of the whole result set); each subdirectory is pushed onto a pending stack to visit next. So fs needs no new op and there is no chunked-reply problem - the shell just keeps asking fs to list directories.

3. Bounds - loud, not silent (§26.6 / §3.12)

The walk holds a bounded stack of pending directories (FIND_QCAP, currently 32). A tree wide/deep enough to exceed it does not silently drop results - find prints find: search truncated … so the user knows the answer is partial. A directory too large to read to the end (past DIR_PAGE_MAX pages) prints find: INCOMPLETE - ...; one whose listing failed prints find: could not read <dir> - <reason>, the search goes on, and the summary adds find: N director(y/ies) could not be read - the answer is incomplete. Either way the command ends Err. A start path that is not a directory is refused: find: not a directory: <path>. The glob matcher is itself bounded: iterative backtracking (glob_match), no recursion and no allocation.

4. Why no fs_index yet

A global index (fs_index, docs/persistence.md §6.5) would let find skip the walk and jump straight to matches. It is deliberately not built (§26.2): find is exactly the need that would pull it into existence, but the correct first implementation is the tree walk - and for any normal tree the walk is fine. fs_index is the optimisation we add the day the walk is measured too slow on a genuinely huge tree, and it will sit behind this same find command (lazy, version-invalidated, rebuilt-from-truth - §6.5). Until then, find is honest and complete; it just walks.

5. Later (separate doc so it can grow)

  • Substring match - done (find report matches report.txt). Glob patterns - done (find *.txt, find f?): a pattern with */? switches to anchored glob matching, no -name flag - the pattern speaks for itself.
  • Type filter - done via the record pipe (find … | where type=file), not a flag.
  • fs_index-backed fast path when the tree-walk is measured too slow (§6.5).

6. Implementation shape & conformance

A shell built-in (like the other file commands) sending LIST_DIR to fs; fs holds all disk authority. Conforms: find help (usage with a real example per row) and find version (number + creator credit) per 0_conventions.md.

Conforms to rule 10 (0_conventions.md §1.10) since 2026-10-13 (backlog/fixed/89). It found 2026-10-09 that the fs handle was lent no notice; every fs handle the shell makes is now lent the command's watch (gs::call::Watch), so the command says [q] quit once it has run 2 s, and q, Q or ESC stops the command at its next request (osdev test quit measures find h2.txt / on a slowed fs).

Utility: tree - print the directory hierarchy

Status: Built + QEMU-verified (osdev test files) as a shell built-in. On hierarchical GSFS (docs/persistence.md). Trails CLAUDE.md; does not amend it.


1. What it is

tree [path] prints the directory hierarchy under path (default: the current directory) as an indented tree - the read-only companion to dir for seeing structure at a glance. It keeps the same name as the POSIX/util tree because the name is already plain and not cryptic.

2. Usage

tree 0.4.0 - print the directory hierarchy

usage:
  tree            tree of the current directory
  tree <path>     tree rooted at <path>
  tree version    print the version
  tree help       print this message

<path> = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)

3. Behaviour

  • Box-drawing, like Unix tree. Connectors ├── / └── mark each entry and │ / blank draw the continuation lines, so structure reads at a glance. A trailing / still marks directories (the console is monochrome - there's no colour to lean on).
  • UTF-8. The box glyphs (U+2500..U+253C) are emitted as UTF-8 and render on both the serial terminal and the display - the console service decodes UTF-8 and draws the box glyphs with procedural strokes (the antialiased Noto font it uses for text has no U+2500 block; procedural strokes also connect cell-to-cell exactly). See services/console/src/render.rs (cell_for_codepoint, box_arms). Unsupported codepoints render as ?, never silently dropped (§3.12).
  • The root line shows the path as given; deeper entries show their basename.
  • Ends with a blank line then a summary: N directories, M files (counting everything under the root).
  • A path that names a file prints just that file (then 0 directories, 1 file); a missing path is a loud error, tree: not found: <path> (§3.12).

Example:

gsh> tree /docs
/docs
├── a.txt
└── sub/
    └── b.txt

1 directory, 2 files

4. Implementation

Read-only, so no capability beyond the fs ListDir (op 14) it already uses for dir/find. It adds no new fs surface: the hierarchy is reconstructed client-side with the same bounded-walk discipline find uses (§26.6) - a fixed-capacity explicit stack, depth-first, no recursion. Every child (file or dir) is pushed so siblings nest correctly, and a directory's whole subtree drains before its next sibling (LIFO + reverse-push). If a tree is wider than the walk's capacity it reports truncation rather than silently dropping branches (§3.12), exactly like find. The walk's own limits are fixed: TREE_CAP (96) pending entries, TREE_FANOUT (64) children drawn per directory, and TREE_MAX_DEPTH (32) levels, which binds before the path-length limit (PATH_MAX) does. Hitting the depth or fan-out limit, a path too long to join, or a directory that could not be read to the end prints tree: stopped early - a LIMIT was reached ...; overflowing the stack prints tree: truncated - ....

The connectors come for free from the same DFS: each node carries whether it is its parent's last child (drives └── vs ├──), and a small level_last[depth] array records each ancestor's last-child flag for the │/blank prefix. Because the DFS finishes a subtree before its siblings, that array is always valid when a node prints - no recursion, no per-node prefix storage.

5. Later (separate doc so it can grow)

  • A depth limit flag (tree <path> depth <n>) if deep trees get noisy.
  • A size column, reusing the size dir already shows.

6. Conformance

Conforms: own tree help / tree version (with a real example, per 0_conventions.md).

Conforms to rule 10 (0_conventions.md §1.10) since 2026-10-13 (backlog/fixed/89). It found 2026-10-09 that the fs handle was lent no notice; every fs handle the shell makes is now lent the command's watch (gs::call::Watch), so the command says [q] quit once it has run 2 s, and q, Q or ESC stops the command at its next request (osdev test quit measures tree / on a slowed fs).

Utility: match - keep the lines that match (the grep-equivalent)

Status: Built + QEMU-verified (osdev test files). A shell built-in FILTER: the direct form match <pattern> <path>, the pipe form <producer> | match <pattern> (slots into the multi-stage middle), the except inverse, */? glob (shared with find), and minimal quoting. Trails CLAUDE.md; does not amend it.


1. What it is - and why not "grep"

match reads lines of text and keeps the ones that match a pattern; it drops the rest. It is the line filter every other system calls grep - a name that is the fossilised keystrokes g/re/p from the 1970s ed editor (global regular expression print). The name describes how it was once implemented, not what it does, and it is opaque to anyone who doesn't know the history (the same family as awk = its authors' initials and sed = stream editor). GodspeedOS names utilities for what they do (write not touch, delete not rm), so the line filter is match: a real English verb, and - as the user noted - the same sense as Rust's match (test a value against a pattern; the truthiness decides the outcome). If a line matches, it is kept; if it doesn't, it is dropped. One honest verb covers it.

2. Usage

match 0.4.0 - keep the lines that match a pattern

usage:
  <producer> | match <pattern>   keep piped lines matching <pattern>
  match <pattern> <path>         keep lines of <path> that match
  match except <pattern> [path]  keep the lines that do NOT match
  match "<two words>" …          quote a multi-word pattern
  match version                  print the version
  match help                     print this message

<pattern> = substring (default) | *? glob (like find)
<path>    = [index:]label/path | /abs | rel

3. Agreed behaviour

  • Two ways in, like find. A pipe sink (read /log | match error) and a direct form (match error /log). Both reach the same place; a person uses whichever reads better. This mirrors find <name> [path], so it is consistent, not bloat.
  • Matching reuses find's model. A plain word is a substring match (match error); a pattern with */? is an anchored glob (match *.txt). One mental model across find and match, no new rules. (Shares the contains / glob_match helpers.)
  • The inverse is except, a leading keyword. match except error keeps every line that does not match - no cryptic grep -v. except leads the pattern because trailing would read wrong ("match error except…?"); same shape as write append, and it is the keyword only when a pattern follows (so matching the literal word "except" stays possible). It is the same matcher with the boolean flipped - cheap, so it ships with the first cut.
  • Whole lines. Like grep, match keeps the whole line in which the pattern appears, not just the matched token.

4. Quoting (folded in with match)

match is the command that forces the quoting question: every argument is one whitespace-delimited token today, so match hello world is broken (pattern hello, stray world). The honest fix - not a POSIX reflex - is grouping a multi-word argument:

A token wrapped in a matching pair of '…' or "…" is one argument; the surrounding pair is stripped. No escapes, no nested quotes, no variable expansion; single and double behave identically.

That gives match "hello world" (the real need) and echo "I am text" → I am text (the visual bounding the user likes, quotes vanishing as expected), while deliberately stopping before bash's footguns ('can'\''t', $VAR, [[ ]]). The one honest cost: with no escape character, emitting a lone literal quote is fiddly - an acceptable trade for a system that isn't trying to be bash.

5. Implementation

A shell built-in filter: input bytes → matching lines out (match_lines, sharing find's contains + glob_match). In a pipe it consumes the previous stage's buffer (pipe_transform dispatches the stage, and a text filter lands in run_filter_builtin); as a built-in it runs in-process, so it is not subject to the 4 KiB service-boundary cap and can filter a full 16 KiB stage buffer. The direct form reads the file itself (gs::fs::Fs::read_into, streaming READ_AT) - no new fs surface - into a fixed FILTER_READ_MAX (8192-byte) buffer; a larger file is refused loudly (match: <path> is larger than 8192 bytes - too big to filter in one pass), so pipe it instead (read <path> | match ...). match is a FILTER, never a pipe producer: match … /file | … is refused (use read /file | match …). Minimal quoting lives in the shared tokenize/strip_quotes helpers, so it benefits every command, not just match.

6. Later (separate so it can grow)

  • match regex <expr> [path] - full regular expressions, gated behind the explicit regex word so a layperson never trips over ^$.*; only someone who asks for regex gets it. Default stays friendly (substring + glob). Needs a no_std regex engine, so it is a clear future opt-in, not the first cut.
  • Case-insensitive matching (an ignore-case keyword), if wanted.
  • count (how many matched) is done, as a pipe stage: ... | match error | count (28_count.md).

7. Conformance

Conforms to 0_conventions.md: its own match help (usage with a real example per row), the match except help subcommand help, and match version (number + creator credit), via the shared help_block helper.

Conforms to rule 10 (0_conventions.md §1.10) since 2026-10-13 (backlog/fixed/89). It found 2026-10-09 that the fs handle was lent no notice; every fs handle the shell makes is now lent the command's watch (gs::call::Watch), so the command says [q] quit once it has run 2 s, and q, Q or ESC stops the command at its next request (not measured on its own by osdev test quit; read, which reads a file the same way, is).

Utility: count - how many lines, words, and bytes

Status: Built + QEMU-verified (osdev test files). A shell built-in FILTER, like match: the direct form count <path> and the pipe form <producer> | count. Trails CLAUDE.md; does not amend it.


1. What it is - and why not "wc"

count reports how many lines, words, and bytes its input has. It is the tool every other system calls wc ("word count") - a cryptic abbreviation that doesn't even name its most common use (counting lines). GodspeedOS names utilities for what they do (write not touch, match not grep), so the counter is count: a plain verb a layperson reads correctly. Its natural partner is a pipe - "how many?" after a filter: find *.txt | count, read /log | match error | count.

2. Usage

count 0.4.0 - count lines/words/bytes (byte stream) or ROWS (record stream)

usage:
  <producer> | count   count piped bytes, or rows of a record stream
  count <path>         count a file
  count version        print the version
  count help           print this message

<path> = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)

3. Behaviour

Output is one labelled line: N lines, M words, K bytes (singular when a count is 1, e.g. 1 line, 1 word, 6 bytes). Unlike wc's three bare numbers, each is named - no guessing which column is which.

  • lines - newline count, plus one for a final unterminated line (so a file with no trailing newline still counts its last line).
  • words - runs of non-whitespace bytes.
  • bytes - the raw size.

On a record stream it counts ROWS and prints the bare number (status | count → 14), since lines, words and bytes mean nothing for a table.

count consumes input; it is never a pipe producer. In a pipe it is normally the last stage (it collapses many lines into one summary), but being a filter it can also feed onward (find *.txt | count | write /n.txt writes the summary to a file).

4. Implementation

A shell built-in FILTER (run_filter_builtin, alongside match): it runs in-process, so it is not subject to the 4 KiB pipe service-boundary cap and can count a full 16 KiB stage buffer. The pipe form consumes the previous stage's buffer (write_count in run_filter_builtin); the direct form reads the file itself (gs::fs::Fs::read_into, streaming READ_AT) - no new fs surface - into a fixed FILTER_READ_MAX (8192-byte) buffer, and refuses a larger file loudly rather than counting part of it (pipe it instead: read <path> | count). count with neither a path nor piped input prints count: a path is required (or pipe input: <producer> | count).

5. Later (separate so it can grow)

  • count lines / count words / count bytes - emit just the one bare number, for when the value feeds something else (a future filter that takes a count).
  • Counting across multiple files, once a multi-file producer exists.

6. Conformance

Conforms to 0_conventions.md: its own count help (usage with a real example per row) and count version (number + creator credit), via the shared help_block helper.

Conforms to rule 10 (0_conventions.md §1.10) since 2026-10-13 (backlog/fixed/89). It found 2026-10-09 that the fs handle was lent no notice; every fs handle the shell makes is now lent the command's watch (gs::call::Watch), so the command says [q] quit once it has run 2 s, and q, Q or ESC stops the command at its next request (not measured on its own by osdev test quit; read, which reads a file the same way, is).

Utility: sort - order the lines

Status: Built + QEMU-verified (osdev test files). A shell built-in FILTER, like match/count: the direct form sort <path> and the pipe form <producer> | sort. Trails CLAUDE.md; does not amend it.


1. What it is

sort orders its input lines and prints them. Unlike grep/wc, the POSIX name sort is already plain English, so it keeps it - naming for what it does is the rule, not renaming for its own sake. Its natural use is a pipe (find *.txt | sort, read /names | sort reverse).

2. Usage

sort 0.4.0 - order the lines (ascending, or reverse)

usage:
  <producer> | sort         sort piped lines
  sort <path>               sort a file's lines
  sort reverse [path]       sort descending
  sort version              print the version
  sort help                 print this message

<path> = [index:]label/path | /abs | rel   (see docs/drives.md §4.1)

3. Behaviour

Lines are ordered lexicographically by bytes (ascending), or descending with reverse. reverse is a keyword wherever it appears after the verb (sort reverse /f and sort /f reverse both work), so it reads the same piped or direct. Blank lines are dropped. Equal lines keep no defined relative order (the sort is unstable - there is no heap for a stable merge), but since they are equal that is invisible.

Bounded (§26.6): sort orders the first SORT_MAX_LINES (1024) lines and says so if there are more - it never silently drops the rest.

sort consumes input; it is never a pipe producer. Being a filter it can sit mid-pipe (find *.txt | sort | count) or end one.

On records - sort by a column

sort spans both pipe worlds (docs/records.md): on a text stream it line-sorts as above; on a record stream (e.g. after status) it orders rows by a column - sort mem, sort reverse name. The column name follows the verb, with reverse leading so the column lands at the end and nothing dangles (sort reverse mem, same shape as match except). The comparison is numeric when the column's values are numeric, else textual. The record verbs proper (where/select/to/from) live in utilities/31_records.md; sort is documented here because its text form predates them.

4. Implementation

A shell built-in FILTER (run_filter_builtin, with match/count): it runs in-process, so it is not subject to the 4 KiB pipe service-boundary cap and can sort a full 16 KiB stage buffer. It records each line as a (start, end) pair into a fixed SORT_MAX_LINES array on the stack and sort_unstable_bys the index array (no heap - sort_unstable is in-place), then emits the lines in order. The direct form reads the file itself (gs::fs::Fs::read_into, streaming READ_AT) - no new fs surface - into a fixed FILTER_READ_MAX (8192-byte) buffer, and refuses a larger file loudly (pipe it instead: read <path> | sort). With neither a path nor piped input it prints sort: a path is required (or pipe input: <producer> | sort).

5. Later (separate so it can grow)

  • sort unique - drop duplicate lines (needs a stable or post-pass dedupe).
  • sort number - numeric order rather than lexicographic.
  • A larger / streaming sort once true pipe streaming lands (the 1024-line bound is the store-and-forward limit, not a fundamental one).

6. Conformance

Conforms to 0_conventions.md: its own sort help (usage with a real example per row), the sort reverse help subcommand help, and sort version (number + creator credit), via the shared help_block helper.

Conforms to rule 10 (0_conventions.md §1.10) since 2026-10-13 (backlog/fixed/89). It found 2026-10-09 that the fs handle was lent no notice; every fs handle the shell makes is now lent the command's watch (gs::call::Watch), so the command says [q] quit once it has run 2 s, and q, Q or ESC stops the command at its next request (not measured on its own by osdev test quit; read, which reads a file the same way, is).

Utilities: first / last - keep the first or last N lines

Status: Built + QEMU-verified (osdev test files). Two shell built-in FILTERS, like match/count/sort: the direct form first [N] <path> / last [N] <path> and the pipe form <producer> | first [N] / | last [N]. Trails CLAUDE.md; does not amend it.


1. What they are - and why not "head" / "tail"

first N keeps the first N lines of its input; last N keeps the last N. Other systems call these head and tail. Those names are not ciphers like grep/wc - they are guessable metaphors (a list has a head and a tail), so they are defensible. But first/last clear the project's bar - "would a layperson guess right with no prior knowledge?" - with zero metaphor to learn: read /log | last 20 is unambiguous to someone who has never touched a terminal. They pair as cleanly as head/tail, and read naturally with the count.

2. Usage

first 0.4.0 - keep the first N lines (default 10)
last  0.4.0 - keep the last N lines (default 10)

usage:
  <producer> | first [N]   first N piped lines
  <producer> | last [N]    last N piped lines
  first [N] <path>         first N lines of a file
  last  [N] <path>         last N lines of a file
  <first|last> version     print the version
  <first|last> help        print this message

N defaults to 10.   <path> = [index:]label/path | /abs | rel

3. Behaviour

N is optional and defaults to 10 (read /log | last is "the last few"). A numeric arg is the count, a non-numeric arg is the path, in either order (first 20 /log = first /log 20). Blank lines are skipped (consistent with the other line filters).

  • first streams the first N lines in one pass - no buffer, any N.
  • last keeps the most recent TAKE_MAX (1024) line spans in a ring buffer, so it is correct even for input far larger than the ring; an N beyond 1024 is capped with a loud note (§26.6). The classic "watch a growing log" follow mode (tail -f) belongs to true pipe streaming - a deliberate future, see docs/pipes.md.

Both consume input; neither is a pipe producer. Being filters they compose: find *.txt | sort | first 5, read /log | match error | last 20.

4. Implementation

Shell built-in FILTERS (run_filter_builtin, with match/count/sort): they run in-process, so they are not subject to the 4 KiB pipe service-boundary cap and can take from a full 16 KiB stage buffer. cmd_take(last: bool) serves both verbs; the pipe path routes through run_filter_builtin. The direct form reads the file itself (gs::fs::Fs::read_into, streaming READ_AT) - no new fs surface - into a fixed FILTER_READ_MAX (8192-byte) buffer, and refuses a larger file loudly (pipe it instead: read <path> | last 20). On a record stream both refuse with a sentence pointing at where/select/sort <col>.

5. Later (separate so it can grow)

  • A follow mode for last (stream new lines as a file grows), once true pipe streaming lands.
  • Byte/character ranges, if a need appears.

6. Conformance

Both conform to 0_conventions.md: their own first/last help (usage with a real example per row) and version (number + creator credit), via the shared help_block helper.

Utilities: where / select / to / from - the record-pipe verbs

Status: Built + QEMU-verified (osdev test shell). Four shell built-in pipe stages that operate on typed records (a Table), not text. They are the user-facing surface of the structured-pipe subsystem; the design rationale and the model live in docs/records.md. Trails CLAUDE.md; does not amend it.

sort is documented in 29_sort.md - it spans both worlds (line-sort on text, column-sort on records), so it lives with the text filters and is only cross-referenced here.


1. What they are - and why a fresh family

POSIX pipes carry text, so the structure a producer knew (ls knew name/size/type) is flattened to a string and grep/awk/cut/sed exist to claw it back. GodspeedOS keeps the structure: a record producer (today status) emits a typed table, and these verbs operate on real fields. The names say what they do with no POSIX heritage to learn (§ conventions rule 8):

  • where - keep the rows whose field matches a predicate.
  • select - keep only some columns, in order.
  • to - render the records to a format (json, yaml) for the terminal or export.
  • from - parse text into records (the bridge from the byte world).

They are pipe-only stages - there is no where /file. They appear in a pipeline after a record producer (status | where …, dir | where …) or after from (read x.json | from json | where …). The shell-side record producers include status (task roster, slot/name/core/state/mem/queue/restarts), dir (name/type/size/sealed), caps (resource/rights), drives (index/label/status/size_mib/free_mib), find (name/type/path/size), observe now (the roster plus a ticks cumulative-cpu-time column - observe now | sort reverse ticks is the native "top"), and uptime, events, trace, jobs and wifi list (is_record_producer and 0_conventions.md rule 12 have the full list). One record producer is a SERVICE, roster (examples/roster). Only the one-shot observe now is pipeable; the bare live observe owns the screen and refuses to be piped (a loud error, docs/records.md).

2. Usage

where  0.4.0 - keep records whose field matches
select 0.4.0 - keep only some columns, in order
to     0.4.0 - render records to a format
from   0.4.0 - parse text into records

usage:
  <records> | where <col><op><val>   ops: = != > < >= <=, and the word `contains`
      e.g. status | where mem>0
  <records> | select <col> [col…]    project the named columns, in order
      e.g. status | select name core state
  <records> | to <json|yaml|grid>    render to JSON / YAML / the plain table at the edge
      e.g. status | where mem>0 | to yaml
  <text>    | from <json>            parse text → records (the bridge)
      e.g. read /svc.json | from json | where core=1
  <verb> version                     print the version
  <verb> help                        print this message

3. Behaviour

where <col><op><val> - the compact predicate

The predicate is one token, operator attached (no spaces, no quotes unless the value has a space): where mem>0, where state=BlockRecv, where name!=shell, where core>=1. The parser finds the operator inside the token by longest match (!= >= <= before = > <): before it is the column, after is the value. Ops: = != > < >= <= contains (the spaced word; ~ is NOT accepted - rule 3 rejects synonyms). The comparison is numeric when both sides parse as numbers, else textual. An unknown column prints where: no such column '<col>', and the rows pass on unfiltered.

select <col> [col…] - projection

Keep only the named columns, in the order given (select name core state reorders too). An unknown column is a loud error (select: no such column (check the column names)) and stops the pipeline.

to <json|yaml|grid> - edge renderers

to json emits a JSON array of objects; to yaml emits a YAML list of mappings; to grid emits the plain aligned table, for a producer that draws something else on the console (trace deps fs | to grid). These live at the edge of the pipeline - terminal output or, piped to write, export. A record never is JSON; JSON is one way to print it. With no to, the default rendering is the aligned table grid.

sum / min / max / avg <col> - reduce a numeric column

Four more pipe-only stages collapse a record stream into one bare number: the total, smallest, largest, or mean of a numeric column (status | sum mem, status | avg queue). The mean is an integer (truncated). A missing column (sum: no such column 'x') or a non-numeric one (sum: column 'x' is not numeric (never a silent 0)) is loud, and on a byte stream they refuse and point at from json. Each has its own help and version. One gap: over ZERO rows all four answer 0 (Table::aggregate), which for min and max is a number no row had.

from <json> - the bridge into the typed world

read gives text; where/select need records. from json parses a flat [ {…}, … ] array (string / number / true|false / null, no nesting; the first object defines the columns) into the table model. read deliberately does not auto-parse by extension

  • that would be hidden behavior (§26.5); from is the explicit, symmetric partner of to. This crosses the byte ↔ record boundary in the unified pipeline (docs/records.md).
status | where state=BlockRecv | select name core
status | where mem>0 | sort reverse mem | to json
read /svc.json | from json | where core=1 | to yaml | write /c1.yaml

4. Implementation

Pipe stages in the unified dispatcher (pipe_transform): one pipeline threads a Stream = Bytes(Cap) | Table(Table), and each stage is chosen by its command and by which it currently holds - so from/to flip between the two worlds and where/select only accept a Table. A mismatch (e.g. where on raw bytes) is a loud error pointing at the fix. The model is bounded (§26.6): REC_MAX_COLS (8), REC_MAX_ROWS (64), a fixed REC_ARENA (4096) for interned strings, REC_COL_NAME (24) - all on the stack, no heap, loud on overflow. No new fs or kernel surface: these operate on data already in the pipeline.

5. Later (separate so it can grow)

  • A JSON string-escaper (values are plain ASCII today); from yaml; record streams larger than one IPC message (chunked encode). The SDK record API and the bounded wire codec (encode/decode, emphatically not JSON on the wire, docs/records.md) are done - examples/roster is a record-producing service (roster | where role=core, no from json).
  • Heterogeneous (differently-shaped) records.

6. Conformance

All four conform to 0_conventions.md: each has its own where/select/to/from help (usage with a real example per row) and version (number + creator credit), via the shared help_block helper. Being pipe-only stages, they have no direct-file form; the help makes the <records> | / <text> | prefix explicit so the grammar is unambiguous.

Utility: result - the previous command's result (Ok / Err)

Status: Built + QEMU-verified (osdev test files). The first slice of the shell's command-result model. Trails CLAUDE.md; does not amend it.


1. What it is

Every shell command now produces a Result - Rust's, not a POSIX exit code. Success is Ok; failure is Err(ShellError). result prints the previous command's result:

gsh> read /notes.txt
…contents…
gsh> result
Ok
gsh> read /nope
read: not found: /nope
gsh> result
Err(FileNotFound)

It is deliberately not a number and not an "exit code". Nothing exits between commands - the shell stays alive and result asks "how did the last one go?" (exit/code are the POSIX reflexes we drop, same family as exec→run; 0_conventions.md §8.) The value reads exactly like a Rust Result: Ok, or Err(<Variant>).

2. The model - Ok is the check

execute() returns Result<(), ShellError>. The common path is just "is it Ok?" - a caller (a future run aggregating pass/fail, or assert) never needs to know the variant names. The variants exist for when you do want to pin a specific failure (negative tests):

  • Ok is the check. is_ok() answers "did it work?" for 90% of uses.
  • Variants are opt-in. Err(FileNotFound) names the category when you care; the human-readable detail (the path, etc.) was already printed by the command itself.
  • Unknown is the catch-all. A failure not yet given its own variant is Err(Unknown), so every failure is at least that. Variants are added one at a time as commands are converted.

ShellError variants are unit (no payload): no_std/no-heap means a variant can't own a String, and the detail belongs in the printed message anyway. The enum is the category; the message is the detail.

3. Behaviour

  • result reports the immediately preceding command. It is itself a command that succeeds, so running result twice shows the real result, then Ok (the first result's success).
  • A blank line is not a command - it leaves the last result unchanged.
  • A number is never involved. (If an external host tool ever needed one, it would be derived from the variant at the very edge - like date epoch - never the source of truth. Not built.)

4. Conversion status (incremental)

Every command is on the Result model. execute() returns the command's genuine Result - there is no Ok-wrapping that could hide a failure:

  • File/storage - read, dir, cd, write (+append), mkdir, copy (+recursive), move, rename, delete, find, tree, and the filter built-ins' direct form match/count/sort/first/last: Ok on success, Err(FileNotFound) for a missing path, Err(Unknown) otherwise.
  • Service-control - spawn/kill/restart: Err(Denied) for a protected core / session-critical service (so assert fails spawn supervisor holds), Err(Unknown) otherwise.
  • Introspection - caps <service> → Err(FileNotFound) for a name that isn't live; observe/observe now → Err(Unknown) if the view fails to spawn.
  • drives - list / flash / label / reset → Err on no disk, a bad selector, an unknown subcommand, or a declined [y/N].
  • Pipelines - pipe_run returns the pipeline's Result (a | assert sink sets it; a stage error is Err).
  • An unknown command is Err (so assert fails typo holds; a typo in a script counts).
  • The info commands (echo/about/mem/cores/date/status/clear/help) genuinely can't fail, so they return Ok - uniformly on the model, not specially wrapped. (A reboot that happens never returns; reboot cancel, reboot 10m and the other deadline forms return Ok or Err like any command.) The exception is date sync, which returns Err(Unknown) for every way the sync did not happen (no time from the network, the clock service did not answer, and the others).

ShellError variants in use: FileNotFound, Denied, AssertFailed, Unknown. New ones are added as a failure earns its own name (StorageUnavailable, EndpointDead, … reusing kernel names where they surface - §7.7).

5. Later (separate so it can grow)

  • Converting the commands is done (§4); what remains is giving more failures their own variant instead of Unknown.
  • run <script> - built (utilities/33_run.md): executes a file of commands, aggregating result into "ran N, failed M".
  • assert - built (utilities/34_assert.md): assert ok/fails <cmd> (result) and … | assert contains X (content), so a script self-verifies the guardrails and the output.

6. Implementation shape & conformance

execute() threads the previous result as local session state (no global - services hold no global mutable state, §3.9) and returns the new one; the main loop stores it. result is a shell built-in. Conforms to 0_conventions.md: its own result help / result version via the shared help_block, listed under Console in the top-level help.

Utility: run - execute a script of commands

Status: Built + QEMU-verified (osdev test files). The shell script runner - the way to replay a suite of commands without typing them (the point: validate on hardware). Trails CLAUDE.md; does not amend it.


1. What it is

run <path> reads a script file and executes each command exactly as if you typed it at the prompt. It is the trivial, sequential case of Appendix D.2 ("cmd1; cmd2") - a command-list runner in its simplest use - but the gsh LANGUAGE is complete and this said it did not exist. let / let mut, if / else if / else, switch, fn with parameters and bounded recursion, import, arithmetic and $() capture are all built (docs/scripting.md, whose own status line reads COMPLETE). Appendix D's "far-future" framing was overtaken.

gsh> run /suite.gsh
> read /lsr/big.txt
hello world
> result
Ok
> read /lsr/nope
read: not found: /lsr/nope
> result
Err(FileNotFound)
--- summary ---
PASS  read /lsr/big.txt
PASS  result
FAIL  read /lsr/nope
PASS  result
--- failures ---
FAIL  read /lsr/nope
run: ran 4, failed 1, skipped 0

run <path> <args...> passes the words after the path to the script as $arg1.., $args and $argcount ($self is the path); run <path> save <out> also writes the run report to a file.

Each command is echoed (> cmd) before it runs, so the serial transcript is self-documenting - exactly what you want when eyeballing a run on the T630.

2. The script format (.gsh)

  • Lines are split on newline; a non-comment line is further split on ; into commands. So a script is either real multi-line, or cmd ; cmd ; cmd on one line. On-device, edit (36_edit.md) writes real multi-line scripts; osdev script-disk bakes one host-side.
  • # comments - a # that starts a line, or follows whitespace outside quotes, starts a comment to the end of the line. Annotate freely.
  • Blank lines are skipped.
  • .gsh is a naming convention (GodspeedOS shell; .gs is reserved for the future general-purpose Godspeed language), not a mechanism - run does not care about the extension (extension-driven behaviour is the hidden magic §26.5 forbids; cf. read not auto-parsing).

3. Result and the summary

run uses the command Result model (32_result.md): after every command it tracks Ok/Err, and prints a summary - a --- summary --- list of PASS/FAIL per statement, a --- failures --- list (and --- skipped --- when anything declined to run), then the tally -

run: ran N, failed M, skipped K

run itself is Ok iff every command was Ok (so result after a run tells you whether the whole script passed). Today a failing line is one that errors (a missing file, a bad column, …). Verifying correct output (not just "didn't error") is the job of assert (34_assert.md) - … | assert contains X, assert fails read /nope - which reads the same Result.

4. Bounds & safety (loud, never silent - §26.6 / §3.12)

  • A script is one fs file, streamed in and minified as it loads (comments, blank lines and indentation stripped), so SCRIPT_MAX (7112 bytes = 2 x IO_CHUNK) bounds the CODE, not the raw file. A script whose code does not fit is refused, and none of it runs (run: script CODE exceeds 7112 bytes - refused, none of it was run ...). A read that fails part way is refused the same way (run: could not read all of <path> - ...).
  • Scripts cannot nest. A run inside a script is refused (run at depth > 0). This is a hard rule, not a nicety: unbounded run-calls-run recursion would overflow the bounded user stack (execute/pipe_run are #[inline(never)] so the per-line nesting stays shallow - the same stack discipline the record builders needed).
  • A missing or empty script is run: not found or empty: <path> and Err(FileNotFound).

5. Later (separate so it can grow)

  • assert - built (utilities/34_assert.md): assert ok/fails <cmd> and … | assert contains X, so a script self-verifies instead of being eyeballed.
  • Image-baked .gsh - built: osdev script-disk <out> <script> bakes a script into a GSFS data disk host-side (gsfs_add_file); dd it to the data drive and run /suite.gsh on hardware, no on-device authoring. osdev test script proves the loop (incl. piped asserts).
  • Multi-line authoring on-device - done: edit (36_edit.md).

6. Implementation shape & conformance

A shell built-in: streams the file in with READ_AT (op 26, like read), minifying as it goes, resolves import / from ... import at load time, then runs each statement through the gsh interpreter (run_lines), which hands each command to the same execute() the prompt uses - so pipes, record verbs, everything compose for free. Threads the per-line Result as local state (no global, §3.9). Conforms to 0_conventions.md: its own run help / run version via the shared help_block, listed under Console in the top-level help.

Utility: assert - verify a result or output (the test verb)

Status: Built + QEMU-verified (osdev test files). The top rung of the script-testing ladder (result → run → assert): it lets a .gsh script self-verify, so the device prints PASS/FAIL instead of you reading the serial. Trails CLAUDE.md; does not amend it.


1. What it is

assert checks a claim and produces a command Result (32_result.md): Ok + assert: ok if the claim holds, Err(AssertFailed) + an assert: FAILED (…) line if it doesn't. Inside a run script those Errs are what the ran N, failed M summary counts - so a suite grades itself. Two forms, because tests check two different things:

  • Result form - did a command succeed/fail? (catches errors, incl. negative tests)
  • Content form - did the output contain the right thing? (catches wrong-but-valid output)
gsh> assert ok read /notes.txt          # must succeed
assert: ok
gsh> assert fails read /nope            # must FAIL (a negative test)
read: not found: /nope
assert: ok
gsh> roster | where role=core | assert contains Matthew
assert: ok

2. Result form - assert ok|fails <command>

Runs <command> (its own output/errors print as usual), then judges its Result:

  • assert ok <command> - holds iff the command returned Ok.
  • assert fails <command> - holds iff it returned Err (any failure).
  • assert fails-with <Variant> <command> - holds iff it returned Err(<Variant>) specifically (e.g. assert fails-with FileNotFound read /nope, assert fails-with Denied spawn supervisor).

assert fails … is the negative-test surface - §22's discipline that every test has a positive and a negative case, runnable on hardware: assert fails read /nope proves the guardrail refuses, and fails-with pins which refusal (the variant names are in 32_result.md: FileNotFound, Denied, …).

3. Content form - <producer> | assert <check> [text]

The verifying pipe sink: it materialises the piped stream to text (a record Table renders to its grid) and checks it. Because it's the last stage, its verdict is the pipeline's Result.

CheckHolds when
contains <text>the output contains <text>
lacks <text>the output does not contain <text>
emptythe output is blank - for records, there are no rows (a table's header line does not count)
roster | where role=core | assert contains Matthew
dir / | assert lacks secret
find *.tmp | assert empty

This is what verifies correctness, not just absence of errors: roster | where role=core returns Ok whether or not it found Matthew; | assert contains Matthew is what actually checks it.

4. In a script - the self-checking suite

# /check.gsh
assert ok    read /lsr/big.txt
assert fails read /lsr/nope
gsh> run /check.gsh
> assert ok read /lsr/big.txt
hello world
assert: ok
> assert fails read /lsr/nope
read: not found: /lsr/nope
assert: ok
--- summary ---
PASS  assert ok read /lsr/big.txt
PASS  assert fails read /lsr/nope
run: ran 2, failed 0, skipped 0

run: ran N, failed 0 is the green bar - the T630 telling you the suite passed, no eyeballing.

Authoring a suite with piped asserts. A script line containing | can't be written via the on-device write (the shell pipes the write line itself). On-device, edit (36_edit.md) writes such a line like any other text. Host-side, the answer is baking: osdev script-disk build/suite.img my_suite.gsh produces a GSFS data disk with the suite baked in; dd it to the data drive, boot, and run /my_suite.gsh. osdev test script runs exactly this loop in CI (a suite full of piped asserts → ran 6, failed 0). Result-form asserts (no |) can also be authored on-device with write … cmd ; cmd.

5. Bounds & safety (§26.6 / §3.12)

  • A failed assert is loud (assert: FAILED (contains 'Matthew')) and returns Err(AssertFailed)
    • never a silent miss.
  • The content sink materialises the stream into one bounded Cap (held off pipe_run's frame - assert_stream is #[inline(never)], the same user-stack discipline pipes/run need).
  • The result form runs the command one nesting level deeper (execute is #[inline(never)], so the frame stays shallow).

6. Later (separate so it can grow)

  • assert fails-with <Variant> is built (§2); what it still needs is more commands naming a specific variant rather than Unknown.
  • More content checks (is <text> exact, lines <N>), if a suite needs them.
  • Host-side image-baked .gsh - built (osdev script-disk, osdev test script): a suite of piped asserts ships on a GSFS data disk and runs from a script on hardware.

7. Implementation shape & conformance

assert ok/fails is dispatched in execute (it re-runs the command via execute); … | assert is a sink recognised in pipe_run (which now returns the pipeline Result). Both print a terse verdict via one assert_verdict helper. Conforms to 0_conventions.md: own assert help / assert version via the shared help_block; listed under Console in the top-level help.

Utility: fcap

Utility: fcap - file-as-capability self-check Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

fcap answers is a file really a capability? - it is the executable proof of the file-as-capability model (§7.10, the P2 amendment; docs/persistence.md §7.2). In GodspeedOS a file is not a path you have permission to, nor a handle the filesystem trusts you to use honestly: opening a file mints a real, kernel-minted capability for it, and every read or write is a validated invocation of that capability - the same unforgeable, non-escalating, revocable machinery as any IPC-endpoint cap.

fcap is a diagnostic, not a file tool. It creates its own throwaway file, exercises every property the capability model promises against it, prints one line per check, then deletes the file. It never touches a file of yours and takes no path argument - so it is safe to run any time as a "does the file-cap model still hold end-to-end?" probe.

2. Invocation

CommandMeaning
fcapRun the self-check against an internal throwaway file.
fcap helpPrint usage.
fcap versionPrint the version (uniform across utilities).
fcap reuseMint a file cap, restart fs, put a DIFFERENT file in the freed blocks, and prove the old cap reaches nothing (the stale-handle, confused-deputy question). Ends fcap reuse: ok - ....
fcap gsreuseThe same restart question asked through the standard library (gs::fs / gs::cap).

fcap takes no path - passing one is refused (fcap: takes no argument …). It uses its own files in the shell's scratch folder (/system/scratch/shell/fcap-selftest, then fcap_append and fcap-gs) and removes them, so it is leak-free and re-runnable. reuse and gsreuse are listed in fcap help and tab-complete; both kill fs, and reuse uses /ru_old.txt and /ru_new.txt at the root.

3. Output

gsh> fcap
fcap: opened rw (file cap)
fcap: write via cap OK
fcap: read via cap OK
fcap: ro-cap write rejected by kernel (non-escalation)
fcap: fs refused write under read right (op<=right)
fcap: forged handle rejected
fcap: cap revoked after close
fcap: cap revoked after rename
fcap: opened append-only (file cap)
fcap: append-only writes moving FORWARD accepted
fcap: rewriting earlier bytes through an append-only cap DENIED
fcap: a later forward write still accepted after the refusal
fcap: gs::cap wrote and read the file THROUGH the capability
fcap: gs::cap non-escalation holds - a READ cap cannot write
fcap: gs::cap an append-only file carries WRITE alone, and closes
fcap: all file-capability checks passed

Each line is one verified property. Any FAIL line (and a non-zero result) means the file-capability model is broken - a constitutional regression, not a cosmetic one.

4. What it verifies

The checks map one-to-one onto the §7.3 capability properties, applied to a file:

CheckProperty pinned
read / write through the capA file is a capability - operated by invoking it, not by naming a path the fs trusts (§7.10).
read-only cap's write rejected by the kernelNon-escalation at the kernel layer - the cap lacks WRITE, so the invocation never reaches fs (CapInsufficientRights).
fs refuses a write op sent under a read-validated badgeNon-escalation at the fs layer - op ≤ right; even a correctly-validated read cap can't smuggle a write (FS_DENIED).
fabricated handle rejectedUnforgeable - only the kernel mints valid caps; a made-up handle is not one.
cap stale after closeRevocable - closing the file revokes the resource (generation bump); the next use is stale.
cap stale after renameRevocable on path rebinding - renaming revokes a still-open cap so it can never silently rebind to a different file later created at the old path (confused-deputy avoidance, §7.10).
append-only cap: forward writes accepted, rewriting earlier bytes deniedAn append-only right is enforced by fs against the resource's own high-water mark, and a refused write does not break it.
the same through gs::capThe standard library speaks the same protocol, on the same tag counter.

The unforgeable-badge detail: the right that authorises a file-cap invocation is carried to fs in an unforgeable, kernel-set Message field, not in the client's payload - so a client cannot fake a file-cap operation over its ordinary fs send cap.

5. Capabilities

  • A narrow SEND cap to fs (the shell already holds it for read/write/drives) - fcap mints and invokes file caps through fs; fs enforces all disk authority.
  • RESOURCE_MINT is not held by the shell - only fs mints delegated resource caps (§3.1, §7.10). fcap receives the minted file cap from fs on open; it cannot mint its own.
  • Console output to print the per-check lines.

6. Non-goals

  • Not a file editor or viewer. fcap never reads, writes, or deletes a file of yours - use read / write / delete for that. It only touches its own throwaway file.
  • No options or paths. No flags (§ 0_conventions.md); the self-check takes no argument, and reuse / gsreuse are its only words. It is a self-check, not a configurable tool.

7. Conformance

Conforms: own fcap help (usage with real examples per 0_conventions.md) and fcap version (number + creator credit), listed by the shell's top-level help. Pinned end-to-end by osdev test file-cap (§22 Test 14 - every line above is asserted), and run in-memory by the shell's selfcheck suite. Hardware-proven on the HP T630 (§23.3). See 0_conventions.md §3, CLAUDE.md §7.10 / §22 Test 14, and docs/persistence.md §7.2.

Utility: seal - freeze a file's content, permanently

Status: Built. Verified in QEMU by osdev test fs-time (15/0), which seals a file, proves the write is refused, reboots the machine, and proves it is still refused and the content is still the original.

seal <path> [yes]

Freeze a file's content. After this the file can be read, listed, renamed, moved and deleted - but its bytes can never change again.

It asks [y/N] first, because there is no unseal. seal <path> yes skips the prompt - a confirm reads the console, so a script cannot answer one, and the warning still prints either way. What yes buys is not silence, it is the ability to be automated.

Why there is no unseal

A seal a holder can lift is not a guarantee, it is a request. The entire value of the flag is that nothing short of deleting the file undoes it: an auditor reading a sealed file does not have to establish who had write access in the meantime, because nobody did.

That is also why seal is the only command here that asks before acting on a single file. Every other one can be undone by doing the opposite.

Sealing an already-sealed file SUCCEEDS

seal on a file that is already sealed returns Ok and changes nothing.

This was decided in the code and never written down here, which is how it was found. The differential oracle (osdev test fs-model, docs/gsfs-carnage.md §3.2) builds a model of the filesystem from THIS page and compares it against the real one. The model predicted a refusal - a reasonable reading of "there is no unseal", and wrong. fs has carried if e.sealed { return Ok(()); } // idempotent: already frozen all along; a reader of this page had no way to know.

Idempotent is the right answer, and worth stating rather than merely recording:

  • seal asserts an invariant, it does not perform an event. The invariant is "this file's bytes never change again". If it already holds, the caller has what they asked for.
  • An error should mean something went wrong. Nothing did. A script that seals defensively - because it cannot know whether an earlier run got that far - would have to special-case a failure that reports success-by-another-name, and that is the kind of refusal CLAUDE.md §26.7 is about from the other direction.
  • It is also the only choice that composes with a crash. seal commits the ro_compat bit before the record flag, so an interrupted seal can leave the volume announcing the feature without the file being sealed; re-running must then be able to finish the job rather than refuse it.

The one thing it does NOT do is re-confirm: the [y/N] prompt still appears, because the command cannot know the file is already sealed until it has asked fs.

What it does NOT promise

Stated plainly, because a security feature that is vague about its edges is worse than one that is narrow and clear:

  • It does not stop deletion. A seal freezes CONTENT, not existence, and deleting a file needs authority over its parent directory rather than over the file. Refusing deletion would make a sealed file unremovable, so a disk could be filled with rubbish nobody is permitted to clear - a denial of service bought with a guarantee nobody asked for.
  • It does not stop renaming or moving. The bytes are what is frozen. Archiving a sealed log into another directory is a reasonable thing to want, and it changes nothing about the content.
  • It is not encryption. A sealed file is as readable as any other, to anyone who can read the disk.

Output

gsh> seal /audit.log
seal /audit.log - its content can NEVER be changed again, and there is no unseal.
 Seal it? [y/N]: y
sealed /audit.log

gsh> write /audit.log tampered
write: failed - file is sealed - its content cannot be changed

gsh> dir /
/
  NAME                  TYPE       SIZE  MODIFIED
  audit.log             seal      812 B  2026-09-17 07:33
  1 entries

The refusal says WHY, and did not always. It read write: failed (bad path, or parent missing?) until the audit that followed this utility shipping: fs knew the reason, logged it, and dropped it at the reply, so sealing a file and then writing to it sent the operator hunting for a typo in a path that was perfectly correct. A failure that misdirects is worse than one that says nothing (CLAUDE.md §26.7). The reason now rides back after the unchanged error byte.

A sealed file shows as seal in the TYPE column of dir, not as a marker beside file. It is less an attribute of a file than a different kind of thing to have on a disk - one you cannot change - and burying that in a suffix makes it easy to miss exactly when it matters.

How it is enforced

Three refusals, and the first is the one that matters:

  1. fs will not mint a writable capability to a sealed file. Refused at open, not at each write, because handing out a capability that looks writable and fails on use is a worse answer than refusing plainly - and §7.3 says rights narrow, so a capability that cannot be honoured should never exist.

  2. Every write path refuses: write, the streaming write_at, the journaled variant, and write_new, which did not until 2026-09-18.

    That omission is worth keeping, because the reasoning that produced it sounded sufficient. This said the seal is carried on the Entry each write route walks to, so a new route "cannot forget to ask". But write_new does not READ the entry it replaces - it OVERWRITES it, constructing a fresh one with the flag cleared - so there was nothing to forget. Carrying a flag on a record only protects the routes that consult that record.

    The reachable consequence was not a subtle one: copy <anything> <sealed file> destroyed the sealed content. copy is write_new followed by streaming write_at, each in its own transaction, so write_new truncated and committed before write_at refused - the command reported failure and the file was already empty. Found by adding seal coverage to selfcheck, and pinned by osdev test fs-time, which fails on exactly that check if the guard is removed.

  3. The volume records a ro_compat feature bit the first time anything is sealed, so a build that does not know about seals mounts the whole volume READ-ONLY (§6.15) rather than writing through a flag it cannot see.

Where the bit lives, and what that costs

The 64-byte directory record was full, so the flag rides the top bit of the 64-bit size - room no file can reach, since 2^63 bytes is eight exabytes. Every size read goes through an accessor that masks it off, which is not tidiness: write_at bounds a fragmented file's extent by its size, so the flag leaking into that arithmetic would let a write run past the file's own blocks.

The one cost is that a build which does not know this feature would display a nonsense size for a sealed entry. That is why the ro_compat bit exists: such a build mounts read-only, so it can neither act on the wrong number nor change anything. Full reasoning in docs/gsfs-next.md §3.

Conventions

Obeys utilities/0_conventions.md: seal help prints usage, the argument is a path (so seal is NOT in NO_PATH_CMDS and Tab completes paths), raw facts without editorialising, and the confirm uses the same line-edited prompt as drives flash - backspace works, and the decision is the final line.

Utility: edit

Utility: edit - full-screen text editor Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

edit answers how do I change a text file in place? - it is a full-screen, modeless text editor for GodspeedOS, modelled after Microsoft's edit: a title bar on top, the text area in the middle, and a key-hint/status bar pinned to the bottom. You open a file, move around, type, and save - no separate "insert mode", no commands to learn beyond the two on the status bar.

It complements read (print a file) and write (create/overwrite from the command line): edit is for interactive changes - fix a line in a script, jot notes, tweak a config.

2. Invocation

CommandMeaning
edit <path>Open <path> for editing. A missing file starts empty and is created on first save.
edit helpPrint usage.
edit versionPrint the version (uniform across utilities).
gsh> edit /notes.txt

<path> resolves like every other file command (absolute, or relative to the current cd). A directory is refused loudly; a file of any size opens (§5).

3. The screen

 edit  /notes.txt  * (modified)                                     ← title bar (name + dirty mark)
shopping list                                                       ← the text area
- milk
- eggs
_                                                                   ← the editing cursor

 Ctrl-S save   Ctrl-Q quit      Col 1   23 bytes   (buf 12/32768)   ← status bar (hints + position)

The title and status bars are drawn in reverse video on both a serial terminal and the display - the console service renders ESC[7m as inverted cells (there is no colour, only reverse). The status bar shows the two essential keys, the live column, the document size, and the edit-buffer fill (buf N/32768)

  • how much you've typed since the last save (§5). When that buffer fills it flips to a loud edit buffer full - Ctrl-S to save & continue prompt. There is no absolute line number: that would require scanning the file from the top on every keystroke, which is exactly the O(file) cost the windowed design exists to avoid.

4. Keys

KeyAction
printable charactersinsert at the cursor
Entersplit the line (insert a newline)
Backspacedelete the character before the cursor
Deletedelete the character at the cursor
Tabinsert 4 spaces (a fixed soft tab, EDIT_TAB)
← →move by one character
↑ ↓move by one line (keeps the column where it can)
Home / Endjump to start / end of the line
PageUp / PageDownmove up / down a screen
Ctrl-Ssave to the file (creating it if new)
Ctrl-Q / Escquit - with unsaved changes, a prompt offers save, discard, or keep editing

The view scrolls vertically and horizontally to keep the cursor visible; there are no modes.

5. Files of any size - a bounded piece table (honest)

edit opens a file of any size. It never loads the whole file into memory. The model is a bounded piece table, the no-heap (§26.6) realisation of "scroll a huge file the way iOS scrolls millions of rows":

  • The original file stays on disk. It is read in fixed IO_CHUNK (3556-byte) windows as you scroll - only the visible window is ever materialised, so opening a 1 MiB file is as cheap as opening a 1 KiB one.
  • Edits never touch the original. Typed bytes go into a fixed in-memory add buffer, and the document is an ordered list of spans (pieces) into either the original file or the add buffer. Inserting or deleting rewrites that span list - a few bytes - not the file.
  • Save streams the spans out to a temporary file and atomically replaces the original (write temp → delete target → move temp into place), then resets the add buffer and span list. The saved file becomes the new original and editing continues.

So the only thing that is bounded is how much you edit between saves, not the file size. The add buffer holds 32 KiB of new text and the span list holds 1024 pieces; if either fills, the next edit is refused loudly - the status bar shows edit buffer full - Ctrl-S to save & continue - and a save empties both. Nothing is ever silently dropped or truncated, which is the failure the constitution forbids (§3.12, §26.7). All of this state is fixed-size stack arrays: no heap, bounded, loud on overflow.

6. Capabilities

edit is a shell built-in: it runs in the shell's protection domain and uses caps the shell already holds - its console read/write (the same keyboard + screen the prompt uses) and its narrow SEND cap to fs (the same one read / write use). It gains no new authority: it cannot spawn, kill, or reach any file fs would not already serve the shell. fs enforces all disk authority; edit only asks it to read one file and write one file.

7. Non-goals

  • No POSIX editor heritage. No vi/nano modal commands or :wq; the two keys you need are on the bar (§ 0_conventions.md rule 8).
  • No mouse, menus, syntax highlighting, undo, or search - a deliberately small v1 (§26.2); these are pulled in only when a real need does.
  • No binary editing. It edits text; control bytes already in a file may render oddly.

8. Conformance

Conforms: own edit help (usage with a real example per 0_conventions.md) and edit version (number + creator credit), listed by the shell's top-level help under Storage. Exercised end-to-end by osdev test edit - 15 checks, QEMU-validated:

  • Small file: open → type/backspace/newline → ^S save → ^Q quit → read-back; edit-existing insert-at-start; quit-with-discard at the unsaved-changes prompt; no-arg usage.
  • Large file (a pre-baked ~16 KiB / multi-window /big.txt): open it windowed, insert at offset 0, save; re-open, PageDown past the first window, insert a mid-file marker, save. The harness then reads the bytes back off the disk and asserts the start edit, the mid-file edit, and the far original tail all survived the streaming save - proving the windowed load + multi-chunk save path end to end.

See 0_conventions.md §3.

Utility: uptime

Utility: uptime - how long the system has been up Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

uptime answers how long has the system been running since boot? It reports a human-readable elapsed time (Nd HH:MM:SS) and the raw total in seconds. It is a wall-clock delta - the kernel records the RTC time at boot, and uptime subtracts it from the current time on the kernel's monotonic clock. On a board with no RTC the monotonic clock already counts from boot, so it is the uptime directly. This is portable and accurate regardless of the APIC timer mode (a raw timer-tick counter is not: it runs at ~100 Hz on TSC-deadline hardware but ~10 Hz under QEMU's periodic timer, so ticks→seconds would be platform-dependent).

2. Invocation

CommandMeaning
uptimeRender a one-row grid: uptime (Nd HH:MM:SS) + seconds.
uptime | to json / to yamlThe same row rendered as JSON / YAML.
uptime | select secondsJust the total-seconds column (records pipeline).
uptime help / uptime versionSelf-documentation (0_conventions.md).

uptime is a record producer (docs/records.md): bare it prints the grid; piped it emits a typed Table, so every record verb works (where, select, sort, to).

3. Output

gsh> uptime
uptime       seconds
0d 00:03:21  201

gsh> uptime | to yaml
- uptime: 0d 00:03:21
  seconds: 201

4. Data source

ServiceContext::uptime_secs() (through gs::task::uptime_secs) = epoch_secs_monotonic() − boot_datetime().epoch_secs(), where:

  • epoch_secs_monotonic() → InspectKernel query 17: the RTC's epoch seconds with backward and wild-forward readings dropped on x86, and seconds since boot on the boards with no RTC; and
  • boot_datetime() → InspectKernel query 12 (rtc::boot_datetime()): the RTC time captured once at boot.

A boot datetime before 2001 (a board with no RTC reports zeros) means the monotonic value is used as-is. The result never goes below 0. Chosen over the kernel's tick counter because that counter's rate is not portable (TSC-deadline HW ticks at 100 Hz; QEMU's periodic timer at ~10 Hz), which would make a ticks→seconds conversion platform-dependent.

5. Capabilities

  • None gating the read. Queries 12 and 17 are ungated - uptime is task-neutral hardware-ish info, like the TSC (query 3) and RTC (query 11). No INTROSPECT cap required.
  • Console output to print the grid.

6. Non-goals

  • No load averages. Unix uptime also prints 1/5/15-minute load; this OS has no such metric (per-core CPU% lives in observe). uptime is elapsed time only (§26.2).
  • No wall-clock date. That is date (the hardware RTC). uptime is elapsed since boot.
  • No POSIX vocabulary. It happens to share the Unix name, but it is its own thing.

7. Conformance

Conforms: own uptime help / uptime version (with a real example, per 0_conventions.md); listed by the shell's top-level help under System; record-producer behaviour per docs/records.md. See 0_conventions.md §3.

Utility: chaos

Utility: chaos - bounded resilience exerciser (kill a service repeatedly, prove it recovers) Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

chaos answers does the system actually recover when a service dies, every time, without the kernel falling over? It is the executable, on-device form of the restartability invariant (§6.2, §14.3): it kills a recoverable service rounds times and, each round, waits for a fresh instance to come up before killing again. It then prints a per-round report and a single verdict: PASS / FAIL - and the fact that the command returns at all is itself proof the kernel never panicked (a panic reboots the machine).

This is a chaos / fault-injection test in the §22 sense: total failures are covered by the identity tests; chaos lets an operator reproduce the between cases live on real hardware.

2. Invocation

CommandMeaning
chaos kill-storm <svc> [rounds]Kill one <svc> rounds times; verify it recovers each round (default 20).
chaos kill-storm <svc> [n] save <path>Same, and also write the report to a file at the end.
chaos flood-storm <svc> [rounds]Saturate <svc>'s IPC queue with a try_send burst until QueueFull, then verify it drains and stays alive. The other axis: "overwhelmed", not "gone".
chaos max-carnage <target> <rounds> [yes] [seed <n>]The chaos monkey: each round, all-services picks a random subset of the live services - the shell and the supervisor included - floods each one it can and kills every one it picked (5b). Runs exactly the count you type; a live progress line ticks %/ETA; q aborts. A [y/N] confirm precedes the run; a 4th word yes skips it for unattended runs, and the warning still prints in full. seed <n> gives the random storm its seed; every random run prints the one it used (5b).
chaos mem-pressure [rounds]Spawn a mem-pressure task that allocates to its limit, kill it, and confirm the memory is reclaimed.
chaos spawn-storm [count]Spawn mem-pressure tasks until the task pool or memory ceiling REFUSES one (a loud Err, no panic), then kill them all and confirm full reclaim.
chaos link-flap [n]Simulate a cable unplug/replug n times (default 1); net-stack reconfigures itself. Needs a live nic-driver.
chaos help / chaos versionSelf-documentation (0_conventions.md). Bare chaos prints a short list of the modes.

kill-storm clamps rounds to 1..=100 (CHAOS_MAX_ROUNDS, §26.6) - it stores per-round generation detail in fixed stack arrays. max-carnage has no round cap: its report is a constant-size per-service aggregate, so the round count is a loop counter, not a resource (same reasoning as the unbounded supervisor respawn, §6.2). It runs exactly what you type (bounded only by u32); q aborts. It also takes no save - it destroys fs, so a save would fight the storm; its report is console-only (§5b).

Targets (<svc>)

Only recoverable services are valid kill-storm targets - the kernel itself cannot be killed, and killing a non-recoverable thing would just wedge. The list is CHAOS_RESTARTABLE in the shell (not the set kill all-services kills, which is asked of the supervisor, its MANAGED set); anything else is refused:

TargetRecovered by
supervisorthe kernel - Path C / Phase 6 (§6.2): the kernel respawns the supervisor on death, unconditionally and forever (no bound - a bound would re-introduce the reboot and be a DoS).
block-driverthe supervisor (Phase D, §6.1) - re-inits the controller on respawn.
fsthe supervisor (Phase D) - re-mounts to a consistent state via its crash-consistency journal (docs/persistence.md §6.8).
xhci / ehci / dwc2the supervisor - a fresh driver is granted its device again and re-enumerates.
events, nic-driver, net-stack, time, controlthe supervisor - a fresh instance. (events holds the trace ring, which a restart empties; nothing in it is a log line, §11.4.)

shell is NOT a kill-storm target (the storm runs inside it); max-carnage kills it, and kill shell recycles it. The supervisor respawns each of these on its own death because the supervisor's MANAGED roster marks them watched (SPAWN_FLAG_WATCHED), so the kernel sends the supervisor a death notification - the kernel restarts only the supervisor itself. Even if a death notification is lost (e.g. the supervisor was itself mid-respawn during a storm), the supervisor's reconcile scans the task table (managed_alive: a task_stat slot that is valid and not Dead) and respawns any managed service with no live task - services self-heal.

The only unkillable component is the kernel ({kernel}). chaos can storm anything above it; there is nothing it can do to bring the kernel down - "do anything except shotgun the kernel."

3. Output

gsh> chaos kill-storm supervisor 4
chaos kill-storm supervisor: 4 rounds - kill, then wait for the supervisor to respawn it...
=== chaos kill-storm supervisor: report ===
target: supervisor (kernel-respawned); rounds: 4
round   1: killed gen 3 -> recovered gen 4
round   2: killed gen 4 -> recovered gen 5
round   3: killed gen 5 -> recovered gen 6
round   4: killed gen 6 -> recovered gen 7
recovered: 4/4; kernel: alive (no panic - this command returned)
verdict: PASS

The companion kernel log shows the recovery path each round, e.g. for the supervisor:

kernel: supervisor died - respawning (#N) (Path C / Phase 6)
supervisor: adopted running block-driver (slot 5)   ← reconciliation: adopt the live services, don't duplicate
supervisor: ready

4. How recovery is detected

Each round reads the target's task generation (a restart bumps it, §7.5 - the same number observe shows in its RESTARTS column), kills the target, then waits for a new generation to appear. The wait is bounded by real wall-clock time (the RTC, CHAOS_RECOVER_SECS = 8 s), not a yield count: a yield count is not portable - it was generous in QEMU but too short on real hardware for the heavier, kernel-driven supervisor respawn, which made chaos kill-storm supervisor under-count recoveries even though the supervisor genuinely came back every time. The loop breaks the instant a new generation appears, so fast targets (fs, block-driver) stay fast; only a genuinely slow recovery pays the larger budget. Before each kill chaos also waits for the target to be alive (it may still be mid-respawn from the previous round), so no round is wasted killing a not-yet-present task.

5. The report avoids a catch-22

chaos kill-storm fs kills the very service that stores files - so the report is recorded in memory during the storm (a bounded buffer, never touching fs) and only printed to the console at the end (fs-independent, captured by the serial log). An optional save <path> then materialises it to a file after the target has recovered, with a bounded retry (if fs was the target it may still be finishing its re-mount). If the save never lands in budget, the console report stands.

5a. flood-storm - saturate the queue

chaos flood-storm <svc> [rounds] is the other resilience axis: not "service gone" (kill-storm) but "service overwhelmed". Each round it bursts try_send at the target's IPC endpoint until the kernel returns QueueFull - proving the queue bounds at depth 16 (§8.5) rather than growing - then yields to let the service drain and re-sends to confirm it recovered. It is try_send, never blocking send (§8.9): blocking into a full queue would hang the shell flooding itself.

  • Targets: anything with a registered recv endpoint. The shell acquires a SEND cap to <svc> by name (AcquireSendCap) - so fs, events, block-driver, even supervisor are floodable.
  • Payload: a minimal benign message the target drains and drops - no writes, no side effects. The test stresses the queue, not the disk.
  • Verdict: PASS = the service survived every flood (no EndpointDead) and still accepts messages. A flood that crashes a service is caught and reported - a finding, not a hang; a restartable one respawns and the storm continues.

Aside (hardening note). This said AcquireSendCap was ungated. It is gated now: the kernel mints a SEND cap by name only to a caller holding ACQUIRE_ANY (the shell, the supervisor, the probes) or for a name the caller was spawned with as a send peer (handle_acquire_send_cap). The shell holds ACQUIRE_ANY, which is what lets it flood any named service.

5b. max-carnage - the chaos monkey

chaos max-carnage <target> <rounds> [yes] runs in the chaos SERVICE, not in the shell, and that is what lets the shell be a victim: killing the shell does not end the run. It reads the live task set (exactly what observe now shows) and, each round, picks its victims by the target:

  • all-services - a coin flip per live service, so about half of them each round, at least one. The shell and the supervisor are victims like any other; the only tasks never picked are chaos itself and the mem-pressure tasks it spawns, and the kernel, which is not a task.
  • a service name - that one every round; a comma list - every one listed, every round.

What happens to a victim: every one picked is flooded and then killed - its queue filled with a try_send burst (the §8.6 back-pressure and queue-drained-on-death cases), then the kill. shell and fs are only killed, never flooded, because a flood corrupts their reply streams. (In an aimed run at one service, it is flooded and killed every round.) This said the run "rolls a creative action mix - kill, flood, flood-then-kill, or kill-then-flood" and spared the shell; neither has been true since the run moved into its own service. chaos waits for a live shell before it hands the console back.

Every service in the supervisor's MANAGED set is respawned on its own death; chaos does NOT check that it came back - asking the supervisor, which is itself being killed, would not be ground truth. The victims are chosen with a tiny xorshift64 PRNG. (This said it was seeded from the TSC; it is not - the TSC is unreliable on the T630 - and the seed mixes the hardware random number where there is one, the monotonic counter, and the wall clock when it is set. Since 2026-10-13 the SHELL draws it from those sources and hands it to chaos, so it can record it, below.)

The seed is printed, and can be given. An all-services run says its seed when it starts and again in its report, and chaos max-carnage all-services <rounds> seed <n> runs on that seed. On a display the start line is overwritten as soon as the table draws; it stays on serial, and the report repeats it:

gsh> chaos max-carnage all-services 1000 yes seed 4242
chaos: seed 4242 (given) - `chaos max-carnage all-services <n> seed 4242` replays these draws, not this run's timing
...
=== chaos max-carnage: report ===
seed: 4242

The seed is also kept on disk, in /system/utilities/chaos/last-run (docs/filesystem-layout.md): the seed, the target, the rounds and the command that replays the draws, written by the shell BEFORE the storm, because the storm kills fs and the chaos service holds no fs at all. One file, the latest random run, whether its seed was drawn or given. With no disk, or an fs that does not answer within 3 s, the shell says the seed was NOT recorded and runs anyway - the printed seed is still there.

gsh> chaos max-carnage all-services 3 yes
 ...
 Seed 216178474221616 recorded in /system/utilities/chaos/last-run.
chaos: seed 216178474221616 (drawn) - `chaos max-carnage all-services <n> seed 216178474221616` replays these draws, not this run's timing
gsh> read /system/utilities/chaos/last-run
seed 216178474221616
target all-services
rounds 3
replay chaos max-carnage all-services 3 seed 216178474221616

What that buys, stated so it is not overclaimed: a seed replays the draws, not the run. One draw is made per live service each round, and which services are live depends on restart timing across the cores, so two runs on one seed part ways at the first round whose timing differs. A seed turns a break "somewhere in a long run" into one run's named decision stream; it does not make the break repeat on demand. An aimed run (one service, or a list) draws nothing, and a seed given to one says so.

The point is that the kernel survives any sequence of random service deaths. The verdict is therefore about kernel survival: the report existing at all proves no panic (a panic reboots before it could print). Whether every service came back is a separate question, and the run does not answer it: ask afterwards - status, hardware, and selfcheck, which fails if a service it needs did not return.

While it runs, a table redraws in place with each service's kill and flood counts, and the serial log gets one line per round (chaos round N: swept ...) - the panel overwrites itself, so serial is the history. q stops it early, from the serial console in an all-services run (it kills the USB keyboard drivers). The report, on the Raspberry Pi 4 on 2026-10-09:

=== chaos max-carnage: report ===
total: 1000 rounds, 7014 kills, 6045 flooded, 1000 mem-pressure, 1000 spawns (999 refused). kernel: alive (this command returned).

(A seeded run also prints seed: <n> after the header line; this run predates the seed.) This section used to show a report with per-service "recovered" counts and a verdict line, from before 0cb8985b (2026-07-09) and before floods, memory pressure and spawns joined every round; no run prints that now.

999 refused is by design: the first mem-pressure holds its memory until the run ends, and every later one is refused as already running.

All output is ASCII (the framebuffer font has no em-dash/ellipsis - they render as ? on the panel) and [q] quit matches the rest of the shell (observe, paginate).

The whole tree regrows from the kernel. Only the directly-restarted services (supervisor by the kernel; block-driver/fs by the supervisor) recover on their own death. The rest (events, xhci, ehci, …) are not watched individually - but the moment max-carnage kills the supervisor (a valid random target), the kernel respawns it, and the supervisor re-runs its boot sequence, re-spawning every service it owns fresh. So a long carnage run that hits the supervisor tends to fully restore the system. Hardware-proven on the HP T630: chaos max-carnage 30 killed the supervisor 6× and every service was alive again at the end (observe: xhci/ehci/events all Ready, no kernel panic). A re-init, not a resume (§14.2/§25) - a revived driver re-enumerates its devices and resumes polling; in-flight state is not preserved.

Corrected 2026-10-08: events, xhci and ehci are watched now - every service in the supervisor's MANAGED set is respawned on its own death (services/CLAUDE.md) - so the tree no longer waits for a supervisor kill to regrow. The T630 run above is from when it did.

6. Capabilities

chaos is capability-clean: it uses only kill (SERVICE_CONTROL), task_stat (INTROSPECT), AcquireSendCap (ACQUIRE_ANY) for the floods, and spawn for the memory modes - nothing ambient. The shell holds these for the modes it runs itself; max-carnage runs in the chaos SERVICE, whose own spawn row in the supervisor grants it SPAWN, INTROSPECT, SERVICE_CONTROL and ACQUIRE_ANY. It cannot kill the kernel because the kernel is not a task. (This said the normal commands refuse the supervisor; only spawn and restart do - kill supervisor is allowed and the kernel respawns it, 11_kill.md §3.)

7. Bounded & loud (§26.6 / §26.7)

  • kill-storm clamps rounds to 1..=100; its per-round generation detail lives in fixed stack arrays (no heap). max-carnage is uncapped - its per-service aggregate is constant-size for any count, so there is nothing to bound but the loop counter; q aborts.
  • Each kill and each recovery is logged; a FAIL verdict names the round that did not recover in budget. Nothing is silent.
  • The kernel's supervisor respawn is itself loud and unbounded - it logs a running count (respawning (#N)) and never gives up, so a sustained real fault is visible to an operator rather than hidden behind a cap that would eventually reboot.

8. Tested

  • osdev test shell - chaos kill-storm block-driver 5 (5/5), chaos kill-storm supervisor 4 (4/4), and chaos max-carnage all-services 5 (a report with its round count, kills and floods, and the kernel alive), each asserting the kernel stays alive.
  • osdev test files - chaos kill-storm fs storms + the directory-reacquire regression (a client reacquires fs by name after its restart).
  • Hardware-proven on the HP T630: chaos kill-storm fs 30 → 30/30; the supervisor stormed dozens of times across a session (observe showed RESTARTS 20+) with no kernel panic.

Utility: fmt - format a script to the GodspeedOS standard

Status: BUILT and MERGED - the .gsh formatter, one canonical layout, non-negotiable, applied in place. STREAMING (no file-size cap), semantics-preserving, and idempotent. Pinned by osdev test files (207/0), osdev test fmt-demo (6/0 - jar before/after, a medium multi-flush file, and a 10 MB file formatted with fmt check = Ok), and osdev test fmt-idem (format-twice diff = IDENTICAL). Trails CLAUDE.md; does not amend it.


1. What it is

fmt <path> rewrites a .gsh script in place to the single GodspeedOS canonical layout. You run it knowing exactly what happens - there is nothing to decide. It is the gofmt / terraform fmt / rustfmt idea, for gsh: the point is not prettiness, it is uniformity. A canonical format kills bikeshedding and makes diffs mean something - you can see what changed instead of squinting past ten differently-cramped lines.

# before  (valid gsh, but jarring - crammed onto lines with ; and inline blocks)
let n = 7 ; if $n > 5 { echo big } else { echo small }
  for i in range 1 5{echo $i}

# after   fmt /script.gsh
let n = 7
if $n > 5 {
    echo big
} else {
    echo small
}
for i in range 1 5 {
    echo $i
}

(fmt fixes layout - the ; becomes newlines, inline blocks expand and indent, a space is inserted before {, runs of whitespace between tokens collapse. It does not re-space inside a token, so a $n>5 written without spaces would stay $n>5 - see the scope note in §2.)

Boring on purpose - zero style options

There is exactly one canonical layout and no knobs to change it. The second fmt grows a style flag, you have re-introduced the argument it existed to end. fmt has subcommands (below), but none of them change how the code is formatted - only what fmt does with the result.

2. The canonical standard (what "formatted" means)

  1. Indent 4 spaces per block depth. No tabs.
  2. Braces, K&R. header { on one line, body indented, } at the block's own indent. } else { and } else if <cond> { stay on one line.
  3. One statement per line. A ; statement separator becomes a newline (the biggest de-jarring win). cmd ; cmd ; cmd becomes three lines.
  4. One space between tokens. Runs of whitespace between tokens collapse to a single space; leading/trailing whitespace per line is stripped.
  5. # comments keep one space after the #; a full-line comment sits at the current indent, a trailing comment is one space after the statement.
  6. Blank lines collapse (2+ become 1); no leading or trailing blank lines; the file ends in exactly one newline.
  7. Strings are sacred. Content inside '…' / "…" is never touched - not its spacing, not its case, nothing.
  8. Idempotent. fmt of already-formatted text is a no-op. fmt(fmt(x)) == fmt(x) is an acceptance test - met, proven from 8 KB to 10 MB by osdev test fmt-idem (format a chunk-spanning file twice and diff = IDENTICAL).

Scope note (honest). fmt canonicalizes layout - indentation, statement-per-line, brace placement, inter-token whitespace, blank lines. It does not re-space the insides of a token (blk-16, val-$x), because gsh uses -/+ inside words as well as as operators, and guessing which is which would risk changing meaning. Micro-spacing of arithmetic is a later slice, gated on the tokenizer's exact rules. Layout is the jarring part; that is what ships first.

3. Surface

Subcommands are words and come before the path (fmt is a subcommand tool like drives, not a file-first verb like write). There is no write subcommand - the default is the write.

fmt <path>            format the script IN PLACE (the default; no subcommand)
    e.g. fmt /script.gsh
fmt check <path>      is it already canonical? Ok + silent if yes; Err + loud if no. Never writes.
    e.g. fmt check /script.gsh
fmt <a>,<b>,...       format (or check) several files - comma-separated, done one at a time
    e.g. fmt /x.gsh,/y.gsh
fmt version           print the version
fmt help              print usage
  • fmt <path> - the default. Format in place, on the fly. Safe to run blind: it only moves whitespace and newlines, so it can never change what the script does (§2.7-2.8).
  • fmt check <path> - the discipline / CI gate. Returns Err (via the Result model, 32_result.md) and names the file if it is not canonical, so a script can gate on it (fmt check /s.gsh ; if result == Err { fail "run fmt" }). Changes nothing on disk.
  • Bare fmt prints usage (a path is the actionable arg; without one there is nothing to do).

4. Guardrails & safety (loud, never silent - §26.7)

Because the default writes in place with no preview, fmt is all-or-nothing and never leaves a damaged file. It streams (constant memory, no file-size cap), and the write is temp-then-rename, so a failed write can never touch the original:

  • Won't parse -> refuse, file untouched. A syntactically broken script (unbalanced braces, a bad block header) gets a loud error and no write. fmt never lays a half-formatted result over a broken one; fix the syntax, then format.
  • One statement too long -> refuse, file untouched. The streamer holds one statement across a read boundary; a single statement bigger than the hold buffer is refused loudly. This is a per-statement bound, not a per-file one - the file itself can be any size (a 10 MB script formats fine).

Neither guardrail is an option - they are the safety that lets the zero-preview default be trusted.

5. Later (separate so it can grow)

  • fmt diff <path> - print the changes fmt would make without writing (needs a bounded diff).
  • fmt <dir> - format every .gsh under a directory in one call.
  • Host osdev fmt + VS Code format-on-save. The single definition of "GodspeedOS standard" is factored into a no_std core crate so the shell built-in and a host osdev fmt share it - one canonical format, never two that drift. This is where the gofmt/terraform muscle-memory lands.

6. Implementation shape & conformance

A shell built-in (0_conventions.md §2), using caps the shell already holds - no widened authority. It is a fully streaming, token-level re-emitter: fmt_stream_pass reads the source in chunks (fs_read_at) and fmt_walk_window formats each, emitting to a closure - holding only ONE partial statement + the indent depth across a read boundary (no native recursion). Constant memory, any file size. It never evaluates the script; it only re-lays-out its tokens, which is why it is provably semantics-preserving.

The write is two streaming passes: pass 1 counts the output size (for OP_WRITE_NEW), pass 2 stream-writes 508-aligned chunks (OP_WRITE_AT) into a temp, which is then renamed over the original - so a failed write leaves the original intact. fmt check formats to a temp and compares the two files sequentially (never two concurrent reads of the same file). Pinned by osdev test fmt-idem (the format-twice boundary diff) and osdev test fmt-demo (the 10 MB).

Has its own help_block and is listed under Console in the top-level help, and conforms to 0_conventions.md rules 1 and 5: fmt is in the shell's UTILS list, so fmt help and fmt version are intercepted before cmd_fmt. (Found 2026-10-09 missing from UTILS, so fmt help took help as a PATH to format; fixed in backlog/fixed/80 H8.)

Utility: net - am I on the network?

Utility: net - network status (IP, gateway, reachability) Status: Built. As-built reference. Shape: shell built-in that brokers a query to the net-stack service (see 0_conventions.md §2).


1. Purpose

net answers one question: am I on the network, and who is my gateway? It reports the IP net-stack configured for itself, the gateway it resolved by ARP, and whether that gateway answered a ping. Raw facts, no verdict (0_conventions.md §7) - it prints what is, and lets you read connectivity off it.

The networking stack (docs/networking.md) is two userspace services: nic-driver (raw frames) and net-stack (ARP/IPv4/ICMP/UDP over those frames). At boot net-stack learns its IP by DHCP, resolves the gateway by ARP, and pings it - then freezes that result and serves it. net is the window onto it: the shell acquires net-stack by name and asks.

2. Invocation

CommandMeaning
netPrint the current network status: NIC (link/speed/MAC/hardware counters), IP, gateway, ping, DNS.
net dns <host>Resolve a hostname to an IPv4 address (a DNS A-record lookup).
net statsDump the NIC's raw registers (chip state) - is the receiver enabled, is the ring armed?
net arp <ip>Resolve one host's hardware (MAC) address by ARP.
net scanARP-sweep the local /24 and list the hosts that answer.
net renewRe-run DHCP/ARP to reconfigure the network in place - recover a link that came up after boot (a cable plugged in later), no reboot.
net leaseOne word for scripts: is there a DHCP lease (ok when there is no link to lease on).
net versionPrint the version.
net helpPrint usage.

Bare net reports the status net-stack froze at boot (plus a live read of the NIC's own hardware counters); net dns <host> is a live query; net stats is a live register read. net never changes the network - it reads and resolves (0_conventions.md §7, and the non-goals below).

Related utilities: ping <ip> (a one-shot ICMP echo to a raw IP, no DNS - see 42_ping.md) and sock (a UDP socket as a capability - see 41_sock.md).

3. Output

On hardware with the RTL8168 (T630), net reports the full picture - the NIC, its live link, the chip's own hardware counters, then the L3 status:

gsh> net
nic      10ec:8168  mmio 0xfea04000  (RTL8168)
nic-mac  xx:xx:xx:xx:xx:xx  reset ok
link     up via the cable
nic-link UP 1000M full  |  tx ok (4 sent)  |  rx 90B (4 recv)
nic-hw   RxOk=1832 TxOk=7 RxBcast=1210 RxErr=0 Miss=0
ip       192.168.4.80
gateway  192.168.4.1 at xx:xx:xx:xx:xx:xx
ping     ok
lease    ok (DHCP)
dns      192.168.4.1
  • nic - PCI vendor:device, the MMIO register base, and the chip name (from the kernel). A controller built into the SoC has no PCI identity, so there it reads built in, not on PCI with its register window (the VisionFive's dwmac), or no PCI network card where the kernel granted none (the Pis, whose driver reaches its controller another way).
  • link - which link is up: up via the cable or down - no cable on a single-link backend (the e1000, QEMU's), and on every board with a radio bridge (the Pi 2, the Pi 4, the VisionFive, the PCs' RTL8168) up via the cable, up via wifi (the cable is out) or down - no cable, and the radio is not joined. When it says down, the address lines below say so too rather than showing the last address held.
  • nic-mac - the MAC read off the chip, and whether the chip reset succeeded (queries nic-driver; TIMEOUT here means MMIO is not reaching the chip).
  • nic-link - link up/down, negotiated speed/duplex, and the driver's TX/RX request counts.
  • nic-hw - the chip's own cumulative hardware tally counters, read straight off silicon (DTCCR dump), independent of net-stack: RxOk, TxOk, RxBcast (broadcasts received), RxErr, Miss. Layer-1 ground truth - if RxOk/RxBcast climb between two nets, the receiver is alive; if they stay flat, the NIC is not receiving.
  • ip - the address net-stack holds (learned by DHCP, or the fallback if there was no offer).
  • gateway - the gateway IP and the MAC ARP resolved for it, or unresolved if ARP got no answer.
  • ping - ok if the gateway answered an ICMP echo; no otherwise.
  • lease - ok (DHCP) when the address was granted, NONE when the link is up but the stack is on its fallback address (the one genuinely wrong case), and n/a when there is no link to lease on. net lease answers the same question in one word for scripts, and says ok for no link.
  • dns - the DNS server net-stack will use (DHCP option 6, or the gateway as a fallback).

On QEMU's e1000 the [3] status reply is shorter, so only nic, nic-mac and link show (no nic-link/nic-hw lines); and when there is no drivable NIC, nic-driver serves empty replies and net-stack reports gateway unresolved / ping no plainly rather than faking it.

net dns <host> resolves a name; net-stack tries the DHCP-learned server, then a public resolver (8.8.8.8) reached through the gateway (a home router may do DHCP + ICMP yet not run a DNS forwarder):

gsh> net dns example.com
net: resolving ...
example.com is 104.20.23.154
gsh> net dns nope.invalid
net: resolving ...
nope.invalid: no reply from the DNS server (0 frames, 0 UDP, 6 timeouts)

The no-reply line carries a diagnostic - frames seen, how many were UDP, and how many driver requests timed out - so a failure says where it failed, not just that it did.

net stats dumps the NIC's raw registers - the chip state, for when you need to know whether the receiver is even enabled (CR.RE), whether the receiver is promiscuous (RCR), and whether frames are sitting in the RX ring (each descriptor's OWN bit):

gsh> net stats
NIC registers (RTL8168):
  CR        0x0c   RE=1 TE=1 RST=0
  9346CR    0x00   locked
  PHYSTATUS 0x2f   link=1 spd=1000M dup=full
  IMR       0x0000
  ISR       0x0005
  RMS       0x0800   (2048 bytes)
  RCR       0x0000e70f   AAP=1 APM=1 AM=1 AB=1
  TCR       0x03000700
  TNPDS.lo  0x1f2c0000   TX ring base
  RDSAR.lo  0x1f2c2000   RX ring base
  RX ring (rx_idx=0):
    [0] opts1=0x80000800  OWN=1 len=2048
    [1] opts1=0x80000800  OWN=1 len=2048
    [2] opts1=0x80000800  OWN=1 len=2048
    [3] opts1=0xc0000800  OWN=1 len=2048

OWN=1 means the NIC owns that descriptor (armed, waiting for a frame); OWN=0 means a frame has landed and the driver has not consumed it yet. The capture above is from when the RX ring held four descriptors; it holds RX_RING_COUNT (256) now, and net stats prints one line for each descriptor the driver sends. (On QEMU the e1000 path prints CTRL/STATUS/RCTL/etc.)

net arp <ip> resolves one host's MAC by ARP, and net scan sweeps the whole /24 - both live queries (like net dns), for "who else is on this LAN?":

gsh> net arp 192.168.4.1
resolving 192.168.4.1
192.168.4.1 is at xx:xx:xx:xx:xx:xx
gsh> net scan
Scanning 192.168.4.0/24 for live hosts:
  192.168.4.1
  192.168.4.80
  192.168.4.107
3 host(s) responded.

net scan walks the /24 host-by-host, driven from the shell (one ARP resolve per host, the same op net arp uses), printing responders as it finds them. Driving it from the shell is deliberate: q/ESC then actually stops the work - net-stack is only ever resolving one host, never wedged finishing a 254-host sweep - so a second net scan never finds net-stack busy (a single batch op left the next command stuck). Quick on a real LAN; slower on a quiet link, but always abortable (0_conventions.md §1.10). net arp is likewise abortable.

4. Pipe behaviour (to / from / where)

net is a pipe producer, never a consumer or filter. Its labelled lines are ordinary output, so they flow onward like any producer's:

  • to a file - net | write /netstat.txt snapshots the status to disk (redirection is | write; there is no >, see 19_write.md).
  • to a filter - net | match gateway keeps just the gateway line; net | count counts the lines.
  • from - net reads from the net-stack service (over IPC, acquired by name), not from a file or from stdin - so it is always the first stage of a pipe, never a later one. Nothing pipes into net; piping into it is a loud unknown-shaped error, not a silent no-op.

where the bytes come from: net-stack's frozen boot-time status record (§5), formatted by the shell. net performs no network I/O itself - it asks the service that does.

5. Data source

  • Service: net-stack (services/net-stack). After its boot dance (DHCP -> ARP -> ICMP) it freezes a 19-byte record - our IP (4), gateway IP (4), gateway MAC (6), a flags byte (bit 0 = gateway resolved, bit 1 = ping OK, bit 2 = the address came from a DHCP lease), and the learned DNS server (4) - and serves it (byte 0 = 0). It also answers live requests: net dns (byte 0 = 1, then the hostname), ping <ip> (byte 0 = 3, then the 4 IP bytes), net arp <ip> (byte 0 = 6, then the 4 IP bytes -> [found, mac(6)]), and net renew (byte 0 = 8 -> re-runs the boot dance run_dance in place and replies the fresh status). net scan reuses op 6 host-by-host - the shell walks the /24 itself, so aborting it actually stops the work (no batch op).
  • Recovery: the boot dance (DHCP -> ARP -> ICMP) is run_dance, run once at boot AND again on net renew, so a link that comes up after boot reconfigures the stack without a reboot (nothing is special; the link recovers like any restartable thing).
  • Driver: nic-driver answers two diagnostic queries directly (the shell holds ACQUIRE_ANY and asks it by name): [3] returns the 32-byte hardware status (MAC, link, speed, and the chip's tally counters via a DTCCR dump); [5] returns the raw register dump for net stats.
  • Shell: the net built-in acquires net-stack (and nic-driver) by name (the kernel name directory, §14.3), sends each query bounded (net_query, abortable with q), and formats the replies. On no reply it reacquires by name and retries, then reports plainly (Commandment VIII / IX).

6. Capabilities

  • Console output to print the lines.
  • ACQUIRE_ANY, which lets the shell acquire nic-driver by name for the two diagnostic queries.
  • A SEND cap to net-stack, acquired by name - the same brokering the file commands use to reach fs. net gains no network authority of its own; it can only ask net-stack, which is the sole holder of the frame interface to nic-driver.

7. Non-goals (deliberate - §26.2 minimal surface)

  • No configuration. net reads; it never sets an IP, route, or DNS server. Those are net-stack's job, and a future net set ... would be a separate, deliberate surface.
  • Ping-on-demand is its own utility. ICMP to an arbitrary IP is ping <ip> (42_ping.md), not a net subcommand - net reports the gateway ping net-stack froze at boot.
  • Sockets are their own utility. Opening a UDP socket is sock (41_sock.md) - a socket is a delegated resource cap (docs/networking.md, §7.10). net stays a status/diagnostic window.

8. Conformance

Conforms to 0_conventions.md: own net help / net version (version header on line 1 of help; version prints the name + number + the copyright credit), real example per usage row, words-not-flags, raw-facts-no-verdict. Pinned by osdev test shell (the net status line after the networking phases).

wifi - join and inspect a wireless network

Version reported by wifi version. Implementation shape: shell built-in, every verb (cmd_wifi in services/shell/src/main.rs), each a question put to whichever radio service is running (RADIOS: wifi-driver, then wifi-usb) - see section 7, where the reason for scan and join is a constraint rather than a preference.

Status, as built and honest (2026-09-29)

(Since then: the VisionFive 2 Lite's AIC8800 (docs/wifi-aic8800.md, V0-V6) and the Pi 2's USB dongle (docs/wifi-usb.md, R4-R12c) answer the same verbs through the same serve loop. What follows is the Pi 4 as of this date.)

The radio scans and lists on the Raspberry Pi 4, and has since 2026-09-28. The shell asks the wifi-driver over IPC, the radio sweeps, and one record per network prints in the order this file specifies - ssid signal band security, with security read from the beacon (an RSN element is WPA2, a Microsoft-OUI vendor element is WPA, the Privacy bit alone is WEP). Eleven networks on the first run, and a second run straight after scanned again on the same driver session. About three seconds each, ending when the firmware says the scan is complete rather than when a timer runs out.

Hardware-verified 2026-09-29 13:05, Raspberry Pi 4, one boot, first try. Everything sections 2 to 4 and 6 describe ran at the prompt and did what the sections say, except the WPA2 join itself, which this firmware cannot complete without the host handshake (below). The key derivation matched all four published vectors at boot (stage 0). The cache lives in the driver's memory - not in a file, so it needs no fs, dies with the driver, and never writes a network name to the card. (That was 2026-09-29. Keys, and the names they belong to, have been on disk in /system/shared/wifi/keys since 2026-09-30 - section 6.)

What every verb does, and what was seen:

  • wifi scan - verified. Live numbered rows under scanning [q] quit [b] background and the header; 4 networks in 3 s; the picker took a number, echoed the row, and joined through the same path as connect. q mid-sweep and b were not exercised on this boot.

  • wifi list - verified, with saved in NOTE for the network whose key was held.

  • wifi status, wifi info - verified in the not-associated state (network none, the scan facts, the addressing line). The associated state cannot be reached until the handshake exists.

  • wifi debug, wifi debug trace, wifi debug firmware - verified. The trace held the boot scan, the prompt's scan, the join's six control exchanges and the handshake frames, on a clock that read real milliseconds. events, stats, transport alone were not typed; their rows appeared under bare debug.

  • wifi join <ssid> - verified to the point this firmware allows: asked the passphrase once, derived the key into slot 0, associated, read six copies of the access point's handshake message 1 about a second apart, and reported the deauthentication (reason 15) as the driver's inability, not the passphrase's - the state before the handshake was built; the not-met list below has the build and what its first boot must show. A second connect of the same name asked nothing and joined with the held key. wifi stored named it.

  • wifi leave, wifi radio on|off, wifi forget - built as sections 2 and 6 say; radio off and on typed 2026-09-30 15:05 (off dropped the link, on brought the radio back and a join after it worked).

  • wifi help, wifi <verb> help, wifi version, tab completion, and a row in help.

  • Absence is told apart from a wedge (section 5): no live radio service (wifi-driver or wifi-usb) means no radio; a live one that will not answer says that after a bounded wait, never a guess.

  • The frame path, both ways - hardware-verified 2026-09-30, 08:50. Cable out, ping over the radio (36 of 38, the two lost being the switch itself, then 0 lost); cable in, the address change, a new lease, ping over the cable; out again, over the radio with no new wifi join; twice round. net said link up via wifi (the cable is out) and link up via the cable. The operator: "Works beautifully". The first boot that morning had proved the same path through DHCP and then gone deaf to a leaked reply cap (docs/wifi.md 41).

Things this file specifies that are NOT met yet, said here rather than discovered:

  • Both rekeys are answered (built 2026-09-30, docs/wifi.md 42) and neither has yet been seen on hardware - they happen when the access point decides, commonly hourly, and cannot be provoked. The group-key rekey re-installs the group key and acknowledges; a pairwise rekey (the access point restarting the four-way handshake) is run to completion by the same handshake the join uses and replaces both keys. If either fails the log names the step and wifi join recovers the link (backlog/64).

  • The WPA2 handshake joins - hardware-verified 2026-09-29, 20:01 (joined first try, already joined twice, the keys installed; the previous evening's boot had verified it through message 4 and been refused at the key install by two bytes of struct padding, docs/wifi.md 40). Kept in this list only for what remains of it: the driver answers message 1 with message 2 (its nonce, the RSN element, MIC'd with the confirmation key derived by PRF-384 from the PMK), verifies message 3 (ANonce, MIC, AES-key-unwraps the group key), sends message 4, and installs the pairwise and group keys through wsec_key - every step from OpenBSD's net80211 and bwfm, named at the step in join.rs. Two primitives were added with published vectors (AES-128, FIPS 197 C.1; key unwrap, RFC 3394 4.1) and self-test at boot with the others. A wrong passphrase is decided by the one pattern that means it: message 1 repeated after two answers, or a deauthentication after an answer. What the first boot must show: message 2 of 4 sent, message 3 verified, JOINED, and then joined <name> at the prompt; with a wrong passphrase, not joined - incorrect passphrase. One weakness, recorded not hidden: the aarch64 kernel exposed no hardware RNG at first, so the station's nonce was hashed from the cycle counter, the access point's nonce and our address, and the driver's log said so every time. Closed 2026-09-30: the kernel reads the Pi 4's RNG200 (docs/wifi.md 40); the fallback and its line stay for a block that answers nothing.

  • Glommed superframes are read, by the descriptor's chunk lengths - hardware-verified 2026-09-29 15:58. The boot at 13:05 showed the events riding inside channel-3 superframes; the boot at 13:57, with a walker that assumed the sub-frames were back to back, showed ASSOC for the first time (sub-frame 0) and then sub-frame 1 failing to validate at +229, +92 and +652 - the sub-frames are PADDED. The descriptor frame before every superframe lists the chunk each sub-frame occupies, padding included, and ctrl::subframes walks by it. The boot at 15:58 showed join event 16 (LINK) flags 0x0001 and ASSOCIATED for the first time in this port's life, rx_glom_sub 40 against 24 superframes, and no does not validate line; the trace shows RX GLOM len=384 carrying ASSOC and LINK, then RX GLOM len=256 carrying JOIN and SET_SSID. This item is closed and kept here because of what it cost (docs/wifi.md §39).

  • The passphrase prompt cannot be abandoned. Closed 2026-09-30: Esc or ^Q ends the entry, zeroes what was typed, and prints passphrase entry abandoned - nothing was sent.

  • The BSSID and band in the joined sentence (section 4b) are not yet read from the association event; the sentence names the network only. wifi info reads both live, so they are one command away.

  • An access point that drops the station is noticed by the frame pull - the next time the stack asks for frames, which is every hundred milliseconds while there is a link - and by wifi status or wifi info reading the link - and, joined and idle, by the driver's own loop, which reads the radio every 250 ms, standing by behind a cable that is in included. (This said a drop was NOT noticed behind a cable, which stopped being true when that read was added; corrected 2026-10-08.) The driver still does not watch LINK events on its own.

  • A drop is rejoined once. When the access point drops a link that had lasted a minute or more, the driver rejoins the same network with the key it holds, through the same path as the boot rejoin - so it honours /system/shared/wifi/radio - and says joining the network last joined, after the access point dropped it. One attempt per drop: if it fails, wifi join brings it back. A link dropped in under a minute is NOT rejoined, and the line says so - an access point that drops every join would otherwise be rejoined forever. (Seen 2026-10-08: a VisionFive idle for half an hour, disassociated for inactivity, reason 4.)

  • Not on the USB dongle. wifi-usb reports no dropped link from its frame pull - it looks for the access point's deauthentication or disassociation only during the handshake - so a dongle the access point drops goes on reading as joined, and is not rejoined; wifi join brings it back. The onboard radios (the Pi 4's and the VisionFive's) report the drop. Recorded 2026-10-08.

SSIDs may contain spaces. In a pipe that costs nothing - the name is its own record column, network (section 3) - and on the screen it is padded to a fixed column, so a name with a space in it does not shift the columns after it.

Why this file moved here, which is a small lesson about the gates. It was written as utilities/56_wifi.md and Commandment X refused it: a spec under utilities/ asserts the shell answers that verb, and it did not. So it lived in docs/ as a design note. The moment the verb was implemented the same check fired in the opposite direction - the shell answers wifi and no spec in utilities/ describes it, which is complexity discoverable only by reading source (§26.11). The pair makes this directory mean exactly one thing, and the document's home is decided by whether the verb answers rather than by anyone's preference.


1. What it is for

One question, asked three ways: what networks are there, which am I on, and join that one. Wireless differs from net in exactly one respect that matters to a user - a wireless link must be joined before it carries a frame, and joining needs a secret. Everything after association is net's job and this utility says nothing about it.

wifi is therefore the link verb. net remains the network verb: addresses, DNS, leases, ARP. There is deliberately no overlap - wifi status reports radio and association, never an IP address, because an IP address has one owner and duplicating it here would make two answers possible.

2. Subcommands

SubcommandWhat it does
wifiwith no args: usage, as every utility (0_conventions.md rule 1). It used to alias status, a second way to say one thing
wifi scanask the radio to sweep. Rows appear as they are heard; when the sweep ends the rows become a numbered picker. q stops the sweep, b leaves it running and returns the prompt
wifi listprint the last complete scan: SSID, signal, band, security. Instant, records only, never scans
wifi join <ssid>join a network by name. Asks the passphrase once if one is needed and none is held; already joined if you are on it
wifi leave (says left <name>, or nothing to leave - not joined)leave the current network. The radio stays up
wifi statusthe human answer: radio, network and band, signal, security, time joined, last scan. Read live. Section 4f
wifi infothe link in detail: bssid, band, channel, signal, security, time joined, scan facts - and where addressing lives. Section 4f
wifi debug [events|stats|firmware|transport|trace]the driver's own account of itself: counters, the firmware's words, the last 64 frames. Section 4g
wifi forget <ssid>drop the held key for that network. Does not leave the network
wifi storedthe networks a key is held for, one per line. Names, never secrets. Sixty-four at most - section 6
wifi radio on / wifi radio offthe FIRMWARE's radio switch: the chip stays powered. off disconnects first and says so. on converges from whichever state the radio is in - soft off, it is the two-second switch below; after off hard, it is the cold start described in that row; and with the radio DOWN (its firmware trapped at start, or never ran) it is the hard on: the driver is restarted (the respawn adopts a live firmware or power-cycles a dead one) and watched to radio on succeeded - joined <name>, and a chip that comes up warm is reported, not retried: ONE attempt, then the prompt and radio on failed - the chip came up warm (...); `wifi radio powercycle` tries once more. on never answers "no control over the radio's power" unless the kernel refused. on then REJOINS the network last joined this boot, with the key it holds and without asking, and says joined <name> (asked for by the operator 2026-09-30); a wifi leave before the off cancels that, and a wifi forget of the name leaves nothing to rejoin with. Asking for the state it is already in says radio already on / radio already off, and sends the radio nothing. Both wait about two seconds, and up to fifteen when on rejoins; past 2 s the shell says [q] quit, and q stops the WAIT, not the request - the driver finishes it, and the shell says wifi: stopped by q - ... (2026-10-13, backlog/fixed/89). No [b]. off is VERIFIED: the driver asks the firmware back (WLC_GET_UP) and the shell prints ... - verified: ... when it reads down, says unverified when it could not ask, and ... FAILED ... when the firmware still reads up (docs/wifi.md 49, 55; hardware-verified 2026-10-01)
wifi radio off hardthe CHIP's power, cut and left cut. waits about three seconds, until the driver has left the network, cut the power and the kernel has read the pin back low; the driver then checks the cut on its own bus (a CMD52 50 ms later that nothing should answer) and the shell prints ... - verified: ..., says unverified when the check could not be made, or ... FAILED ... when the chip still answered (docs/wifi.md 49, 55; hardware-verified 2026-10-01). Past 2 s the shell says [q] quit; the cut cannot be stopped once asked, so q stops the WAIT and says the driver may still answer, and wifi status shows what it did (2026-10-13, backlog/fixed/89). No [b]: it offered [b] background until 2026-10-01, and three seconds does not earn one. The driver stays alive to answer: wifi status says radio off (hard - the chip is powered down; wifi radio on powers it up), every other request answers that the chip is powered down. wifi radio on from this state restores the power, prints radio powered up - starting the driver on the cold chip, restarts the driver, and watches the cold path like powercycle does, ending radio on succeeded - joined <name>. About twenty seconds; the soft off/on stay the two-second switch. on after off hard can still come up WARM - it did at 09:44 and 14:43 on 2026-10-01 - and then, after its one attempt, returns to the prompt saying wifi radio powercycle tries once more; off hard cuts and verifies the power, it does not produce a cold chip on demand (docs/wifi.md 47, 48). On a USB dongle (wifi-usb, docs/wifi-usb.md 18) the power is its port's and is not touched. off hard there is the chip's own power-down, Linux's rtl8192cu_power_off: the network left, the firmware asked to stop and stopped from the host if it does not answer, the chip suspended. Its check is that no firmware is marked running. on and powercycle restart the driver, whose bring-up powers the chip on and uploads its firmware. A dongle that no longer answers on USB cannot be powered down this way; the driver says so, and only unplugging it helps
wifi radio powercyclethe CHIP's power, not the firmware's radio switch: the driver cuts and restores it through the kernel's DevicePower (its own grant, renewable - CLAUDE.md 12.3), and the shell then restarts the driver, which comes up from power-on and rejoins from /system/shared/wifi/keys. The power request itself is bounded at fifteen seconds before the watch begins. Then it says radio powered down for 2.0 s - restarting the driver on the cold chip (with (the network is left) before the dash when it was joined), and BLOCKS with [q] quit [b] background, printing each change of state as the driver answers its status question - waiting for the driver, radio coming up, radio up, joining - and ends in one of two lines: powercycle succeeded - joined <name>, or powercycle failed - <why> (the driver did not answer, this machine has no control over the radio's power, the driver could not be restarted, or the radio did not rejoin within 90 s and where it got to). VERIFIED: success is a join younger than the watch itself, so a stale answer from before the restart cannot pass for one; and a driver that comes back with its radio down means the chip came up warm - its firmware trapped at start - and the shell reports it and stops: ONE cycle per invocation (attempts on the same chip were not independent, so a retry only hid the cause; docs/wifi.md 52), re-runnable by hand. The driver waits 300 ms after power-on before its first command (POWER_ON_SETTLE_MS; five seconds was tried in section 52, changed nothing, and was reverted). BOUNDED at the operator's word: a warm chip ends powercycle failed - the chip came up warm (its firmware trapped at start; ...), returns the prompt, and can be run again - nothing needs a reboot. The driver parks its SDIO host for the whole off window. The "warm starts" this paragraph once expected were a slow host - the Arm cores at their minimum clock - and with the power service's lease every power cycle has come up cold (docs/wifi.md 55-57). q leaves the watch. q and b both return to the prompt and say the power cycle continues in the driver: once the power is cut there is nothing to stop (docs/wifi.md 47)
wifi hardwareBUILT (section 11): the radios this machine has, one row each - its name, chip, bus, state, network and whether it is the one in use. A report: it pipes
wifi hardware <radio>BUILT (section 11a): one radio in full - the chip as the driver read it, its firmware, its address, and the bus it is reached over down to the port and endpoints. Labelled lines, like wifi info: it pipes
wifi hardware use <radio>BUILT (section 11): choose the radio every other wifi verb talks to and that carries the frames when the cable is out - onboard or usb (the usb-XXXX names for two dongles are designed, not built - section 11). An action: it does not pipe
wifi helpusage, with one real example per row
wifi versionversion number plus the collective copyright line

scan and list are two verbs, not two spellings. Rule 3 forbids synonyms, and these are not one: scan is an ACTION whose value is its effect - the radio sweeps and the driver's cache is replaced - and list is a REPORT whose value is its output. The split exists for two mechanical reasons, not taste. First, a verb that both waits and produces puts its status line into the pipe: with one verb, wifi list | count was off by one because scanning [q] quit went down the pipe with the records. Second, a picker's numbers are only safe when the list under them cannot change, and a list that changes only when somebody explicitly runs scan is exactly that.

scan is a candidate for the background for the same reason copy is (55_background.md §3): the test is "is this command's value its EFFECT or its OUTPUT", and a scan's value is entirely the filled cache. Its output is a different command, run later. The driver is already the job service - it holds no console capability, does the work, and holds the result - so backgrounding a scan costs no new service and no jobs row. The state lives in wifi status instead.

join and leave, not connect and disconnect - renamed 2026-09-29 at the operator's call. join is what the 802.11 layer calls it (a station joins a BSS) and what the driver's log has said since the first association; connect is the socket word and stays with tcp. leave is its natural opposite. Rule 3 forbids aliases, so wifi connect answers try `wifi join <ssid>` and wifi disconnect answers try `wifi leave` , the way ls answers try dir.

The outcome is one confident word, or not joined - <why>. joined Maple-House; already joined Maple-House (checked live against the firmware, not remembered - nothing is sent); not joined - incorrect passphrase; not joined - no network named X in range; not joined - aborted. "Incorrect passphrase" is not a guess: it is the one situation in which the access point receives our message 2 and, its MIC failing to verify, repeats message 1 and gives up - nothing else produces that pattern. Before the handshake was built (2026-09-29) the WPA2 case ended in not joined - X began the WPA2 handshake, which this driver did not answer; that reply stays in the table so a shell can still name it, and no current driver produces it.

wifi join takes no passphrase argument, in any position, and that is a security decision rather than an ergonomic one: an argument would be recorded in the recall ring and written to /users/default/history, where an up-arrow recovers it. The passphrase is only ever read through the shell's invisible-entry path (input secret, docs/scripting.md §8), which is already excluded from both.

3. Output is a pipeable structure (rule 12)

wifi list is a producer. In a pipe it is RECORDS - one per network, with the columns network, band, signal (the word), dbm (the reading, a signed number), security and note - so the record stages work on it with no special case:

wifi list | where security=WPA2
wifi list | sort reverse dbm          strongest first
wifi list | where dbm>-60
wifi list | select network dbm
wifi list | max dbm                   the strongest signal heard
wifi list | count
wifi list | to json
wifi list | write /networks.txt

On the screen, bare wifi list prints the fixed-width lines below; the records are the same facts, decoded once for both. match, first and last are text stages and refuse a record stream with a pointer to where - the same rule as dir and observe now. (It was text in a pipe for one morning, 2026-10-02, and wifi list | match WPA2 worked then; where security=WPA2 is its record form. docs/wifi.md 58.)

The reports pipe; the actions do not. list, stored, status, info, debug, hardware (and hardware <radio>) and version can start a pipe. scan, join, leave, forget, radio ... and hardware use are actions, and refuse with a sentence that names the reports: their output is a conversation, not data - join reads a passphrase from the console, and in a pipe its prompt would disappear while it waited. A report that fails (no scan yet, a scan running, the radio down) says why on the CONSOLE and stops the pipe, and an empty scan gives a pipe no rows at all, so wifi list | count says 0 rather than counting the sentence. (Until 2026-10-02 this section described pipes the shell refused: wifi was not on its list of producers - docs/wifi.md 58.)

Fields, in order: network, band, signal, security, note. Signal is the dBm, a raw fact (rule 7), with a word beside it that is a STATED RULE over the number, so a reader can check it: -50 dBm or stronger is excellent, to -60 good, to -70 fair, weaker is weak. The number is always printed; a reader given only the word could not recover it.

The columns are fixed-width, and they can be because the widest is known. An SSID is at most 32 bytes - that is the size of the field in the beacon, so a longer one cannot exist - and every other field has a bounded vocabulary. So the layout is NETWORK padded to 32, BAND to 6 (2.4GHz or 5GHz), SIGNAL as a word padded to 9 then the dBm right-aligned to 4 (the header carries the unit once), SECURITY to 8, then NOTE - joined for the network the radio is on, saved for one whose key the driver holds, else blank - two spaces between columns, 78 columns with the number:

gsh> wifi list
Maple-House                       5GHz    excellent  -41  WPA2      joined
Maple-House                       2.4GHz  excellent  -47  WPA2
(hidden)                          5GHz    fair       -63  WPA2
BT-Hub6-K7QR                      2.4GHz  weak       -71  WPA2/WPA  saved
Riverside Tenant WiFi Guest Netw  2.4GHz  weak       -74  open
xfinitywifi                       2.4GHz  weak       -79  open
SKY7F2B1                          5GHz    weak       -80  WPA2
PrinterDirect-4A                  2.4GHz  weak       -82  WEP
(unprintable)                     2.4GHz  weak       -85  WPA2
a                                 5GHz    weak       -86  WPA
Free_Cafe_WiFi                    2.4GHz  weak       -88  open

Nothing is ever truncated: the fifth row is exactly 32 bytes and fills its column edge to edge. A network that does not announce its name prints (hidden); a byte outside printable ASCII prints as a dot rather than reaching the terminal as a control code. security is one of open, WEP, WPA, WPA2, WPA2/WPA, read from the beacon. Two rows with the same name are two access points - the same network on 2.4 GHz and 5 GHz is the usual case, and both are listed because both were heard.

Rows are in the order the radio heard them, and list prints the cache in that order every time. A sorted view is one pipe away (wifi list | sort reverse dbm), and keeping the producer unsorted is what lets the picker's numbers in section 4 stay put.

wifi list never scans, and says so when there is nothing to print:

State of the cacheWhat wifi list prints
a completed scanits records, as above
no scan since bootno scan yet - run wifi scan (an error, not an empty list)
a scan is runningscanning - 9 heard so far; wifi list when it finishes (an error, not the OLD cache)
the radio is downone of the four radio-down lines, each naming why (section 4f and section 5)

The two error rows are Commandment III: the cache is a derived view of the air at a past moment, and a derived view is never served as current when it is not. An empty list would say "no networks" about a radio nobody asked; the old cache during a sweep would hand a pipe stale rows with nothing marking them stale. An error goes to nobody's pipe.

wifi stored is a producer of one network name per line. It exists so that "which networks do I have a password for" is answerable without a way to ask "what is the password", which is not a supported question at any privilege level.

4. wifi scan: a live surface that ends in a picker (rules 10, 10a, 11)

A scan takes real time on real hardware - the radio sweeps channels, about three seconds. wifi scan is the surface for that wait, and it earns its keep by being the one place a network can be chosen by number.

4a. What it looks like

Rows appear as the radio hears them, numbered from 1, appended and never re-sorted while the surface is live - a number that moves under a finger is the one bug a picker must not have. When the firmware reports the sweep complete, the status line becomes the prompt:

gsh> wifi scan
scanning  [q] quit  [b] background
    NETWORK                           BAND    SIGNAL (dBm)    SECURITY  NOTE
 1  Maple-House                       5GHz    excellent  -41  WPA2      joined
 2  Maple-House                       2.4GHz  excellent  -47  WPA2
 3  (hidden)                          5GHz    fair       -63  WPA2
 4  BT-Hub6-K7QR                      2.4GHz  weak       -71  WPA2/WPA  saved
 5  Riverside Tenant WiFi Guest Netw  2.4GHz  weak       -74  open
 6  xfinitywifi                       2.4GHz  weak       -79  open
 7  SKY7F2B1                          5GHz    weak       -80  WPA2
 8  PrinterDirect-4A                  2.4GHz  weak       -82  WEP
 9  (unprintable)                     2.4GHz  weak       -85  WPA2
10  a                                 5GHz    weak       -86  WPA
11  Free_Cafe_WiFi                    2.4GHz  weak       -88  open
11 networks in 3 s
join: type a number and Enter, [q] quit:

The header row and the numbers exist only here. wifi list prints the same columns without either, so the eye lines up between the two and a pipe never sees a number it did not ask for.

Digits are not text in this surface, so q and Esc are free to mean leave (rule 10a). Numbers go to two digits because the driver holds up to 32 results, hence Enter rather than a single keypress. A number outside the list says so and asks again.

4b. Picking one

A pick names a NETWORK, and the radio chooses the access point. Two rows with the same SSID (rows 1 and 2 above) are one network on two bands; picking either joins that network, and the firmware associates with whichever access point it prefers - usually, not always, the stronger. So the pick echoes the row, and the join reports what was actually joined:

join: 1
 1  Maple-House                       5GHz    excellent  -41  WPA2      joined
passphrase (not shown):
joining Maple-House
joined Maple-House
  (for the address, type net)

The joined line names the network only. Which access point answered - its BSSID and band - is wifi info, read from the firmware: the row said which network was asked for, the firmware says which access point answered, and where they differ the firmware is the truth.

An open network skips the passphrase prompt. A WEP network is refused at the pick with WEP is not supported (section 9) rather than asked for a key it will not use. A (hidden) row cannot be joined from the picker: the join request carries the SSID and no BSSID (sdk/wifi/src/wire.rs), a hidden row's SSID is empty, and the shell refuses an empty one with wifi: the name or passphrase is out of range - nothing was sent. wifi join cannot reach it either, since it needs the name.

The passphrase prompt is a typing surface, so q is a letter there. Esc or ^Q abandons it (rule 10a), and wifi status then reports not associated. A prompt abandoned sends nothing.

4c. The two keys while scanning

q stops the SWEEP, not just the shell's interest in it (rule 11). The driver is told to abort the scan and the radio stops - on the Broadcom. On the AIC8800 no cancel is sent (its message is not confirmed from the source): the chip finishes the sweep and the driver discards what it sends. The rows already on screen were a partial hearing of the room and are not kept: the cache holds the last complete scan, and a half-scan replacing it would be a view that lies about how much of the room it saw. It says so:

scan stopped - 4 heard, not kept; the last complete scan (11 networks, 180 s ago) stands

b returns the prompt and leaves the radio sweeping. The same key foreground uses for the same meaning (55_background.md §2). Nothing is printed to the console after that - a detached scan has no console, exactly as a detached copy has none - and the results land in the cache when the sweep ends. The picker never appears; wifi list and wifi join <ssid> are the way in:

gsh> wifi scan
scanning  [q] quit  [b] background
    NETWORK                           BAND    SIGNAL (dBm)    SECURITY  NOTE
 1  Maple-House                       5GHz    excellent  -41  WPA2      joined
 2  Maple-House                       2.4GHz  excellent  -47  WPA2
scan continues in the driver - wifi list when it finishes, wifi status meanwhile
gsh> wifi status
radio      on
network    none (not associated)
scan       running - 6 heard so far
gsh> wifi list
scanning - 9 heard so far; wifi list when it finishes
gsh> wifi list
Maple-House                       5GHz    excellent  -41  WPA2      joined
...

A wifi scan while one is running attaches to it - it shows the rows heard so far and continues live - rather than starting a second, because the radio has one sweep in it at a time.

4d. The cache and its age

The driver owns the cache: it already holds the scan result, and it is the one place that survives between shell commands. It is bounded at 32 networks, replaced only by a completed wifi scan, and lost with the driver - a respawned driver starts with no cache and wifi list says no scan yet.

Its age is always available. wifi scan ends with 11 networks in 3 s (whole seconds); wifi status carries last scan: 42 s ago, 11 networks. wifi list itself stays records-only so pipes stay clean - the age is one command away, never mixed into the data.

The operator drew the line: status is the human operational answer, info the detailed connection and device facts, debug the driver's own account for whoever is fixing it. All three are labelled lines, so they pipe (wifi status | match signal), and every link fact is READ from the firmware when asked - BSSID, RSSI, chanspec - not remembered from the last join.

gsh> wifi status
radio      on
network    Maple-House  5GHz
signal     excellent  -41 dBm
security   WPA2
joined     3 min ago
last scan  42 s ago, 11 networks

The word before the dBm is the same stated rule as the scan rows (section 3): -50 dBm or stronger excellent, to -60 good, to -70 fair, weaker weak. Not associated: network none (not associated) and no signal, security or joined lines. Radio off by the soft switch: radio off (soft - the firmware's switch; the chip stays powered; wifi radio on turns it back on) and the same. The last line is one of scan running - N heard so far, last scan N s ago, M networks, or last scan none - run wifi scan.

Two radio states carry their own line instead of the table above. After wifi radio off hard, wifi status says radio off (hard - the chip is powered down; wifi radio on powers it up). With the radio DOWN - the driver is alive but its radio is not up, at boot or after a respawn - the driver says why (byte 1 of its RADIO_DOWN reply, docs/wifi.md 49) and wifi status prints exactly one of:

wifi: the radio is down - its firmware trapped at start (the chip came up warm); `wifi radio powercycle` cuts its power and tries again
wifi: the radio is down - the driver found no working radio on its bus; `wifi radio powercycle` restores the chip's power and tries again
wifi: the radio is down - the driver's bring-up stopped before it was up (the serial log names the stage); `wifi radio powercycle` tries again
wifi: the radio is there, but its driver stops at a stage that is not built (the serial log names it); nothing here can bring it further

The fourth is reason 4 (DOWN_NOT_BUILT). Corrected 2026-10-08: this described the VisionFive 2 Lite's radio before its driver existed. The AIC8800 now runs to a station (docs/wifi-aic8800.md, V0-V6), and wifi-driver sends reason 4 only when that bring-up stops before a station interface exists - so the shell's old sentence, "its driver is not written yet ... nothing here can bring it up", was untrue; since 2026-10-10 it names a stage that is not built instead (backlog/fixed/80 H13). Whether radio on and powercycle should retry on this reason is an open decision (audits/documentation-audit.md Audit 12). As written before: the VisionFive 2 Lite's radio, which this driver identifies and cannot yet run. wifi radio on and wifi radio powercycle print the same sentence there and restart nothing, since a restart would identify the same chip and stop at the same place. wifi radio off hard does cut its power, and wifi radio on after it restores the power, restarts the driver and ends with this sentence. Only reason 1 is ever reported as a chip that came up warm. A reason the shell does not know (0, or one added later) prints the generic wifi: the radio is down; wifi radio powercycle tries again.

The earlier single line, "did not come up at boot", is gone: it was wrong after a respawn and named no cause.

gsh> wifi info
radio       on
network     Maple-House
bssid       02:1a:7e:c4:09:51
band        5GHz
channel     44
signal      excellent  -41 dBm
security    WPA2
joined      3 min ago
last scan   42 s ago
networks    11
addressing  type net (an IP address has one owner, and it is not this command)

info deliberately ends where net begins. Section 1 gives an IP address exactly one owner so that two answers are never possible; the last line says where the other half of "am I online" lives rather than printing a copy of it.

One exception to the live read, stated because it is deliberate: while a sweep runs the link is NOT read - a control exchange takes frames off the bus and skips the ones that are not its reply, which mid-sweep would be the scan's own results - so both views report the driver's memory and say the sweep is running, which is the fact that matters then. An access point that has dropped the station is noticed on the next status or info after the sweep ends.

4g. wifi debug - the driver's account of itself

Five views, each a word (rule 4). Bare wifi debug prints stats, transport and events together. Every number is a raw count the driver keeps in its Session (rule 7); nothing is a verdict.

ViewWhat it prints
wifi debug statsthe control channel: requests sent, accepted, refused (and the last refusal's command and status), unanswered; the session's age on the driver's own clock
wifi debug transportthe bus side, named for the radio that answers (sdio onboard, usb for a dongle): bytes each way, frames read by channel (control, event, data, glommed, flow-control, other), and the frames read during a control wait and lost to the scan. It printed the CYW43455's SDIO function and block sizes for every radio until 2026-10-10 (backlog/fixed/80 H13); the chip's own layout is in its driver's docs
wifi debug eventshow many of each firmware event this driver names has arrived, and the last one's code and status
wifi debug firmwarethe radio's driver, the running firmware's own version string and capability words (asked of it now, not remembered), its MAC, and that the host runs the handshake. It printed the CYW43455 and its image for every radio until 2026-10-10 (backlog/fixed/80 H13); the chip, the image and its provenance are wifi hardware and the driver's docs
wifi debug tracethe last 64 frames on the bus, oldest first
gsh> wifi debug trace
        ms  frame     id     what
    12.184  TX CTRL   id=8   cmd=263 len=108
    12.192  RX CTRL   id=8   cmd=263 status=0 len=44
    12.431  RX EVENT         event=69 status=8 len=572
    12.612  RX EVENT         event=69 status=8 len=572
    14.004  RX EVENT         event=69 status=0 len=24

RX GDESC is a glom descriptor (it lists the lengths of the superframe that follows), RX GLOM the superframe itself, and a trailing * marks an event or data frame taken out of one.

The trace is a fixed ring of 64 entries in the driver (§26.6.1), the oldest overwritten - two full scans' worth. Timestamps are milliseconds since the driver's session began, by its own cycle counter; where the kernel gave it no rate they read 0.000 everywhere rather than inventing a clock. Every reader of frames feeds it - the control waits, the sweep, the join - so it is the whole traffic and not one path's view, which is what makes it the instrument this port was built without: the frames a control exchange read and skipped mid-sweep appear in it, and are counted under skipped.

4e. What this cost the driver, said plainly

scan::collect used to block the driver until the firmware reported the sweep complete, which is why rule 11 was unmet and why b and wifi list-during-a-scan could not exist. All three needed one change, made on 2026-09-29: the scan became a state (scan::step) that the driver's serve loop advances one frame at a time, answering list (with the "scanning" error), status (with the count so far) and abort (on the Broadcom, the firmware's own abort - a one-channel scan of channel -1, read from Linux before it was written; the AIC8800 sends none and discards the rest of the sweep) between frames. That was the remaining debt of phase 3, and it is paid.

wifi join blocks through association and is escapable with q. Escaping it mid-handshake leaves the radio not associated, and wifi status says so - a half-joined state is never reported as joined.

5. Failure is loud and says which half failed

The distinction a user needs is whose fault it is, and there are four different answers that all look like "no internet":

SituationWhat wifi says
No radio service running (neither wifi-driver nor wifi-usb)no wireless radio on this machine, then (neither `wifi-driver` nor `wifi-usb` is running - this machine has no radio, or none is driven yet) - and it is not an error to ask
Driver running, not answeringwifi: the radio driver is not answering, after a bounded wait - or, when it still owes answers to earlier requests, wifi: not sent - the radio driver still owes N answer(s) ...
Driver running, firmware trapped at startwifi: the radio is down - its firmware trapped at start (the chip came up warm); wifi radio powercycle cuts its power and tries again
Driver running, no working radio on its buswifi: the radio is down - the driver found no working radio on its bus; wifi radio powercycle restores the chip's power and tries again
Driver running, bring-up stopped earlywifi: the radio is down - the driver's bring-up stopped before it was up (the serial log names the stage); wifi radio powercycle tries again
Driver running, the radio's bring-up stops at a stage that is not built (reason 4)wifi: the radio is there, but its driver stops at a stage that is not built (the serial log names it); nothing here can bring it further
Chip powered down by wifi radio off hardradio off (hard - the chip is powered down; wifi radio on powers it up)
Radio switched off by wifi radio offradio off (soft - the firmware's switch; the chip stays powered; wifi radio on turns it back on)
SSID not found in a scannot joined - no network named <ssid> in range - naming what was searched for
Wrong passphrasenot joined - incorrect passphrase - never "connection failed", which hides it
Associated, no leaseassociation reported as good; net owns the lease and says its own piece

The wrong-passphrase row is the one worth being careful about. A wrong passphrase and a missing DHCP server both end with "no network", and conflating them costs a user an hour. wifi reports association; net reports addressing; neither editorialises about the other.

And the rule above the rules applies throughout: a radio that is absent, wedged or unplugged makes wifi return with a loud unavailable, never hang. That is Commandment VIII at the command layer - wait on the driver's reply or on the loud fact of its death, never on a timer.

6. Where the passphrase goes: a bounded table in the driver's memory, holding KEYS

Decided 2026-09-29, and it supersedes the keyring design docs/wifi.md §6 sketched:

  • The shell prompts with invisible entry and hands the passphrase to the driver, which does not keep it. The moment it arrives the driver derives the pairwise master key from it and the network name (PMK = PBKDF2-HMAC-SHA1(passphrase, ssid, 4096, 32), IEEE 802.11-2020 §12.7.1.2 - a few milliseconds) and the passphrase is gone. The key is the only thing ever needed again: to answer the handshake, to rejoin when the access point drops us, to roam. So the key is what is kept - and it is kept only once it has joined. A join that ends not joined keeps nothing, whatever the reason, and the next wifi join <ssid> asks for the passphrase again. A key that joined once and is later refused as incorrect (the network's passphrase changed) is dropped the same way. Asked for by the operator on 2026-09-29 after the first handshake run: the driver kept the key on arrival, so every failed attempt had to be followed by wifi forget before the prompt would come back.
  • Sixty-four slots of (ssid, key), about 70 bytes each, in the driver's memory. Home, the cafe, the office and every hotspot after them are typed once and rejoined by name. When all sixty-four are held, the one JOINED LONGEST AGO is replaced. The number is a bound (CLAUDE.md §26.6) chosen so that nobody reaches it, not a fit to the 16 MiB the driver may use - a table that grew to fill what was available would be the elastic growth §26.6.1 says to resist, and its limit would be readable nowhere.
  • On disk since 2026-09-30, in /system/shared/wifi/keys, by the operator's decision - "save passphrase on filesystem so that when the machine starts up, if there's wifi, it auto joins ... and ready to go", with the exposure named and accepted: "if I were to unplug the usbstick and put it on another machine, that machine will have access to wifi passphrase (I'm ok with that for now)." What the file holds is the DERIVED KEY of each network, never the passphrase text: it joins the network exactly as the passphrase would, so the card's holder can join it, but it does not reveal the passphrase itself, which people reuse elsewhere. Network names are in plain text. Nothing is encrypted at rest - there is no per-machine secret to encrypt with, and pretending otherwise would be a silent substitution (CLAUDE.md §26.4). The in-memory table stays the working set: the file is loaded once when the radio comes up and written after every change to the table (a join that added or re-ordered a key, a forget); where fs is absent or mid-restart the driver retries a bounded number of times, says so, and runs on the table alone, as it did before. At most 48 networks are saved, most recently used first. Open networks hold no key and are not saved. The previous decision - the table dying with the driver, "better that than the kernel crashing" - still governs the CRASH case: nothing above the kernel must survive, and a respawned driver reads the file back rather than carrying anything across its own death.
  • At boot the radio joins the network it last joined, from the file, without being asked, and the cable still wins for the link. The log says joining the network last joined, from /system/shared/wifi/keys, then the same lines a wifi join prints; if that network is out of range or refuses, not joined and the prompt is yours. wifi radio on after radio off does the same from memory (section 2).
  • wifi join <ssid> asks for a passphrase only when one is needed. The shell sends the name alone first; the driver joins with the stored key if the slot holds that name, joins open if the last sweep heard the network as open, and otherwise answers needs a passphrase - at which point, and only then, the shell asks. A network joined once this boot is rejoined by name alone; one that has only been TRIED is asked for again.
  • wifi stored prints the held names, one per line; wifi forget <ssid> zeroes that entry. Neither can print a key.
  • The primitives that derive the key are checked against their published vectors at every boot (FIPS 180-1, RFC 2202, RFC 6070, IEEE 802.11 Annex J.4.2). A wrong hash would be refused by every access point in a way indistinguishable from a wrong passphrase, so if the check fails, passphrases are refused with the reason rather than accepted into silence.

One residual, recorded here rather than left for a reader to work out: a USB keyboard driver sees the passphrase as it is typed. That is the SEC-2 residual - CONSOLE_PUSH holders are inside the shell's trust perimeter because keystrokes are commands - and it is inherent to being a keyboard, not a gap in this design.

7. Built-in or service, and why it is both

0_conventions.md §2 says to prefer a standalone service whenever a command would otherwise run beside dangerous authority. That default is not followed here today. Every verb is a shell built-in routed through cmd_wifi, and every one asks the driver over the same send cap (wifi_ask), so list, status and stored hold exactly the authority join, forget and radio do. Nothing separates the read-only verbs from the others by construction; a read-only view with a narrower cap would be a standalone service, and none has been built.

join could not follow the default anyway, for a mechanical reason. There is one console input ring with one reader slot (docs/console-service.md §5a), and the shell is the reader. A spawned service cannot prompt for a passphrase: it would have to take the input ring, and while it held it the shell could not field the take/release messages. So the invisible-entry prompt has to happen in the shell, which makes join a built-in. scan follows it for the same reason: the picker reads keys.

That is not a compromise so much as the right place anyway - the shell is where authority is decided (CLAUDE.md Appendix D.4), and handing over a secret is exactly that decision. What the built-in must not do is keep the secret: it reads it, hands it to the driver - which derives the key and drops the passphrase (section 6) - and drops its own copy.

8. Tab completion (rule 9)

wifi completes from the command table; its subcommands from SUBCMD_FIRST in services/shell/src/main.rs, which is also what doc_command_check.py reads - so this document cannot come to show a subcommand the shell does not offer. Two subcommands complete one level further, from SUBCMD_SECOND: radio to on, off and powercycle (off hard is typed, not completed), and debug to events, stats, firmware, transport and trace. An SSID argument completes from wifi stored, never from the last scan, because completing from a scan would leak the names of networks in range into a shell's history.

9. Not in scope

Following docs/wifi.md §9, and for the same reasons: no enterprise or 802.1X, no WPA3, no WEP ever (a WEP row is listed because it was heard, and refused at the pick), no access-point mode, no manual channel selection, no joining a hidden network in the first version (the join request carries only an SSID, so neither the picker nor wifi join <ssid> can reach one), and no signal-strength monitor - observe is where live views live, and a second one here would be the duplication rule 7 exists to prevent.

Decided by the operator on 2026-09-29, after the first joined boot: "cable always wins. unplug the cable, switch to wifi automatically."

  • wifi join makes the radio available; the cable decides. While the ethernet cable reports a link, every frame goes over it. Pull the cable and, within about a second, the frames go over the radio - if it is joined. Plug it back and they return to the cable. Nothing is typed for either switch.
  • wifi leave and radio off take the radio out of that choice; with the cable out too, the link is down and net says so.
  • net names the carrier: link up via the cable, link up via wifi (the cable is out), or link down - no cable, and the radio is not joined. wifi status says whether the radio is JOINED, which is a different fact: a joined radio with the cable in is standing by.
  • A switch re-configures the network. The radio has its own address, so the stack sees a different link and asks for a lease again (docs/wifi.md 41). A ping in flight across the switch loses its replies for a second or two; that is the switch, not a fault.
  • Where the rule lives. In nic-driver, which is the link front end on every board (docs/wifi.md 2) - one comparison, Carrier in nic-driver's radio.rs, shared by every backend that bridges to a radio. net-stack never learns there are two links, and the wifi utility never learns there is a cable.

11. Which radio: wifi hardware - the report BUILT (2026-10-06); one radio in full (11a) and use BUILT (2026-10-07)

Agreed with the operator on 2026-10-06, while the USB dongle was being brought to xhci (docs/wifi-usb.md section 7, U2). Until then every machine has had at most one radio, and the shell takes the first of its radio services that is running (RADIOS: wifi-driver, then wifi-usb). A Pi 4 or a VisionFive with the dongle plugged in has two, and that rule would leave the dongle unreachable.

wifi hardware is a report, one row per radio, and like every report it pipes (rule 12): records with the columns radio, chip, bus, state, network and in_use.

As built (2026-10-06). The shell asks every radio service that is running - not only the first - what it is (wire::OP_HARDWARE, answered by the radio's Host, so a radio that is down or powered off still says its chip and bus) and what it is doing (OP_STATUS). state is joined, on, off, off hard, down or not answering; network is empty unless joined; in_use is yes on the radio every other verb addresses, the first running one, until use lets the operator choose. A radio whose chip was never identified says not identified rather than the board's usual part. Two dongles are not supported yet, so a dongle is always plain usb, and its bus names the host only (USB xhci, USB dwc2): the socket in the example below belongs to the two-dongle naming, not built. The console's headings are in capitals (RADIO CHIP ...). Checked in QEMU on the Pi 4 image, where wifi-driver runs with no card: onboard not identified SDIO down - *, and wifi hardware | to json gives the record.

in_use is the shell's view, and nic-driver's bridge follows the same choice from the radios' own answers (wire::OP_USE), so the two agree: the onboard radio or the dongle on the Pi 4 and the VisionFive, the dongle on the Pi 2 and on the PCs' RTL8168 (docs/wifi-usb.md 39).

radio      chip         bus                 state    network     in use
onboard    CYW43455     SDIO                joined   home-5g     *
usb-1a0d   RTL8188CUS   USB xhci port 3     on       -
usb-77e2   RTL8188CUS   USB xhci port 4     off      -

How a radio is named - by what it is, not where it is (CLAUDE.md invariant 11, identity is stable and location is not):

  • onboard for the board's own radio.
  • usb for a dongle, when there is one.
  • usb- and the last two bytes of its MAC address (usb-1a0d) for each dongle when there is more than one. The address is in the dongle's own efuse, so the name follows the dongle into any socket, on any board, across any reboot. Numbering them in the order they came up would swap after a replug, and naming them by socket would make one dongle a different radio when it moved. The socket is still shown, in bus, so a reader can tell which stick is which.

wifi hardware use <radio> chooses the radio the other wifi verbs address and that carries the frames when the cable is out. It is an action and does not pipe.

  • The choice lives in nic-driver, not in the shell. The radio that carries the traffic is the one nic-driver's bridge talks to (section 10), so a choice held by the shell alone would let wifi status describe one radio while the frames went through the other. use tells nic-driver; the shell asks nic-driver which radio is in use and follows its answer. One fact, one owner.
  • The cable still always wins (section 10). use chooses among radios; it does not choose the radio over the cable.
  • Refused, with a sentence:
    • a name that is not in the report: no radio called 'usb-9999' - wifi hardware lists them;
    • plain usb when there are two dongles: there are two USB radios - usb-1a0d or usb-77e2.
  • The default is today's order: the onboard radio, else the dongle - so on a machine with no onboard radio (the PCs) the dongle is the default.
  • A choice is saved, by its owner. nic-driver holds the choice, so nic-driver keeps it, in /system/shared/wifi/radio, as wifi-driver keeps /system/shared/wifi/keys, and reads it back at boot and on every respawn. That gives nic-driver fs as a peer, a grant pinned with this reason. The shell saving it and telling nic-driver at each start was the alternative, and it splits one fact across two services.
  • The file holds the full name (usb-1a0d), even while the report shows plain usb because there is one dongle, so a second dongle plugged in later does not make the saved choice ambiguous.
  • A saved choice is a preference, not a requirement. When the chosen radio is not there (the dongle is unplugged), the default order carries the link and wifi hardware says so on the chosen radio's row; when it comes back, the link returns to it. Unplugging a dongle must not leave a machine without its other radio, and plugging it back must not need choosing again. wifi hardware use onboard (or the radio the default would pick) clears the file rather than writing the default into it.

What use does, walked through on a Pi 4 with its onboard radio joined and a dongle plugged in.

  1. The dongle is already up before anyone types use: wifi-usb runs while its dongle is attached (on the Pi 2 the supervisor starts it when the host reports the dongle), and plugging the dongle in is what binds it and brings the chip up (a few seconds). use on a radio still coming up waits for it, bounded, and says so.
  2. use usb records the choice in nic-driver and /system/shared/wifi/radio.
  3. nic-driver asks the radio in use what it is joined to, and asks the chosen one to join that network. The key is read from /system/shared/wifi/keys, which both radio drivers share, so nothing is typed.
  4. Only once the chosen radio reports JOINED do the frames move to it - so a join that fails loses nothing: use says why, and the other radio keeps the link.
  5. The new radio has its own address, so the stack asks for a lease again: the same second or two a ping loses across a cable-to-radio switch (section 10).
  6. The other radio then leaves the network and idles, powered, so use back is steps 3 to 5 again.

What that needs, beyond the report and the choice:

  • Only the radio in use auto-joins. Today each radio driver rejoins the last network from /system/shared/wifi/keys at boot; with two, both would join the same access point. A radio auto-joins only when nic-driver says it is the one in use.
  • /system/shared/wifi/keys gets two writers. Each driver rewrites the file from its own table, so one radio's save could drop a key the other added. A save re-reads the file and merges before writing.
  • nic-driver asks a radio what it is joined to, which it does not do today.

As built (2026-10-07), and where it differs from the design above. Built, checked in QEMU on the Pi 4 image (the refusals, and use of the radio already in use), and hardware-verified on the Pi 4 with both radios (docs/wifi-usb.md 37).

  • The SHELL writes /system/shared/wifi/radio, and nothing else does. The design gave the choice to nic-driver, but the choice decides which radio the wifi verbs address as well, on boards where nic-driver bridges and where it once did not. So the one writer is the shell; the file holds the radio's name (onboard, usb), and choosing the default radio removes it. One fact, one writer, every other party a reader.
  • Each radio knows whether it is the one in use, read from /system/shared/wifi/radio at start beside /system/shared/wifi/keys (same bounded retries) and kept current by a new op, wire::OP_USE (14), which the shell sends every running radio on use. The shell asks the radios the same op to find the one in use; with one running, it asks nothing.
  • nic-driver holds no choice and needs no fs. Each radio's link status (OP_NET_INFO) gains a byte saying whether it is the one in use. nic-driver's bridge follows it: while the current radio says another is in use, or does not answer, it asks the other (at most every 5 s) and moves to it when that one says it is in use - or when the current one is gone and the other answers, so a missing radio never leaves the machine without the one that is there. Its peers on the Pi 4 and the VisionFive gain wifi-usb, the second radio. A respawned nic-driver finds the choice again the same way.
  • The join is the shell's, before anything moves. use asks the chosen radio to join the network the radio in use is on, by name (its key from /system/shared/wifi/keys, or the passphrase asked for if it does not hold one). Only once it says JOINED is the choice written and told to the radios; then the radio that was in use leaves and stays up. A join that fails changes nothing and says so.
  • Only the radio in use rejoins at start: a radio that /system/shared/wifi/radio says is not the one skips the boot rejoin, with a line saying why. With no file, both rejoin as before.
  • /system/shared/wifi/keys is merged on save (keyfile::save): the file's entries this radio's table lacks are kept after its own. A wifi forget passes the network to drop, and the shell sends the forget to both radios, so the one that did not hold the key does not put it back.
  • Not done: the dongle's two-dongle names (usb-1a0d) and saving a choice of a radio by its MAC; a choice of usb with the dongle unplugged is kept in the file and the onboard radio carries the link, but wifi hardware does not yet mark the chosen-but-absent radio's row.

11a. One radio in full: wifi hardware <radio> - BUILT (2026-10-07), hardware-verified on the Pi 4 (docs/wifi-usb.md 37)

Asked for by the operator on 2026-10-07, as wifi hardware details usb<x>. Built without the word details: naming a radio already means "tell me about this one", as dir with and without a path does, and use is the only other word after hardware. A radio name is never use, so the two cannot be confused.

The report (section 11) is the summary, one row per radio. This is the raw account of one: what the driver read from the chip, and how the machine reaches it. Like wifi info it is labelled lines, so it pipes the way wifi info does (wifi hardware usb | match firmware).

As built (2026-10-07). The driver answers wire::OP_HARDWARE_DETAIL with labelled facts, each label one of wire::DETAIL_LABELS; the shell prints radio and driver, then the facts in that list's order, then state and in use. The host's facts (serve::Host::details) come in any state, the station's (Station::details) only while there is one; a radio with no station says firmware not running - the radio is down. wifi-driver's loop for a radio that never came up answers too, with the bus and chip not identified. Differences from the examples below: dwc2 does not report where the dongle is yet, so on the Pi 2 the bus line says so; the USB speed is not reported; the onboard radio's chip line is its name, without a revision. Checked in QEMU on the Pi 4 image, where wifi-driver runs with no card:

radio      onboard
driver     wifi-driver
chip       not identified - the bring-up stopped before one was
firmware   not running - the radio is down
bus        SDIO, card clock asked 25 MHz, function 2 at 512-byte blocks
state      down
in use     yes
gsh> wifi hardware usb
radio      usb
driver     wifi-usb
chip       RTL8188CUS - RTL8188C, 1T1R, cut A, TSMC
id         0bda:8176 (vendor:product)
address    00:11:22:33:44:55 (efuse)
firmware   rtl8192cufw_TMSC.bin, version 88.2, running
bus        USB 2.0 high speed, xhci, root port 1 > hub port 3, slot 3
endpoints  control 64 B; bulk IN 0x81 512 B; bulk OUT 0x02 0x03 512 B
queues     high, normal (low unused)
state      joined home-5g
gsh> wifi hardware onboard
radio      onboard
driver     wifi-driver
chip       CYW43455, revision 6
address    00:11:22:33:44:66 (from the firmware)
firmware   7.45.241, running
bus        SDIO, 4-bit, 50 MHz, function 2 block 512 B
state      joined home-5g

The dongle's chip, IDs, firmware, endpoints and queues above are what the Pi 4 logged on 2026-10-07. The onboard radio's revision, firmware version and clock are illustrative, and both addresses are made up.

Who says what - one owner per fact (Commandment III).

  • The radio's driver answers the whole record, over a new wire op beside OP_HARDWARE. It is the only one the shell already asks, and it is the one that read the chip.
  • The chip, its address and its firmware are the driver's own readings: the efuse and the version the firmware reported for the dongle, and the chip ID and firmware version for the onboard radio.
  • The bus lines on a USB radio come from its host, which the driver relays: host, port path, slot, endpoints and the IN packet size. usbfn::OP_INFO's answer carries them after the IDs, from usbfn::INFO_WHERE_AT; xhci and ehci fill them in, dwc2 not yet. The driver does not work them out itself, because only the host enumerated the device.
  • On the onboard radio the bus facts are the driver's own, since the driver is its host.

When the radio is down (the driver is running and the chip is not up), the facts that are the hardware's are still given: the chip's ID once read, the bus, the endpoints. The readings that need a running chip say why they are missing rather than going blank: firmware not running - the radio is down. A dongle that is unplugged has no driver running and is not in the report, so wifi hardware usb says no radio called 'usb' - wifi hardware lists them, the same sentence use gives.

What it is not. It is not wifi debug: no counters, no event history, no register dumps. Those are the driver's account of its own work and stay there. This shows what the hardware IS and how it is reached. Nothing here is written to the chip. The dongle's facts are all already held, so asking it costs no bus traffic; the onboard radio's address and firmware version are asked of its firmware (two reads, as wifi debug firmware makes), and not mid-sweep or with the radio off.

The address is shown on the console and in the record, as wifi info shows the network's. It is the operator's own device. The rule that keeps addresses out of the repository is about commits, not about what the machine tells its owner.

Refused, with a sentence: a name that is not in the report: no radio called 'usb-9999' - wifi hardware lists them; and plain usb when there are two dongles: there are two USB radios - usb-1a0d or usb-77e2 - designed with the two-dongle names and, like them, not built. These are section 11's sentences, shared.

More than one dongle is named now and built later. Today each USB host binds one radio, and one wifi-usb drives one dongle. Two need either a wifi-usb per dongle or one serving several, plus a host that binds more than one. The names and the report are designed for many so they need no change then; the support itself waits until two are plugged in (CLAUDE.md 26.2).

Tab completion (rule 9): hardware, then a radio's name or use (onboard, usb, use), then after use the radio names.

audio - sound: what is playing, the volume, the codec's power, a test tone

Version reported by audio version. Implementation shape: shell built-in, asking the board's audio driver over IPC - audio-driver on x86, pwm-audio on the Pis. The driver owns the controller; the shell owns the words.

Status, as built and honest (2026-10-10)

Built, run in QEMU, and heard on every board with an audio output. In QEMU (intel-hda with the hda-output codec, mixer=on) every verb in section 1 answers as section 2 says, and the WAV QEMU writes agrees - the level follows the volume, volume 0 and mute are silent, a tone lasts as long as asked and q cuts it short (osdev test audio). On hardware: the Dell Wyse 5070 and the HP T630 through audio-driver, speaker and headphones on each; the Pi 4 and the Pi 2 through pwm-audio, which drives the 3.5 mm jack by PWM. The VisionFive 2 Lite has no audio output, and audio answers no audio hardware on this machine. A codec not in the driver's table of verified codecs is surveyed and not played on, and every verb below says so (section 4).

The verbs in section 1 are built, and the volume, the mute and the output survive a reboot (section 5). hardware, outputs, output, debug, system sounds and the keyboard shortcuts were built on 2026-10-09 (feat/audio-finish) and run in QEMU, which has ONE audio controller with ONE output - so the reports, the in-use marks and a refused name are shown there; a second controller and switching between two outputs are not, until hardware with them. That is all of the surface the operator agreed except audio hardware use, which waits for a machine with two audio devices a driver can play on; that word answers not built rather than being mistaken for a fault.

1. Verbs

VerbKindWhat it does
audiohelpusage (rule 1). Never an alias for status
audio statusreport, pipeson or off, volume, muted, output, what is playing and how far through, underruns
audio inforeport, pipesthe detail a fault needs: controller version, codec, the path from converter to pin, the amplifier and the step it is at, the format, the ring, interrupt or polling
audio outputsreport, pipes as recordsBUILT: the outputs the device has - line out, speaker, headphone - with * on the one playing, and whether something is plugged into each where the jack can tell (plugged in, empty, or cannot tell). Two of a kind are numbered: line out, line out 2
audio output <name>actionBUILT: play through that output - audio output headphone. Names come from audio outputs; an unknown one is refused with the list. Refused while something plays, and kept in the driver's settings file (section 5)
audio hardwarereport, pipes as recordsBUILT 2026-10-09: every audio device on this machine, one row each - its PCI address as hardware names it (or jack on the Pis), what it is, who made it, the service that drives it or -, its state (the driver's own word for the one it holds: ready, off, surveyed, not played (A6)), and * on the one audio talks to. Answers with or without an audio driver running. The T630 has two controllers (its analog codec and the Radeon's HDMI audio), which is why this exists
audio hardware <device>report, pipesBUILT 2026-10-09: one device in full, by the name audio hardware gives it - why it is or is not driven, its registers and grant as hardware <device> shows them, and the driver's account (audio info) for the one in use. audio hardware use is not built: no machine has two audio devices a driver can play on
audio volume <0-100>actionset the volume. Reading it is audio status - one way to ask (rule 3)
audio muteactionsilence the output, keeping the volume
audio unmuteactionrestore the volume set before mute
audio onactionbring the codec back to full power, then re-apply the volume and the mute
audio offactionstop anything playing and put the codec in its lowest power state; the controller stays up
audio off hardactionstop anything playing and hold the whole controller in reset - the closest HD Audio has to cutting the power. audio on brings it back
audio tone <hz> [seconds]actionplay a sine the driver generates itself, 20 to 20000 Hz, 2 s unless told (tenths allowed: 0.5, up to 600). Blocks with [q] quit; q STOPS the tone (rule 11)
audio play <path>actionplay a WAV file from disk: 16-bit PCM, mono or stereo, 44100 or 48000 Hz where the codec offers it. Blocks with [q] quit; q STOPS it. Anything else is refused with the reason
audio debug [view]report, pipesBUILT: the driver's own account of itself, one view of stats (bare), codec, stream, trace or registers - verbs sent and unanswered, interrupts, underruns and the last sound's rate by the clock; the whole widget graph; the output stream's registers and buffer descriptors; the last 64 verbs and their answers; the controller's globals. On a driver that surveyed its codec and stopped (a codec not in its table), codec, trace and registers still answer. The Pis' jack answers every view, codec and trace with a line saying it has neither
audio system sounds on / offactionBUILT: the short sounds the system makes on its own - two falling tones for a command that failed at the prompt, one low tone for one that was refused, a rising chirp for a finished background job, a rising or falling pair for a USB device the supervisor starts a driver for arriving or leaving. On by default; kept in the driver's settings file (section 5); audio status shows which. Never from a script, at most one in half a second, and the volume and mute apply
Ctrl+Alt+Up / Ctrl+Alt+DownshortcutBUILT: volume up or down 5, stopping at 100 and 0; held, it repeats. The result as read back: volume 65 [#############-------], one line above the prompt that a second press overwrites; volume 0 - silent; muted (volume 65) while muted
Ctrl+Alt+MshortcutBUILT: mute, or unmute if muted; held, it does not repeat
audio helpusage, one real example per row
audio versionthe version and the collective copyright line (rules 5 and 6)

mute and unmute are two verbs, not two spellings: two distinct actions, so rule 3's ban on synonyms does not apply.

Volume is 0 to 100, and 0 is not mute. Both are silent, and they stay separate states: mute keeps the volume and unmute returns to it. On x86 the scale is linear in the codec's own amplifier steps, which are even steps of decibels, so equal steps of volume sound like equal changes; audio info shows the step. The Pis have no amplifier: pwm-audio scales the samples by the square of the volume, for the same reason.

2. What each action answers

Every change of state is READ BACK from the codec before it is reported: - verified when the codec agrees, - unverified (the codec could not be asked) when it could not be asked, - unconfirmed (this codec does not report it, so it cannot say) when the codec does not model the change at all (QEMU's codec reports no power states, so audio off is unconfirmed there), and FAILED - the codec reads back something else when it disagrees.

AskedAnswer
audio volume 60volume 60 - verified
audio volume 0volume 0 - silent - verified
audio volume 60 while offvolume 60 - kept; audio is off, so it is set at `audio on`
audio mutemuted - verified; if already: already muted (nothing sent)
audio unmuteunmuted - volume 60 - verified; at volume 0: unmuted - volume 0, silent - verified; if not muted: not muted
audio offaudio off - the codec is powered down; audio on brings it back - verified
audio off hardaudio off (hard - the controller is held in reset; audio on brings it back) - verified
audio onaudio on - volume 60, unmuted, output line out - verified; if on: already on
audio output headphoneoutput headphone - verified; already in use: already playing through headphone (nothing sent); nothing plugged in: says so on the next line
audio output speakersaudio: no output called 'speakers' - this device has: and the list
audio tone 440 2playing 440 Hz for 2.0 s, then played 440 Hz for 2.0 s or stopped after 1.2 s
audio tone while offaudio is off - `audio on` first
audio tone muted, or at volume 0plays, and says first: muted - nothing will be heard / volume is 0 - nothing will be heard
audio play /song.wavplaying /song.wav (48000 Hz, 16-bit, stereo, 0:02), then played 0:02, or stopped after 1.4 s
audio play that ran dryplayed 0:02, 180 ms of silence where the samples did not arrive in time - never hidden
audio play /x.wav, 24-bitaudio: /x.wav is 24-bit - this plays 16-bit PCM
audio play /x.wav, 8000 Hzaudio: /x.wav is 8000 Hz - this codec plays 44100 or 48000 Hz
audio play /x.mp3audio: /x.mp3 is not a WAV file; compressed WAV: is compressed - this plays uncompressed PCM

3. Pipes (rule 12)

status and info start pipes, as labelled lines: audio status | match volume, audio info | write /audio-info.txt. outputs pipes as records - output, plugged, in_use - so audio outputs | count is how many there are; debug pipes as text (audio debug codec | write /codec.txt captures a new machine's codec); hardware pipes as records too - device, kind, made_by, driver, state, in_use. The actions refuse with a sentence naming the reports. Piping sound IN will be refused in the design: a pipe carries 16 KiB, a tenth of a second of sound, so play takes a PATH and the file is the adapter (docs/audio.md).

4. Failure says which half failed

SituationWhat audio says
No audio driver running (the VisionFive, which has no audio output, or a board whose jack was not found)no audio hardware on this machine - not an error to ask
The driver is running and did not answeraudio: the audio driver is not answering
No HD Audio controlleraudio: no audio hardware on this machine
The controller did not leave resetaudio: the controller is there and did not come out of reset - the serial log says more
No codec on the linkaudio: the controller is up but no codec answered
No usable output pathaudio: the codec offers no output this driver can use
A codec playback has not been verified on (one not in the driver's table)audio: this codec has not had playback verified yet - the driver surveyed it and stopped (docs/audio.md, A6)
A tone still playing five seconds after it should have endedstopped, and said

An absent, wedged or restarting driver makes audio return with a loud sentence, never hang: every ask is bounded, and every answer is immediate by design - a tone is started and answered, then followed with status (sdk/audio's wire protocol, which is tagged so a late answer is never read as the next one).

5. Settings that survive a reboot: the driver's settings file

The volume, the mute and the output are kept on disk, in the driver's own folder (docs/filesystem-layout.md): /system/services/audio-driver/settings on the PCs and /system/services/pwm-audio/settings on the Pis. Plain labelled lines, readable with read /system/services/audio-driver/settings:

volume 60
muted no
output headphone
  • The driver owns the file and its folder. It makes the folder at its first write. It reads the file once when it comes up and writes it after a change the codec did not contradict - a setting the codec refused is not written. on and off are NOT kept: audio comes up on at every boot.
  • Never written while a tone plays. A write blocks the driver, and a slow one mid-tone would starve the sound; a change made during a tone is written when it ends.
  • Bounded and tolerant. The file is read in one piece, at most 256 bytes; a line the driver does not know is ignored and said once in the log; no file means the defaults (volume 50, unmuted). Where fs is absent - no data disk - or does not answer, the driver says so once, carries on with what it holds, and says a failed write once rather than on every change.
  • system sounds off is written when they are switched off; no line means on, the default.
  • output is written only once one has been chosen with audio output; until then the driver plays through the first output its survey found. At boot it is restored if the codec still has an output of that kind, and the log says so either way. The Pis' jack is one output, so pwm-audio keeps no line.

6. Tab completion and words from elsewhere (rules 8 and 9)

audio completes its BUILT verbs, and off completes hard. The words people bring from other systems are hints, never aliases: beep and speaker-test point to audio tone, amixer and alsamixer to audio volume, and aplay to audio play. audio play completes a PATH, which is why audio is not among the commands whose arguments are keywords only.

hardware - what this machine is, and what drives each part of it

Implementation shape: shell built-in, asking each device's owner - the kernel's introspection for the cores and memory, hw-enumerator for the PCI bus, and the supervisor (supcmd::DEVICES) for which service it runs for which device. Read only, always. The design, including everything not built yet, is docs/hardware-design.md.

Status, as built and honest (2026-10-08)

Step 1 of the design's build order is built: the facts that already exist, no kernel change. Run in QEMU (osdev test shell), and on hardware on 2026-10-08: the Dell Wyse 5070 and the HP T630, where the T630's view at 86f4e60c reads right against its boot log - 22 PCI devices, every driver on the device it drives, the dongle in usb.

Three runs to get there, each finding what QEMU could not. The Wyse showed its xHCI controller driverless (the supervisor's answer left out the USB hosts, c2bae9a1). The T630 then lost every row after the 24th: the commit that fixed it says the T630 has 19 PCI devices, which was the number the truncated view SHOWED - it has 22, and the three hidden were two host bridges and the RTL8168, so the NIC was missing too. It also showed ehci's controller driverless and audio-driver on both HD audio controllers (86f4e60c).

Built: the overview, the sections cpu, memory, pci, soc, display and usb, a comma list of them, one device in full, the vendor and class names, and records when piped.

Step 2 is built too, and with NO kernel change - by the operator's rule for this utility (2026-10-08): hardware is read only and takes what the system already answers. It adds, to one device in full, the live command register, every BAR and the interrupt route, the capabilities its driver holds, and what the driver's spawn asked for; and the views <device> debug, <section> debug, interrupts, why <device> and report. QEMU-verified (osdev test shell), and on the HP T630 at 2149fc8c: interrupts read xhci and audio-driver on MSI vectors 0x30 and 0x31 to APIC 18, and ehci, AHCI and the RTL8168 on legacy lines; 00:10.0 debug decoded the xHCI controller's configuration space - its MSI capability enabled, its MSI-X present but off - and its raw dump began 22 10 14 79; why named xhci's confinement and ehci's passthrough with their reasons. report ran there too: every driven device in full - xhci holding console_push, asking for class 0x0c0330 with confinement and an interrupt; block-driver BAR 5 unconfined; ehci by kind - and the cores. It said of wifi-usb that the kernel "logged" a grant at spawn, when wifi-usb is granted no hardware at all; a driver with no device word now says so instead.

Where those come from: the device's configuration space, read live by hw-enumerator (its op 4 - the bus is where the kernel wrote each device's interrupt route, so reading it there needs no kernel query); the driver's capabilities (TaskCaps, as caps reads them); each core's scheduler counts (InspectKernel 6 and 7, as observe reads them); and, for why, the supervisor's spawn decision and the reason it keeps beside its spawn rows (supcmd::WHY).

What it does NOT show, because the kernel does not report it, and each view says so where it would be: the addresses of the grant as the kernel recorded them (a device's BARs are shown as the device reports them; the kernel's own record of the window, the DMA arena and whether the IOMMU confined it is in its boot log - events log boot | match <driver>, which the view names); how often each interrupt fires; per-device IOMMU fault counts; each core's timer mode. A BAR's SIZE is not shown either: reading it means writing all ones to the BAR, and this utility never writes.

Step 3 is built (2026-10-09), still with no kernel change: problems, tree, firmware, compare and events, and the hardware invariants in selfcheck hardware. QEMU-verified (osdev test shell and the selfcheck suite), and on hardware: the T630 at f54aafef ran problems, firmware, events and selfcheck hardware; the Pi 2 and the Pi 4 ran the whole selfcheck after 1000 chaos rounds; the VisionFive ran hardware firmware (67c759f9). tree and compare have run only in QEMU. selfcheck hardware is the existing part 3 of selfcheck, which also checks hw-enumerator's authority and the lifecycle guardrails - including a supervisor kill, which starts the supervisor's event record afresh before the hardware checks run. Where each comes from:

  • problems - the same answers the overview uses: a driver named but not running, a device of a class whose driver took another, a device nothing drives (bridges left out - they are the bus), a driver that has restarted, a driven device whose memory decoding is off, and a spawn that asked for an interrupt on a device showing no MSI enabled (the kernel gives such a driver an MSI vector, so a legacy line there means the route was not set up). It says, every time, that IOMMU faults and interrupt counts were not checked.
  • tree - a PCI device on a bus behind a bridge hangs under that bridge, by the secondary bus number in the bridge's own configuration space; a USB device hangs under the host that bound it.
  • firmware - each radio's driver already reports its chip and firmware (wifi hardware <radio>); this asks them. A controller's own ROM is not read, and the view says only firmware this OS loads is listed.
  • compare <report> - reads a saved hardware report (or hardware text) and lists devices added, removed, or with a different kind, driver or state. The detail column is not compared: it holds live numbers, such as free memory, that differ on every run.
  • events - the supervisor keeps a bounded record of what it sees happen to the devices it drives: a driver's death and restart (or failed restart, or a death the sweep found), a USB device attached or removed. The newest 32, with a count of any overwritten; lost when the supervisor restarts, and the view says from when its record runs. What the kernel does on those occasions - granting, confining, releasing, resetting - is not recorded.

Not built, answering designed, not built yet rather than being mistaken for a device: power.

Two limits of step 1, stated: the usb section lists only the devices the supervisor starts a driver for (today the USB WiFi dongle) - a keyboard or a stick a host drives itself, or a device nothing drives, is not reported by its host yet; and a USB device is named by its IDs (0bda:8176), not by its host and port.

1. Verbs

VerbKindWhat it does
hardwarereport, pipesa line for the machine, then every section this machine has
hardware <section>[,<section>...]report, pipesthose sections; one this machine lacks says so
hardware <device>report, pipes as linesone device in full, named as hardware names it: its live registers, interrupt and authority
hardware <device> debugreport, pipes as linesthe device as the hardware sees it, read now: configuration space decoded, its capability list, and the raw 256 bytes
hardware <section> debugreport, pipes as linesdebug for every device in the section; cpu debug is each core's scheduler counts
hardware interruptsreport, pipeseach PCI device's interrupt route - MSI vector and target APIC, MSI-X, or legacy line - and its driver; records device, route, driver
hardware why <device>report, pipes as lineswho drives it, why that service, and the reason the supervisor records beside its spawn row
hardware reportreport, pipes as lineseverything, for a bug report: the overview, problems, interrupts, firmware, events, every driven device in full, the cores
hardware problemsreport, pipeswhat is wrong now; records severity, device, problem, detail
hardware treereport, pipesevery device by how it connects; records device, parent, kind, driver
hardware firmwarereport, pipeseach radio's chip and firmware, as its driver reports them; records device, driver, chip, firmware
hardware compare <report>report, pipeswhat changed since a saved report; records change, device, was, now
hardware eventsreport, pipeswhat happened to the devices, as the supervisor saw it; records time, device, event, detail
hardware help / hardware versionthe house conventions

2. Sections, and a section that is not there

cpu, memory, pci, soc (devices granted by kind on the Pis and the VisionFive), display and usb, printed in that order.

  • The bare view leaves out a section the machine does not have. A Pi 2 has no PCI bus, and a heading with nothing under it on every run is noise.
  • A section asked for by name is always answered: pci: none on this machine (no PCI bus the OS can read). Asking is not an error. In a pipe it is no rows, and the sentence goes to the console.
  • A section that is there but empty shows its heading and says so: usb with nothing attached prints (no device with a driver here attached). "The bus is not there" and "the bus is there with nothing on it" are different facts.
gsh> hardware
x86_64 - 4 core(s), 7 GiB

cpu
  DEVICE      KIND                 DRIVER         STATE       DETAIL
  core 0      cpu core             kernel         up          boot core
  ...
pci
  DEVICE      KIND                 DRIVER         STATE       DETAIL
  00:10.0     USB 3 (xHCI)         xhci           running     AMD 1022:7914, IRQ line 11
  00:18.0     host bridge          -              no driver   AMD 1022:...., no IRQ line
  ...

9 device(s) with a driver, 2 without - hardware <device> for one in full

Which device a driver drives. A driver the supervisor names by PCI class is given the FIRST device of that class on the bus (the T630 has two HD audio controllers: the first shows audio-driver, the second not driven). A USB host asked for by kind (ehci, xhci) is shown on its PCI controller where the kernel resolves the kind on the bus, and in soc where it does not. A view that cannot hold every row says how many it left out.

One device in full, as the QEMU machine's network card reads:

gsh> hardware 00:03.0
device     00:03.0
section    pci
kind       ethernet
id         8086:100e (Intel)
class      0x020000
driver     nic-driver
state      running, on core 1, restarted 0 time(s)
command    0x0107 (memory on, I/O on, bus master on, INTx on)
bar0       0xfebc0000 (32-bit memory)
bar1       0xc000 (I/O ports)
interrupt  INTx pin A, IRQ line 11
authority  nic-driver holds log_write, and 3 endpoint(s)
asked      class 0x020000, the first memory BAR, no confinement
granted    the kernel does not report its grant's addresses; it logged them at spawn - events log boot | match nic-driver
why        hardware why 00:03.0

3. Pipes (rule 12)

Every section is one record shape - section, device, kind, driver, state, detail - so any combination pipes as one table: hardware cpu,memory | count, hardware | where driver=-, hardware pci | select device kind. interrupts is its own table - device, route, driver. One device, debug, why and report are labelled lines and pipe as text, to match or write; the pipe holds 16 KiB and says when it cut a long report.

4. Failure says whose answer is missing

The supervisor not answering leaves the DRIVER column at - and the overview says so in a line of its own; hw-enumerator not answering means no pci section. An unknown name answers no section or device '<name>' and fails. A word of the design not built yet answers that it is not built yet and fails, so if hardware problems cannot be read as an answer.

5. Tab completion (rule 9)

The built sections and the words interrupts, report, why, problems, tree, firmware, compare and events complete; a device name is the machine's, typed. No word completes to a path.

ask - plain questions about this machine, answered from what its services know

Implementation shape: a foreground program for one question (services/ask), started by the shell, handed the question, read back as one record table, and ended by the shell before the next prompt. Nothing of it stays running and nothing restarts it. Read only, always. The design, and what it does not do yet, is docs/ask-design.md.

Status, as built and honest (2026-10-11)

Built on feat/ask: the four questions of the design (system health, what failed, the WiFi restarting, the filesystem, with the storage chain walked when there is no disk), the general question why did <service> <event> with how the service last ended, the network, what a service is and what it can do, who holds an authority, what to say, glossary terms, the Easter eggs, ask evidence, and records when piped. QEMU-verified only; no board has run it yet. The vocabulary and every truthfulness rule are host tests (services/ask/src/know.rs, run by scripts/host_test_check.py).

(Note 2026-10-25: feat/ask merged to main for v0.26.0, and that merge records chaos max-carnage 1000 and selfcheck 0 failed on all five boards - the Pi 4, Pi 2, VisionFive 2 Lite, Dell Wyse 5070 and HP T630 - with ask in the image. The paragraph above is the state on the day it was written.)

1. The command

ask "<question>"            the verdict, then what it rests on, as plain sentences
ask evidence "<question>"   the same answer, every line labelled FACT, INFERENCE or UNKNOWN, with its source
ask help                    what it can be asked, with an example of each
ask version

A bare ask prints its usage and fails (rule 1). evidence changes how much is SHOWN: within one invocation the summary and the evidence are one assessment.

2. What it can be asked

QuestionWhat answers it
is my system healthy?the task table, the services the supervisor keeps running (supcmd::MANAGED), fs's drive report, each radio's status
what failed?the supervisor's record of driver deaths and restarts (supcmd::EVENTS, the newest 32), the task table's restart counts, what is down now
why did <service> <event>?that service in the task table, the supervisor's record, and how it last ended (supcmd::DEATH, section 3). <service> is any name the machine knows - the task table's and the supervisor's - so a new service needs no new word; <event> is one of restart, fail, crash, stop, kept apart
why did the wifi restart?the radio driver that is running, its restarts, and the radio's own status. "The WiFi" is an alias, not a service
is the filesystem healthy?fs's drive report; a scrub is offered, not run
is the network up? (why can't I reach the internet?, am I online?)net-stack's status - the address, whether DHCP leased it, whether the gateway answers ARP - and the radio where the link is wireless. An address and a gateway mean this machine is on a network, NOT that anything beyond the gateway answers: nothing is sent to find out, and the commands that would test it are offered. Started as the ask-net row (below)
what is <service>? (what does xhci do?)the service's own description - the description in its crate's Cargo.toml, read when ask is built, the one place a service says what it is - and whether it is running and kept running by the supervisor. A name is a service here only while it is running or the supervisor keeps it running; any other word is looked up as a term (below)
what can <service> do? (what can fs reach?, what capabilities does shell have?)the capabilities the kernel holds for that task (INTROSPECT): each kernel authority by the name a contract uses and what it allows, and the running services it can send to. What it CAN do, never what it has done
who can <do this>? (who can reboot?, which services can kill?)every running task holding that kernel authority. The words are the authorities' names and the verbs for them (reboot, kill, restart, spawn, type, introspect, mint, power, pci, ...); a word that names none is answered "which authority?", never guessed. A task whose capabilities could not be read is named as UNKNOWN and counted in the verdict ("AT LEAST 2 TASK(S) ... 1 COULD NOT BE READ"), never taken to hold nothing. ask itself is left out
what to say? (or what can I ask?, help)the forms above, each with an example, then suggestions from this machine as it is: a service that has restarted (most first), one that should be running and is not, the WiFi where a radio driver runs, the filesystem where fs does. Nothing is suggested that this machine could not answer

A question it does not understand is said to be, with a short list of what it can answer; a question that fits two kinds, or names two services, is not answered - both readings are named and it asks "which?"; a request to DO something (restart fs) is refused - ask reads and changes nothing.

What a term means (operator, 2026-10-11): what is MISCIS?, what does AMD-Vi mean?, define MMIO. Answered from GLOSSARY.md, read when ask is built, so there is one copy of every definition. A term one or two letters off is answered and the answer says how it was read (I read 'immou' as IOMMU); a term the glossary does not have is "not in the glossary", never a guess. Each ends with the published glossary's address. A question word is never taken for a term: what is wrong? is still the health question.

And a few it was not built to answer, but does (operator, 2026-10-11): tell me a joke, what is the meaning of life?, sudo ..., rm -rf /, are you alive?, who are you? / what is godspeed?. Each says what it is - a joke's verdict is A JOKE - so a script reading verdicts never takes one for an answer, and every line is true or is a joke. There are 180 jokes, fixed in the program, and the uptime second picks one by a stride that visits every joke before any repeats: no joke comes round again within three minutes, and two questions a few seconds apart get jokes from different parts of the table.

3. What every line is

  • FACT - a source said it, or recorded it. The source is named.
  • INFERENCE - concluded from facts by a written rule. The rule is named.
  • UNKNOWN - not established by anything available.

How a service ended is a fact; why is usually UNKNOWN. The kernel's death notice says how a task ended and the supervisor keeps the newest per service (supcmd::DEATH), so ask "why did fs fail?" says fs WAS KILLED BY shell - and that a kill is not a failure - or that it CRASHED on a memory fault at an address. Why shell killed it, or which bug faulted, is not recorded anywhere, and the answer says so; a fault at address 0 is said to be a null pointer or a panic, which ends exactly that way. Only the newest death per service is kept (docs/ask-design.md 6).

Curly quotes count as quotes. At the prompt the shell reads a curly quote as ", so a question typed on a phone or pasted from a document is quoted exactly as a straight one, pipes included.

Each answer carries the uptime it was taken at. A source that did not answer is a hole the answer names, never a pass: HEALTHY is said only when every required check ran.

4. Result, and records

An answered question succeeds whatever the answer says - "the filesystem is not mounted" is a successful answer. A question not understood, an ambiguous one, a refused action, and an answer in which a source did not answer all fail, and the last record says which.

Piped, the answer is records with the columns label, source, rule, text (rule 12): one row per line, label being verdict, fact, inference, unknown or note, then one row of label status whose text is answered, source-silent, not-understood, ambiguous, action or empty.

gsh> ask "what failed?" | where label=fact
gsh> ask "is my system healthy?" | where label=verdict

5. Two rows, one program

The kernel gives a task at most six send peers, and one row for every question would need seven. So the supervisor carries two rows for the one program, as it does for observe: ask (the supervisor, fs, block-driver, the USB host the board's disk is behind where there is one, and the radio) and ask-net (the supervisor, net-stack, the radio - both radio drivers where a board carries two, the Pi 4 and the VisionFive). The shell picks the row by reading the question with ask's own know.rs, compiled into the shell from the same file, so the two cannot disagree about which question it is. Each row's grants are pinned (Commandment VII).

6. Bounds

A question is read to 512 bytes; an answer holds at most 48 lines and one record table's text, and one that runs out of either says it is PARTIAL. Every request ask makes waits at most 3 s - fs's drive report 8 s, because it re-asks block-driver for the device's size - and the shell waits at most 25 s for the answer, so a dead source ends one line ("did not answer") and never the command.

That wait is quittable and says so: once 2 s pass with nothing from ask, the shell prints [q] quit, and q ends it (conventions rule 10). If the answer is not whole, the shell says who ended it (backlog/84 S5):

  • q: only pipe: aborted, since the operator knows why it stopped;
  • the shell's 25 s bound: ask: no answer - nothing came for ~25s, so the shell ended ask, or ask: the answer arrived incomplete - nothing more came for ~25s, so the shell ended ask when part of it came;
  • ask itself, ending its stream: ask: ask ended without an answer, or ask: ask ended, and what it sent does not read as an answer;
  • an answer larger than the shell holds: ask: the answer was larger than the shell holds, so what arrived does not read as one.

It does not print the record decoder's reason, which is about the wire format and not about what the operator asked.

godspeed - the native package manager

Implementation shape: a foreground program for one command (services/godspeed-pm), started by the shell, handed the command line, read back as one record table, and ended by the shell before the next prompt, as ask is. Nothing of it stays running and nothing restarts it. The design, and everything it does not do yet, is docs/developer-services-design.md section 10.

The command typed is godspeed. godspeed-pm is only the program's name, because the standard library's crate is already called godspeed; it appears in status while a command runs and nowhere else.

Status, as built and honest (2026-10-17)

Built: the registries, the settings, the registry check, and (2026-10-17, branch feat/packages) search, info, fetch, list and remove, every answer verified on this machine against the registry's signed metadata by the shared package crate's own checks (the CLI repository's godspeed-package). And (the same day) install, upgrade and uninstall for a SERVICE: the supervisor checks the fetched copy from the disk again and starts it, now and at every boot, with the send peers the operator approved and no privilege (section 4b; design 12). And a utility package - a program that does one job and ends - installed the same way and run by its name (section 4c). osdev test godspeed runs every command against a real disk and the real registry over verified TLS through QEMU's user network, fetching godspeed/hello (its registry checks are skipped, not passed, when the machine running the test is offline). Packages are QEMU-verified only, x86; no hardware card yet. On hardware: godspeed check verified the registry and got HTTP/1.1 200 OK on all five boards - the HP T630 and the Dell Wyse 5070 on 2026-10-16, and the Raspberry Pi 2, the Raspberry Pi 4 and the VisionFive 2 Lite on 2026-10-17, each with its own hardware random source (RDRAND on the PCs, the Pis' RNG and RNG200, the JH7110's TRNG).

1. The command

godspeed search <word> [from <n>]                  the packages the registry vouches for whose name holds the word, 40 a page
godspeed info <publisher/name>[@version]           its versions, then what it is: description, licence, release, what it ships and requests, who signed it
godspeed fetch <publisher/name>[@version]          verify a package end to end and fetch it here - fetched, not installed
godspeed fetch <publisher/name>[@version] offline  and write it here as one file, <name>-<version>.gsp, for a machine with no network
godspeed inspect <publisher/name>[@version]        every check installing makes, this machine's binary included - nothing kept
godspeed inspect <file>.gsp                        the same for a package file, with no network
godspeed list available [from <n>]                 every package the registry in use offers, 40 a page
godspeed list fetched                              the packages fetched here, with their sizes
godspeed list installed                            the packages installed, and whether each is running
godspeed list updates                              each fetched package against the registry it came from
godspeed verify [<publisher/name>[@version]]       check fetched copies again from the disk alone
godspeed remove <publisher/name>[@version]         delete a fetched copy, one version or every one
godspeed trust                                     what this machine has accepted from each registry, and the publishers' keys
godspeed trust reset <registry> [yes]              forget it, so the next command meets it again from root 1; asks [y/N]
godspeed trust add <publisher> <key> [yes]         trust a key yourself, for an unpublished package file; asks [y/N]
godspeed trust remove <publisher> <key>            stop trusting it: what it signed no longer starts
godspeed install <publisher/name>[,...] [yes]      start fetched packages, now and at every boot; asks [y/N] for each
godspeed install <publisher/name> grant <p>,...    the same with the peers named, and no question
godspeed install <file>.gsp [yes] [accept-old]     a package file, with no network (4d)
godspeed plan <publisher/name>[@version]           what installing it takes: what it requires, in the order to install (4f)
godspeed resolve <publisher/name> <range>          the newest release on the registry in a range, such as ^1.2.0 (4f)
godspeed upgrade <publisher/name>[,...] | all [yes] fetch the newest release of what is installed and install it; asks
godspeed uninstall <publisher/name>[,...] [yes]    stop them, and no longer start them at boot; asks [y/N] for each
godspeed uninstall <publisher/name>[,...] purge [yes]  and delete every fetched version too; asks [y/N], naming both
godspeed cleanup [yes]                             delete every fetched copy nothing uses, and interrupted fetches; asks [y/N]
godspeed registry                                  the registries this machine knows; one is in use
godspeed registry add <name> <url> <root> [yes]    know another; asks [y/N] before trusting its root
godspeed registry use <name>                       search and install from that one from now on
godspeed registry remove <name>                    forget one (not the one in use)
godspeed config [<key>]                            the plain settings, or one, and where each came from
godspeed config set <key> <value>                  change one setting, checked first
godspeed check                                     reach the registry in use over verified TLS, one row per step
godspeed help                                      what each does
godspeed version

A bare godspeed prints its usage and fails (rule 1). Every command reads the settings file first, so an edit takes effect at the next command; there is nothing to reload.

2. Registries

A machine knows up to eight registries, and exactly one is in use: the one searched, and the one a new install comes from. There is no priority between registries because only one is ever asked, so a package is never found in two places at once.

A registry is three things, kept together on one line of the file:

  • a name - what use and remove call it: 1 to 32 lower-case letters, digits and hyphens;
  • an address - https:// only, because the transport is TLS;
  • a signing root - sha256: and 64 hex digits, the root its metadata must be signed by. Each registry has its own, so switching registries switches which root is checked and never trusts one registry with another's root. It is checked on first contact with the registry: root 1 must have this SHA-256 (section 4a, step 1).

godspeed registry add shows the root and asks [y/N] before trusting it, because trusting a root is trusting whoever holds its key. yes as the last word answers in advance, as for seal and chaos max-carnage; the line naming the root still prints. A registry is added known, not in use, and the answer says godspeed registry use <name> to switch. remove refuses the one in use: switch first, so something is always in use.

A fetched package remembers the registry it came from (in its origin), and list updates and upgrade ask there, not whichever registry is in use at the time. Otherwise switching registries would quietly change where installed packages update from, and a package with the same name on another registry, signed by another key, could replace one. If that registry has been removed, the command says so rather than trying another.

godspeed registry is one row per registry: item its name, value its address, note the root it is signed by and, for the one in use, in use.

3. The settings

/system/services/godspeed/config, # starting a comment:

registry default https://godspeed-registry.bankole-422.workers.dev sha256:d769...146d
use default
tls-roots /system/services/godspeed/roots/
timeout-secs 30
max-package-mib 64

A missing file is written with these defaults on first start, so the file always shows what is in effect, and godspeed config says of each plain setting whether it came from the file or is the default because the key is absent.

keydefaultwhat it is
tls-roots/system/services/godspeed/roots/the certificate authorities TLS accepts, one DER file each. On first start the folder is given ISRG Root X2, the root the registry's chain reaches; a folder that exists is never refilled, so a root removed stays removed
timeout-secs30how long a request may go with nothing moving on its connection, 1 to 100: a STALL, not a total, so a large download that keeps arriving is never cut off. Looking the name up and connecting keep their own limits, 10 s and 20 s, so a smaller value does not shorten those
max-package-mib64the largest package that will be downloaded, 1 to 4096
offline-max-age-days30how old a package file's registry check may be before installing it asks, 0 to 3650 (4d)

A registry is not a plain setting: godspeed config set registry is refused with the command that manages registries, and so is trust-root.

A file with a problem - an unknown key, a key set twice, a value that does not check, a use naming no registry, several registries and no use line - is never half used: every command names the line and refuses until it is fixed. Which registry is in use is never guessed.

A file written before registries had names - registry <url>, and perhaps trust-root <root> on its own line - reads as one registry called default, in use. The first registry command that changes the file rewrites it in the form above; every other line is kept as it was.

godspeed config set checks the value before writing (a folder ending in /, a number in range), keeps every other line of the file as it was, and refuses a change that would leave the file unreadable. edit works on the file too.

4. godspeed check

One row per step, stopping at the first that fails and saying why:

itemvaluenote
registrythe address of the registry in useits name, and (in use)
clockthe date a certificate is checked againstits source: hardware clock, network time, or a floor from the last boot. No clock, no check: date sync first
rootshow manythe folder they were read from
addressthe registry's IPv4 addressits name
tlsverifiedTLS 1.3; the certificate chain reaches a trusted root and names the server
httpthe status linehow many bytes came back
saysthe first line of the registry's answer

A failure names the step and the reason in words a person can act on: an expired certificate (or a clock that is ahead), one not valid yet (or a clock behind), a chain that reaches no trusted root, a certificate for another name, no hardware random source, no network. TLS is never skipped: there is no insecure mode.

godspeed check <https-url> reads that one page instead of the registry, through every step above, to its end, and throws it away: page replaces registry, says is left out, and a speed row says how fast it came, KiB/s and the bytes and seconds, the handshake included. It is how this machine's download speed is measured (osdev test download). The address may carry a query (https://host/path?query); it is still https:// only.

4a. Packages

What is checked, in order (services/godspeed-pm/src/chain.rs and packages.rs, the checks themselves the shared crate's):

  1. The root. On first contact, root 1 (the registry's trust/root/1.toml) must have the SHA-256 pinned on the registry's line in the settings, and be signed by its own root keys. Every root since must be signed by the one before it and by itself. What this machine reached is kept in /system/services/godspeed/trust/<registry>/: the root, the pin it was reached under, and the newest timestamp and namespaces versions seen.
  2. Timestamp, snapshot, namespaces, each signed by its role's keys, unexpired, and not older than what this machine already accepted (a rollback is refused).
  3. The package's index, exactly the bytes the snapshot names for it.
  4. The manifest, by the digest the index names; its signature, by a key the signed namespaces vouch for the publisher at the release's publication time (a revoked key never; a retired one only for what was published before it retired).
  5. The contract and this machine's binary, each by the digest and size the manifest names. The binary is streamed to disk and checked as it arrives; a file that does not match is deleted, never kept.
commandrows
search, list availablea list, package latest description, a page of 40 from from <n> (default 0): each package's latest version and description, from its own index and manifest by the verified digests; the count, which are here, the command for the next page and the chain in the status note
infoa CARD (key value note): title (the release, the latest or not, when and by whom), about (its description), licence, built for (the release, beside this machine's), ships (its architectures, and whether this machine's is one), requests, signed (the key, shortened, and its standing), versions; the chain, through the contract, in the status note. Checked as fetch checks it
fetchprogress while it works (the chain's steps, then a bar as the binary downloads), then a CARD: title (fetched, or already fetched, and from where), about (the folder), binary (this machine's architecture and size), built for, requests, signed, installed no; the chain, through the binary, in the status note
list fetcheda list, package version from size state, size in bytes (a pipe can compare it; the console shows KiB); the count and total in the status note
list installeda list, package version state (section 4b); the count in the status note, or, with none, the command that installs one
list updatesa list, package have latest status: newer (with the command that fetches it), up to date, or no longer listed. Each is asked of the registry it came from, not the one in use
verifya list, package version status detail, status intact or DAMAGED; the count in the status note. A damaged copy fails the command
trusta CARD per registry: title (its name, in use or not), root (and its expiry), timestamp, namespaces, pin (and whether it is the configuration's), then a key <publisher>/ row per key, whole, with its standing
trust resetforgotten, or nothing to forget
removeremoved and the folder

verify works from the disk alone: the signature over the manifest, the signing key against the one recorded when it was fetched, the manifest against its folder, and the contract and the binary against the manifest's digests and sizes. Whether the registry still vouches for the key needs the network; list updates asks the registry, and a fresh fetch checks it again.

How it looks, in the style dir set: two spaces in, upper-case headers, a footer line. A LIST has columns that name what it lists; a CARD, about one thing, has a title, a line about it, and the rest aligned under it; failures and the settings (config, registry, check) are item value note. Piped, every answer is the same records, the column names lower-case.

A list is its entries and nothing else, so count counts them; what a list's rows do not say about themselves (the chain it was verified by, why it is empty) is the status row's note, which the console prints under the table. An empty list is not a failure: the console says 0 packages, the status is ok, and piped, only the status row is left (where item!=status | count is then 0). A bare godspeed search and a bare godspeed list are usage errors: each says what to add.

Where a package is fetched to: /system/packages/<publisher>/<name>/<version>/, holding <arch>.elf (this machine's only), manifest.toml, manifest.sig, contract.toml and origin (the registry, when, the architecture and the signing key). A fetch is written beside it as <version>.partial and renamed into place only once every file is verified, so a half-fetched package is never listed. Fetching a version already fetched leaves it as it is.

A pin that changed since this machine first reached a registry is refused - changing it is trusting another registry - until the operator runs godspeed trust reset <registry> (which asks [y/N]; it removes /system/services/godspeed/trust/<registry>/), after which the registry is met again from root 1. The record also keeps the last namespaces document accepted, which is what godspeed trust lists the keys from.

The snapshot is kept on the disk, not in memory, so the registry's size does not decide whether a command runs. It names every package (a line each), so it grows with the registry: it is streamed to /system/services/godspeed/trust/<registry>/snapshot.toml as it arrives, sized by its Content-Length, hashed on the way and verified from the digest (a signature covers the digest, not the bytes), each line checked as the whole document would be, and only then put in place of the one kept. A command whose timestamp names the snapshot already kept does not download it again - the chain note says (kept). Lookups and listings read it back a line at a time. A snapshot over 256 MiB (about two and a half million packages), or one that does not state its size, is refused by name. What is not solved here: a NEW snapshot is still downloaded whole, once per version, which at a large registry is a lot of bytes - the registry format's to fix (the CLI repository's backlog, item 2).

Limits, refused by name, never cut: an index 8 KiB (about 40 releases), a manifest or a contract 4 KiB, a binary max-package-mib, a snapshot line 320 bytes. They are on the stack, which is 256 KiB for every service and already carries the TLS session.

One connection carries a command's requests, kept alive; a server that closes it part way is asked again on a fresh one, up to three times, and a command writes its rows only after its last request, so nothing is said twice.

4b. Installing

godspeed install godspeed/hello asks one question per package, with everything the package would be given in it:

godspeed/hello 0.1.0 asks for nothing (no peers, no privilege) and starts at every boot. Install it? [y/N]:
bankole/notes 0.2.0 asks for: fs, events (no privilege) and starts at every boot. Install it and grant them? [y/N]:

The peers are the send peers its contract requests, read from the fetched copy after it is checked from the disk. yes as the last word answers every question, and approves only what the questions name; grant <peer>,... (or grant none) gives some of them, for one package, with no question. A comma list installs several, each asked on its own; one that fails does not stop the rest, and the command fails if any did.

What happens on yes. godspeed-pm writes the answer to grant in the package's folder, adds the package to /system/packages/installed, and asks the supervisor to start it. The supervisor checks the fetched copy again from the disk, by the same code godspeed verify runs, narrows the grant to what the signed contract requests, and starts the bytes it checked, under the task name publisher/name. A start that fails takes the package off the list again, so the list holds only what runs. At every boot the supervisor starts what the list names, checked the same way; a package that dies is restarted from the disk up to 5 times a boot and then left down, with a line that says so.

What it is given: the approved peers, never supervisor, an endpoint if its contract declares ipc_receive, its contract's memory limit up to 64 MiB, and no privilege, which is not asked and cannot be granted.

A driver is asked about its device as well (device): a PCI device by class - its registers, and its interrupt when the contract asks - or a USB device by vendor and product, reached through its host and started only while it is attached. One that asks for a DMA arena lists memory too and is then asked a SECOND question, because where no IOMMU confines the device its driver can read and write all of memory:

godspeed/kind-driver 0.1.0 asks for: device, memory (no privilege) and starts at every boot, driving that device. Install it and grant them? [y/N]: y
godspeed/kind-driver 0.1.0 also asks for DMA memory: its device reads and writes memory by itself.
Where no IOMMU confines the device - every Raspberry Pi and the VisionFive, an Intel PC, and
any device the kernel leaves unconfined - its driver can then read and write all of this
machine's memory. Grant it DMA memory? [y/N]:

A device a GodspeedOS driver already drives is taken over: that driver is stopped while the package is installed and started again when it is uninstalled (docs/developer-services-design.md 13.1).

godspeed upgrade <pkg> (or all) looks up the newest release of each installed package on the registry it came from, fetches and verifies it, and installs it in the old one's place with the same question, now naming the version change: a new release may ask for more. One already newest is said to be up to date. godspeed uninstall asks, stops it and takes it off the list; its fetched copy stays, and godspeed remove refuses an installed copy.

list installed is package version state, state being what the supervisor says: running, NOT running, or for a utility, how to run it.

4c. Utilities

A package of kind utility (godspeed new utility in the CLI) is installed exactly as a service is - fetched, checked, asked about once - but it is NOT started at install or at boot. It runs when its name is typed:

gsh> hello-utility Bankole
hello, Bankole
gsh> hello-utility Bankole | match Bankole | count
1

The shell asks the supervisor to start it, wired to send to the shell and to the peers its grant names; the supervisor checks the copy from the disk again first, as it does for every start. The rest of the command line is handed to it as its arguments, and what it writes is shown, or is the stream of a pipe, as any producer's is. It ends itself with a status (gs::program, the Exit syscall); the shell says the status when it is not 0, and the command fails then. A utility is never restarted and runs one at a time. Its name is the package's own name (hello-utility), or the full publisher/name when two publishers' utilities share one, which is refused rather than guessed between. A built-in command of the same name always wins: an installed utility can never replace dir.

4d. Package files: installing with no network

A package travels as one file, <name>-<version>.gsp (.gsp for GodspeedOS Package), made where there is a network and installed where there is not (docs/developer-services-design.md 15):

gsh> godspeed fetch godspeed/hello offline          on a machine that can reach the registry
gsh> godspeed install hello-0.3.0.gsp               on one that cannot, after copying it there

The developer CLI makes the same file on a workstation (godspeed fetch godspeed/hello --offline).

Trusted through the registry. The file holds the documents a fetch reads, under the paths the registry serves them at, and is checked by the same code from the root this machine's image pins. Nothing about what is checked differs; only "now" does: a file is checked as of the last moment its timestamp was valid, and records nothing of the registry's view on this machine - except that a NEWER record of the publishers' keys wins, whichever side has it, so a key this machine knows was revoked refuses the file.

Its age is the one thing asked. A file whose check is older than offline-max-age-days, or any file while the clock is not set (the Pis and the VisionFive have no clock chip), is asked about before anything is kept:

godspeed/hello 0.3.0 its registry check ran out on 2026-10-19, 59 days ago. A key revoked since then would
not show. Install it anyway? [y/N]

yes on the line does NOT answer this - a script that says yes never accepts the risk by accident - and accept-old does. The acceptance is recorded in the copy's origin and shown on its card. A bad signature, a digest that does not match or a key known revoked is refused, with no question.

Unpublished packages. godspeed package --local in the CLI makes a file nobody has published: no registry vouches for its key, so this machine installs it only if its operator trusts that key for that publisher, once - godspeed trust add <publisher> <key>, which asks and says what it means, and which the CLI prints. It is listed as coming from (operator). Nothing tells this machine if that key leaks: godspeed trust remove withdraws it, and what it signed no longer starts, since the supervisor checks the list at every start.

godspeed inspect <file>.gsp checks either kind and keeps nothing.

4e. Cleaning up

godspeed uninstall keeps the fetched copy, so a reinstall needs no network; godspeed uninstall <pkg> purge deletes it too. What is left over time - copies uninstall kept, packages fetched and never installed, older versions after an upgrade, and what an interrupted fetch left - godspeed cleanup lists and, after [y/N], deletes (yes answers it; piped, it only lists). It cleans only the package manager's own store, /system/packages/, and its scratch folder: never the installed version of anything, never a package file (.gsp) wherever it is, since those are the operator's, and never the registries' trust or the settings.

4f. Dependencies: a package that needs another

A package may REQUIRE others - a service it talks to, a driver it needs running - in its signed manifest (docs/developer-services-design.md 16). A requirement grants nothing: the peers are still asked about, one question for all of them. A built-in service (fs, net-stack) is never required; it comes with the OS.

godspeed install of such a package works out what it needs first. A requirement already installed in range is kept; one fetched in range is planned; one neither is fetched - the newest release in range, found by godspeed resolve - and then planned too. Then it asks once:

gsh> godspeed install godspeed/dep-user
godspeed/dep-user 0.1.0 needs 1 more package:
  godspeed/dep-store           0.1.0     a service  asks for: nothing   (needed by godspeed/dep-user)
  godspeed/dep-user            0.1.0     a service  asks for: godspeed/dep-store
Install all 2 and grant them? (no privilege) [y/N]:

On yes they install in that order, the dependency first; one that does not start stops the rest, and what was installed before it stays installed. yes on the command line answers it, as for one package.

Refused, with nothing installed: a requirement no release satisfies, or that no registry has; one installed outside its range (godspeed upgrade it first); two that no one version of a package satisfies; packages that require each other; a chain of more than 4 packages, the one asked for included, or more than 8 besides it; a requirement that is a utility or an app, which runs to end rather than to be needed.

Kept whole afterwards. uninstall refuses a package another installed one requires, naming it. An install or upgrade that would take a package outside an installed dependent's range is refused, naming both. At every boot the supervisor starts installed packages in dependency order, whatever order the list is in, and one whose requirement is not running is not started, with a line saying which.

godspeed plan <pkg> shows what an install would take without doing it: one row per package, in the order to install them, columns package version kind asks state - state is install, needed by <pkg> (fetched and checked) or fetch, needed by <pkg> (not here yet; its version column is the range, and kind and asks are -), and the package asked for is last, install.

5. Pipes

godspeed search, info, inspect, list available | fetched | installed | updates, plan, resolve, verify, trust, registry, config and check are reports, so they pipe as records (item, value, note), with a last row whose item is status and whose value is ok or failed:

godspeed search hello | where package=godspeed/hello
godspeed list available | where package=godspeed/hello | count
godspeed list fetched | where size>1000000
godspeed registry | where item=default
godspeed config | where note=file
godspeed check | where item=tls

godspeed fetch, remove, install, uninstall, upgrade, trust reset, trust add and trust remove, godspeed config set and godspeed registry add, use and remove are actions and refuse to start a pipe, with the sentence naming the reports. godspeed cleanup piped is its listing alone: nothing is asked, so nothing is deleted. A package file given to inspect in a pipe is named by its full path.

6. What it holds

Its supervisor row grants exactly five peers - the shell for its answer, fs for its settings, roots, trust record and fetched packages, net-stack for the registry's connection, time for the date, and the supervisor, which starts and stops an installed package for it (supcmd::INSTALL, UNINSTALL, INSTALLED) - and no privilege of any kind. Pinned in COMMANDMENTS.baseline.toml. The TLS client is sdk/tls; the connection is net-stack's op 23, a TCP connection held as a capability (gs::net::Net::connect). The [y/N] before a root is trusted is asked by the shell, which holds the console; the package manager is told only what to add.

Utility: download - a file from a web server, saved here

Status: Built 2026-10-18, QEMU-verified (osdev test download). No hardware card yet.

download <link>
download <link> to <file>

Fetch one file over https:// or plain http:// and save it to this machine's disk. With no to, it is saved in the current folder, named from the link's last part (https://example.com/a/file.zip becomes file.zip); a link that ends in / is saved as index.html. to <file> names it instead, relative to the current folder or absolute.

What it checks, and what it says it did not

  • https:// is checked as everything on this machine is: TLS 1.3, the certificate chain to a root in /system/services/godspeed/roots/ (the same roots godspeed uses - one trust store, not two), and the server's name. A link that fails a check is not downloaded, and the answer says which check failed.
  • http:// is taken, and the answer says NOT checked: nothing proves the bytes are the ones the server sent, and anyone between here and there could have changed them.
  • A host that is an IP address (http://192.168.4.40:8000/file) is that address: no name server is asked (since 2026-10-24; a resolver answers "no such name" for one, so such a link used to fail). An https:// link by address is still checked, and fails unless the server's certificate names that address.
  • Redirects are followed, up to five. A link that began https:// and is redirected to http:// is refused: a link that looked checked never quietly becomes one that is not. Typing the http:// link yourself is the way to take it.

The file

Written as <file>.partial and renamed only when its last byte is in, so a file at <file> is always a whole one. A download that fails deletes its partial. One that is ended with q leaves it, and the next download to the same file deletes it first. An existing file is never replaced: delete it, or name another with to.

While it runs

The same progress as godspeed fetch: one line of words - the percentage, the size so far of the whole (2.0 / 9.5 MiB), the speed so far (KiB/s or MiB/s, the units the sizes use), about how long is left (about 45 s left, 2 min 5 s, 1 h 3 min; done in ... at the end) and [q] quit - and the bar on its own line under them, as wide as the console. Nothing ends a download that keeps arriving: the bound is timeout-secs (godspeed config) with NOTHING moving, never a total.

Output

A card when it is done:

keyvaluenote
savedthe fileits size
fromthe link it came fromas typed, or how many redirects from the one typed
checkedhttps, or NOT checkedwhat that means
speedKiB/s or MiB/sthe bytes and the time, the connection included
timewhere the time went: network, decrypt, disk and screen, in mshow many reads of net-stack it took, and how many found nothing yet, with the time they waited (a read that finds nothing yields and asks again, and parks for a tick once 2 ms of that has found nothing, so the count is of reads, not of pauses). https:// only; the network and decrypt figures include the handshake, and the name lookup, the connect and the sends are in none

What it does not do, recorded rather than hidden

  • A server must say how large the file is (Content-Length). fs sizes a file before it is written and has no append, so a page built as it is sent, with no length, is refused with that reason. Static files nearly always say.
  • Certificates must be ECDSA (P-256 or P-384), which is all this machine's TLS checks. Many sites still use RSA certificates, and a download from one is refused, saying so. RSA is a change to the TLS library.
  • No resume and no retry yet. A download that breaks starts again from the beginning when it is run again. A connection does not survive the machine's address changing - pulling the cable when the radio is on another network, for one - on any system; resuming is what will make that cheap, and it is the next step.
  • HTTP and HTTPS only: no FTP and no other protocol (agreed 2026-10-18).
  • Not a pipe. It is an action; its value is the file. read the file to pipe it.

Where it runs

In the package manager's program (services/godspeed-pm/src/download.rs), which already holds what it needs and no more: the network, the disk, the clock and the trusted roots. It touches nothing of the package store and is given no authority the package manager did not already have. wget and curl answer try download.

spawncap - spawn a service and prove the capability to it routes

Implementation shape: shell built-in, asking the supervisor. A diagnostic, from the naming work (docs/naming-design.md, Phase 0): it checks the seam the supervisor's name -> cap map is built on, that a spawner is handed a capability to the service it started and that the capability reaches it.

Status, as built and honest (2026-10-10)

Built. It answers spawncap help and spawncap version since 2026-10-10; before that it was missing from the shell's UTILS list, so version and help were read as service names (backlog/fixed/80 H8). Its sibling spawnwired (spawn greet wired to pong by a passed capability) has a help block and no spec of its own; it is recorded as vocabulary debt in COMMANDMENTS.baseline.toml.

Usage

VerbKindWhat it does
spawncap <svc>actionask the supervisor to spawn <svc> and hand back a capability to its endpoint, send one byte through that capability, then give the capability back
spawncap helpusage
spawncap versionthe version and the collective copyright line

What it answers

SituationAnswer
The capability routesspawncap: pong - endpoint cap acquired; send Ok
The send through it failsspawncap: pong - cap acquired but send failed, and an error
The service has no endpoint to hand backspawncap: pong - spawned, but it has no recv endpoint to hand back, and an error
The supervisor refused or the spawn failedspawncap: could not acquire endpoint cap for pong (...), and an error
A core servicerefused, as spawn refuses it

It changes state: the service it spawns stays running. It does not pipe; it is an action.

Utility: sock - a UDP socket as a capability

Utility: sock - open a UDP socket capability and send a datagram through it Status: Built (first slice). As-built reference. Hardware-verified on the Raspberry Pi 2 (2026-09-15): the mint, the grant and the badged invocation all complete on real hardware. The DATAGRAM round trip took until 2026-09-25 and two separate fixes - see 3. Shape: shell built-in that opens a socket cap from net-stack and invokes it.


1. Purpose

sock demonstrates the payoff of the networking design: a socket IS a capability (§7.10 - the same delegated-resource-cap mechanism as file-as-capability). The shell asks net-stack to open a UDP socket; net-stack MINTS a socket capability and hands it back. The shell then INVOKES that cap to send a datagram - the kernel validates the cap and badges the invocation with the socket's ResourceId, so net-stack knows which socket without the kernel knowing what a socket is.

A socket is not an ambient channel or a file-descriptor number - it is an unforgeable token the client holds, exactly like a file cap.

2. Invocation

CommandMeaning
sockOpen a UDP socket cap, send a datagram through it, report the round-trip.
sock versionPrint the version.
sock helpPrint usage.

First slice: one fixed demonstration. A general sock <ip> <port> <data> and a receive path are the next steps (§7).

3. Output

gsh> sock
sock: UDP socket cap - sent 29 bytes to 192.168.4.1:53, received 94 bytes back (a round-trip through a capability)

The datagram is a small DNS query (just data that elicits a UDP response); sock reports the round-trip - bytes out and back - which proves the cap does real UDP I/O. When there is no NIC, the invocation returns nothing and sock says so plainly. The destination is the resolver from the DHCP lease, which is why the address above is a LAN address and not a constant; with no lease, sock says there is no resolver rather than sending into the void.

It took TWO fixes to work on hardware, and the first one's success hid the second

This section used to say the destination was "hardcoded to 10.0.2.3:53, which is a QEMU address, so on real hardware this reports 0 bytes back", and recommended reading the resolver from the lease. That was true, the fix shipped (82705c59), and it was only half the cause:

  1. The address. 10.0.2.3 is QEMU SLIRP's resolver and nothing else, so on any real LAN the datagram went nowhere. Unfalsifiable under emulation, where the constant happens to be right.
  2. The wait. udp_roundtrip RE-TRANSMITTED the query on every retry and read whatever came back from the send. nic-driver no longer couples a receive to a transmit, so each retry drained the reply that HAD arrived and discarded it; it also never answered an ARP for us, and never paced its polls. Fixed in 5716da17 by giving it the send-once-then-RX-poll shape the DNS path already had.

The lesson is about the DOC, not the code: stating one cause confidently concealed the other. After fix 1 the address was right, the command still failed, and the spec said the cause was known. A partial diagnosis asserted as complete is worse than no diagnosis, because it stops the next person looking. Measured on the T630 between the two fixes:

sock: UDP socket cap - sent 29 bytes to 192.168.4.1:53, nothing came back (the send went through the capability; the peer did not answer)

Right address, real resolver, reply destroyed in flight. The capability path - minted, granted, invoked, badged, routed, answered - was working correctly the whole time, which is what made the wrong half so easy to believe.

The socket path is deliberately UNTAGGED. Every other net-stack request carries a correlation byte at offset 0 (docs/net-tags-design.md §8); a badged socket invocation does not, because the badge already names the socket and there is nothing for the client to disambiguate. fs makes the identical exception for file capabilities.

4. Pipe behaviour

sock is a pipe producer: sock | write /sock.txt snapshots the result to a file.

5. How it works (the capability path)

  1. net-stack holds RESOURCE_MINT, from the privilege word in the supervisor's spawn request for it, exactly like fs (the kernel keys no grant on a service name, CLAUDE.md §13.6).
  2. sock sends net-stack an "open socket" request; net-stack mints a socket cap (resource_mint, READ|WRITE) and grants it back (send_with_cap_by_handle).
  3. sock invokes the cap (resource_invoke with RIGHT_WRITE and a payload of dest_ip, dest_port, data); the kernel validates rights + generation, badges the message with the socket's ResourceId, and routes it to net-stack.
  4. net-stack reads the badge (last_recv_badge), finds the socket, builds a UDP datagram, sends it through nic-driver, and replies with the response.

Because this is the file-cap mechanism, every §7.3 property is inherited: unforgeable (a fabricated handle is not a socket cap), non-escalating (a send needs RIGHT_WRITE), revocable (closing bumps the generation). This first slice exercises the mint + invoke + send; the forged/revoke checks (as fcap does for files) come with the fuller sock surface.

6. Capabilities

  • Console output.
  • A SEND cap to net-stack, acquired by name (the shell holds ACQUIRE_ANY).
  • The socket cap itself, minted by net-stack per open - the client's authority to use that one socket, and nothing more.

7. Non-goals / next

  • First slice. One fixed demo (send + report). A general sock <ip> <port>, receiving on a socket, and binding a local port are the next steps.
  • No raw ICMP/TCP sockets yet. UDP first; TCP sockets ride the same mechanism once TCP lands.

8. Conformance

Conforms to 0_conventions.md: sock version / sock help, words-not-flags, raw facts. Pinned by osdev test shell (open + invoke a socket capability, and net's tab-completion adjusted for the new so-prefixed verb).

Conforms to rule 10. The resolver lookup (ns_deadline, 3 s, one reacquire-and-retry) is under the command's watch (2026-10-13, backlog/fixed/89), and the open and the send go through gs::net::Net::with_notice, which says [q] quit once a wait passes 2 s and ends on q. That notice is the request's own, not the command's: a q typed earlier in the same command does not stop the next request before it is sent. (Found 2026-10-09 lending no notice, through Net::new; bounded at gs::net::NET_SECS (10 s) for the open and SOCKET_SECS (30 s) for the send either way.)

Utility: tcp - one TCP transaction

Status: Built. Hardware-verified on five machines across four instruction sets and four ethernet controllers (Raspberry Pi 2, Raspberry Pi 4, StarFive VisionFive 2, HP T630, Dell Wyse 5070), and in QEMU against a decoded packet capture. See docs/tcp-design.md.

tcp <ip> <port> [text]

Open a TCP connection to <ip>:<port>, send text if given, print whatever comes back, and close.

Why one transaction and not a session

net-stack drives the connection from inside the request that asked for it, so tcp is one complete exchange: connect, send, read, close.

This used to be BLOCKED, and it is not any more - it is simply unbuilt, which is a different thing and worth stating precisely. The blocker was that net-stack received client requests and nic-driver replies on one untagged endpoint, so any unsolicited driver traffic consumed client messages; a background connection was therefore impossible rather than merely absent (docs/net-tags-design.md §1). That is resolved: the service now sifts what arrives on every driver conversation (phase 2), keeps a displaced client request in a bounded stash rather than dropping it (phase 3, §9), carries a correlation tag on the client hop so a late reply is discarded instead of misread (§8), and runs a bounded poll step when no client is asking.

Both halves of the API that this needed now exist. A long-lived connection is a thing a client can HOLD - a TCP connection as a delegated resource capability, the way a file and a UDP socket are (gs::net::Net::connect, which godspeed uses for its TLS connection to the registry) - and listen/accept let the machine serve rather than only fetch (serve, utilities/49_serve.md). tcp itself stays one transaction.

What the protocol actually implements

The three-way handshake, sequence numbers and cumulative acknowledgement, the retransmit queue with RTO estimation per RFC 6298 and Karn's algorithm, in-order delivery with a bounded out-of-order queue, window advertisement and update, and the full close sequence through TIME_WAIT.

Plus, since the first hardware run:

maximum segment sizeadvertised on every SYN and honoured from the peer's. Before this the stack advertised nothing, so every peer had to assume the RFC 1122 default of 536 bytes per segment
congestion controlRFC 5681: slow start, congestion avoidance, fast retransmit on three duplicate acknowledgements, and NewReno fast recovery. Sending is bounded by the smaller of the peer's window and the congestion window - the receiver's limit and the network's are different questions
persist timera shut receive window is probed, so a lost window update cannot deadlock two correct implementations

A startup self-test proves the parts the network cannot reach. Congestion control reacts to loss, fast retransmit to reordering, the persist timer to a closed window - none of which a healthy LAN or the QEMU backend produces. So net-stack drives its own state machine against a synthesised peer when it starts, using the same encoder and parser that serve the wire, and logs net-stack: tcp selftest PASS - 31 checks. A guard nobody has seen fire is not evidence that it works.

Arguments

<ip>an IPv4 address in dotted form, for example 10.0.2.2. Not a hostname - resolve it with net dns first, so that a name that will not resolve is a separate, visible failure from a peer that will not answer
<port>1 to 65535
[text]optional. Remaining words are joined with single spaces and sent as the request body

Output

tcp: 13 byte(s) back
hello, world.

Bytes outside printable ASCII render as ., so a binary reply cannot spray control codes at the terminal. An empty reply is reported as connected to nothing rather than as silence, because net-stack answered - the reason it produced no data is in its log, and the two cases are different faults.

Failure

Every failure is named rather than folded into "it did not work":

what happenednet-stack logs
the peer refused, or reset an established connectionpeer reset the connection
six retransmissions went unacknowledgedno acknowledgement after 6 retransmissions
no answer to the opening SYNno answer to our SYN
the connection worked but returned nothing before the budgetno data and no fault (budget expired)
the network is not configured yettcp asked for before the stack is configured

Bounds

One transaction is capped at 8 seconds and at a fixed number of protocol steps; a connection holds a 2 KiB send arena and a 256 KiB receive arena (8 KiB if the large one could not be had), and at most 2 connections exist at once; when they are all taken, one whose client has not used it for 15 s is released for a new one. The advertised receive window is the free space in that receive arena, scaled (RFC 7323), so the peer is told exactly what this machine can hold (docs/tcp-design.md).

(This said "at most four connections" until 2026-09-15. MAX_CONNS was reduced from 4 to 2 when service_main's stack frame reached 37% of the 256 KiB user stack on arm32, and the spec was not brought with it - the kind of drift scripts/facts_check.py exists to catch.)

Escaping it

q, Q or ESC aborts the wait and returns to the prompt (tcp: aborted), and [q] quit is advertised once the wait lingers past about two seconds (gs::net::Net::with_notice). A transaction that gets no answer at all gives up after 20 seconds (gs::net::TCP_SECS) with tcp: net-stack did not answer within 20s - see its log.

This was not always true, and its absence cost a power cycle. The request used a bare request_with_reply, which parks the shell inside the syscall where it cannot read the keyboard - so on a Dell Wyse a tcp net-stack never answered froze the prompt with no way out (backlog/fixed/29).

What q stops is the SHELL'S wait, not net-stack's transaction: net-stack keeps working its own 8 s budget out. That is rule 11's concern and it is named here rather than glossed. It does not wedge the next command - the abandoned reply is discarded by its correlation tag when it lands - but a tcp issued immediately after an abort can wait behind the one you abandoned.

Conventions

Obeys most of utilities/0_conventions.md: arguments are words rather than flags, and the command reports raw facts without editorialising. tcp help prints its help_block and tcp version its version (rules 5 and 6); until 2026-10-10 tcp was missing from the shell's UTILS list, so tcp version printed the usage instead. Its arguments are an address and a port, never a path, so it is in the shell's NO_PATH_CMDS (rule 9). Rule 10: the wait is q-escapable.

Utility: serve - answer TCP connections until you quit

Status: Built. Verified end to end in QEMU (scripts/tcp_serve_test.py), checked on both sides - the guest's own report and the bytes the test machine received over a socket outside the guest. That test also runs serve twice on the same port, which is the regression for the listener leak below.

And hardware-verified on the HP T630 (2026-09-24), which is the run that matters most. Three inbound connections from a separate machine on the LAN, each accepted, echoed and closed, every byte returned unchanged. QEMU structurally CANNOT test this: SLIRP's only peer is the gateway, so an unsolicited inbound connection from a real third party had never happened before that run. The whole point of serve is answering a stranger, and until then only the guest's own side had been proved.

serve <port> [for]

Listen on <port> and KEEP ANSWERING: each connection is accepted, printed, echoed back and closed, then it waits for the next one.

Waits until you press q. A duration bounds it instead: 30s, 5m, 2h, 1d, or a plain number of seconds.

serve 8080           wait until q
serve 8080 5m        give up after five minutes
serve 8080 90        or after ninety seconds

The first version of this capped the wait at 30 seconds, which is shorter than walking to another machine and typing - a server that stops listening because a timer ran out was not listening when somebody called. A duration is now something you ask for, not something imposed on you.

Why this is the interesting one

This is the machine acting as a host rather than a client, and it is the first command that does. Everything else in the networking surface dials out: ping, net dns, sock and tcp all start the conversation. serve answers one.

That needed the passive half of the TCP open - State::SynReceived, a listener that is not a connection, and a SYN-ACK owed and sent by the poll step - none of which existed until it was written (docs/tcp-design.md). It also needs the poll step itself: a connection accepted here makes progress with no client asking, which is exactly what net-stack could not do before.

It keeps serving

The first version handled exactly one connection and exited, which made every use a coordination exercise: start it, race to connect before it gave up, start it again. It now serves until you stop it.

Still not a daemon, and the distinction is worth keeping: it runs in the foreground, holds the prompt, and ends when you press q. A server that outlives its shell is a service with a contract of its own (§26.2).

Arguments

<port>1 to 65535. The port to answer on
[for]optional. How long to wait: 30s, 5m, 2h, 1d, or a plain number of seconds. Omitted, it waits until you press q

Output

gsh> serve 8080
listening on 192.168.4.37:8080 - answering connections
  [q] quit
still listening - 10s, 0 served
accepted a connection (1)
received 11 byte(s): knock knock
echoed 11 byte(s) back
closed - waiting for the next connection

[q] quit is the shell's, once, 2 s in, as for every command that waits; it is not repeated on the status lines.

With a duration the reminder reads still listening - 10s of 300s, 0 served. When it ends it says serve: 300s elapsed, N connection(s) served (a duration ran out) or serve: stopped after N connection(s).

The address is asked of net-stack rather than remembered, so it is the one the stack holds now and cannot drift from a changed lease. net-stack logs the other side of the same story - an inbound SYN by address and port, the handshake completing, and a connection refused for want of a slot - so "the SYN never arrived" and "the SYN arrived and we never answered" are not the same silence.

Bytes outside printable ASCII render as ., so a peer cannot spray control codes at the terminal.

Bounds

Waits for as long as you asked - by default until q, Q or ESC - and at most 10 seconds for an accepted peer to send something. A duration whose NUMBER is over a year of seconds is refused as a typo rather than honoured. The check is made on the seconds, after the unit is applied, so 400d is refused too.

While it waits it says so every ten seconds, because two minutes of a mute prompt is indistinguishable from a wedged one.

At most MAX_LISTEN ports are listened on across the whole system, and an accepted connection takes one of the connection slots - so a machine already holding its maximum connections refuses new ones by dropping the SYN (saying so in its log) and the peer retries.

The port is RELEASED, on every exit

Closing the listener is an explicit operation, and serve performs it whether it finished, timed out, or you pressed q.

This is not automatic and its absence was a real leak. Dropping the client's capability does not tell net-stack anything - the kernel revokes the holder's authority, but net-stack's listener table is its own state and nothing walks back to it from a dropped cap. Found on a Raspberry Pi 2: the second serve 8080 was refused, and stayed refused until the service restarted. With MAX_LISTEN at 2, that leak is two runs deep. Connections are released the same way - net-stack reaps one that has finished and revokes it, so the holder's next call gets CapRevoked from the kernel.

It does not always land, and that is recorded rather than hidden (backlog/28). The release is a round trip, and measured over a dozen QEMU runs it failed a couple of times - leaving the port registered and the next serve on it refused. serve asks once and says serve: the port was NOT released (...) - the next serve on it will be refused if that fails, so the condition is visible; the underlying asymmetry is that the kernel does not tell a service when a capability is dropped, so a port cannot release itself and a client that dies will always leak one.

What it is made of

Three capabilities, each minted by net-stack and held by the shell (§7.10, the same delegated resource mechanism as a file and a UDP socket):

  1. The listener. net-stack mints it when the port is granted. Closing it stops new connections and leaves connections already accepted from it running.
  2. The connection. accept mints a second capability and grants it to the caller. From that point the connection is a thing the client HOLDS - reading needs READ, sending and closing need WRITE, and a stale handle gets CapRevoked from the kernel rather than a wrong answer from net-stack.
  3. A send cap to net-stack, acquired by name.

A peer's MAC is taken from the frame its SYN arrived in, so an inbound connection needs no ARP at all: the segment reached us from that address, which is the one fact that never has to be asked for.

Failure

what happenedwhat you see
the stack has no address yetnet-stack would not listen on that port, and net-stack's log says which of the three reasons it was
the port is already listened on, or every listener slot is in usethe same line; net-stack's log distinguishes them
nobody connected in timeserve: <n>s elapsed, 0 connection(s) served, when a duration was given
you pressed q while it waitedserve: stopped after N connection(s), and the port is released
you pressed q while the port was still being openedserve: aborted; nothing was granted, so nothing is released
the peer connected and sent nothingthe peer connected but sent nothing

Conventions

Obeys most of utilities/0_conventions.md: the argument is a word rather than a flag, raw facts without editorialising, and q escapes every blocking wait. serve help prints its help_block and serve version its version (rules 5 and 6); until 2026-10-10 serve was missing from the shell's UTILS list, so serve version was read as a port and refused. Its argument is a port, never a path, so it is in the shell's NO_PATH_CMDS (rule 9).

Utility: ping - continuous ICMP echo to a raw IPv4 address

Utility: ping - repeatedly echo a raw IPv4 and report round-trip time + TTL, Windows-style Status: Built. As-built reference. Shape: shell built-in that sends a [3, ip, bytes] request to net-stack per echo.


1. Purpose

ping is the reachability probe everyone already knows: it sends ICMP echo requests to a raw IPv4 address, once a second, and prints a line per reply with the round-trip time and the reply's TTL - exactly like ping on Windows/Linux. No DNS is involved (you give it an IP, not a name), so it is the most direct way to ask "can I reach this host, and how fast?".

It is also the cleanest diagnostic for the rest of the stack. ping runs through net-stack's serve loop (the same path net dns and sock use after boot), not the one-shot boot dance - so ping <your-gateway> proves the post-boot request path works, and ping 8.8.8.8 probes the internet directly, each without depending on DNS.

2. Invocation

CommandMeaning
ping <ip>Ping continuously (a reply line per second). q quits, then a statistics summary prints.
ping count <N> <ip>Send exactly N echoes, then stop and print statistics.
ping bytes <N> <ip>Set the ICMP data size (default 32, max 1024). Combinable with count.
ping versionPrint the version.
ping helpPrint usage.

Options come before the IP and may be combined: ping bytes 64 count 4 192.168.4.1. <ip> is a raw IPv4 literal (a.b.c.d). A hostname (ping google.com) needs DNS and is rejected with a clear message - use net dns <host> to resolve, then ping the IP.

3. Output

gsh> ping 192.168.4.1
  [q] quit
Pinging 192.168.4.1 with 32 bytes of data:
Reply from 192.168.4.1: bytes=32 time=2ms TTL=64
Reply from 192.168.4.1: bytes=32 time=3ms TTL=64
Request timed out.
Reply from 192.168.4.1: bytes=32 time=2ms TTL=64
(q)

Ping statistics for 192.168.4.1:
    Packets: Sent = 4, Received = 3, Lost = 1 (25% loss)
Approximate round trip times in milliseconds:
    Minimum = 2ms, Maximum = 3ms, Average = 2ms

([q] quit is the shell's, not ping's, and appears once. For every other command that waits it comes 2 s in; ping prints until it is stopped, so it is said FIRST, on its own line above the header, and never among the replies.)

  • time= - the measured round trip, in microseconds below 1 ms (time=412us, time<1us for zero) and whole milliseconds above. The summary uses one unit for all three figures, chosen by the average. net-stack times the round trip with the CPU's cycle counter, calibrated against the wall clock, and reports microseconds as a u16, which saturates: a round trip of 65 ms or more prints time>=65ms, and the summary adds a line saying such a reply counts as 65 ms. On a host whose cycle counter is uncalibrated every reply reads time<1us, and the summary reports 0us for all three figures.
  • TTL=N - the time-to-live in the reply's IP header (the pinged host's, not ours).
  • Request timed out. - no matching echo reply arrived within the budget for that round. No reply from <ip>: link not confirmed - ... means net-stack could not confirm a link, and No reply from <ip>: net-stack not responding means the request itself got no answer.
  • The statistics summary prints whether you stopped with q or a count run finished.

4. Pipe behaviour

ping is a pipe producer. A bounded run is the sensible thing to capture: ping count 4 8.8.8.8 | write /ping.txt. (Piping the endless default is possible but pointless.)

5. How it works

  1. The shell parses the options and <ip> (parse_ipv4, no allocation) and, per echo, sends net-stack a [3, a, b, c, d, bytes_lo, bytes_hi] request.
  2. net-stack's serve loop builds an ICMP echo request of bytes data inside an IPv4 packet addressed to <ip> (Ethernet destination = the gateway's MAC, so it routes off-subnet), times the round trip with the TSC, sends it through nic-driver, and waits for the echo reply - matching the reply's source IP so a gateway ping and an internet ping cannot be confused.
  3. net-stack replies [alive, rtt_us(le u16), reply_ttl] (alive 2 = no link confirmed); the shell formats the Reply from line, accumulates min/max/average, and paces ~1 s to the next echo while polling for q.

This reuses the same ping() helper net-stack runs at boot to check the gateway, so a working ping <gateway> from the shell and a working boot-time gateway ping exercise identical code.

6. Capabilities

  • Console output, and console input (to catch q).
  • A SEND cap to net-stack, acquired by name (the shell holds ACQUIRE_ANY).

ping holds no hardware capability itself - net-stack owns the ICMP construction and the frame path to nic-driver; the shell only asks. net-stack calibrates the TSC itself for the timing.

7. Non-goals

  • No name resolution. ping <hostname> needs DNS; resolve with net dns first.
  • No fragmentation. The data size is capped at 1024 bytes (one frame); larger is clamped.

8. Conformance

Conforms to 0_conventions.md: ping version / ping help, words-not-flags (count/bytes, not -c/-s), raw facts, pipe producer. Pinned by osdev test shell (ping count 3 10.0.2.2 -> the Windows-style header, reply/timeout lines, and statistics summary, end to end).

Utility: version

Utility: version - the GodspeedOS version and build stamp Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

version answers what version of GodspeedOS is this, and exactly which build? - the system's version number plus the short git commit SHA it was built from. It is the whole-system counterpart to the per-utility <util> version (which reports one utility's version); this reports the OS.

It does not overlap with about: about is the identity card (name, slogan, core count, credits) and repeats this line as prose; version is the version fact on its own, the form to pipe or compare.

2. Invocation

CommandMeaning
versionPrint the version, architecture and build stamp and return.

3. Output

gsh> version
GodspeedOS 0.22.0 x86_64 (f7a6946a)

The number is the system version (the workspace version every crate inherits; it is NOT the shell's UTIL_VERSION, which versions the utilities and is a separate number). The word after it is the architecture the build targets, as the project names it (arm32 for 32-bit ARM, otherwise Rust's own name). The parenthesised value is the git commit SHA, 8 characters, stamped at build time. A build made outside a git checkout reports (unknown).

ASCII only - the framebuffer console's font is ASCII (no non-ASCII glyphs).

4. Data source

  • Version: env!("CARGO_PKG_VERSION"), the workspace version in the root Cargo.toml (the shell's Cargo.toml has version.workspace = true; see CONTRIBUTING.md).
  • Architecture: env!("GODSPEED_ARCH"), derived by the shell's build.rs from CARGO_CFG_TARGET_ARCH.
  • Build SHA: env!("GODSPEED_GIT_SHA"), stamped by the shell's build.rs from git rev-parse --short=8 HEAD at compile time. build.rs watches .git/logs/HEAD, so the stamp refreshes when HEAD moves.

5. Capabilities

  • Console output to print the line. No introspection cap needed (both values are compile-time constants).

6. Non-goals

  • Not about. version is the version fact, not the identity card. Keep them separate (conventions rule 7 - a utility reports one raw fact).
  • No per-utility versions. version reports the OS; a single utility's version is <util> version (the universal subcommand, 0_conventions.md §1 rule 5).

7. Conformance

Conforms: own version help / version version (with a real example, per 0_conventions.md); listed by the shell's top-level help under System; tab-completes as a command name and its version / help subcommands complete at the first-arg position. See 0_conventions.md §3.

Utility: wait

Utility: wait - do nothing for N seconds, abortable with q Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

wait pauses for N wall-clock seconds. It is the shell's pacing primitive: the thing a script (or a person) uses to space repeated work out in time. repeat wait <seconds> is built on it (61_repeat.md), and a script's own loop the same way - loop { <cmd>; if !wait 2 { break } }. It was pulled into existence by the library script watch, which repeat replaced (§26.2, not added speculatively).

The name is the plain English of what it does. It is not POSIX wait (wait for a child process) - GodspeedOS has no fork and no child processes; here wait 2 reads as the sentence it is.

2. Invocation

CommandMeaning
wait <seconds>Pause for N seconds (1..3600). q/Q/Esc aborts with Err.

A wait longer than 3600 s is refused loudly - a pacing pause is seconds-to-minutes; an hour-plus wait at the prompt is a typo, not a plan (§26.6 bounded).

3. Behaviour

  • Whole-second granularity. Paced by the deglitched monotonic RTC second (the same clock uptime and ping pace by) - never the TSC, whose wall-time calibration is wrong on some hardware (the T630). wait 1 ends at the next second boundary, so it can return up to a second early, never late.
  • q aborts, and the abort is an Err. That is what makes it compose: if !wait 2 { break } ends a loop the instant the user quits. At the prompt, an aborted wait shows as Err in result.
  • Sleeps between checks. The pause sleeps 50 ms between its clock and key checks (pause_quittable), so the core is free for the wait and q still answers at once. It used to call yield_now, which kept an otherwise idle core busy (backlog/fixed/80 H21).
  • Other keys are kept. A key that is not q, Q or Esc is kept for the prompt, so a command typed during the wait is still there when it ends. They were dropped until backlog/84 S3.

4. Commandment VIII note (read this before imitating)

wait is a user-commanded delay: the user (or their script) chose the cadence and holds the q escape. That is policy in the user's hands, not a service coordinating with a peer - so "wait on truth, not time" is not violated here. Services must still never pace their dependencies with a timer: a service that sleeps instead of blocking on its dependency's reply is the exact bug Commandment VIII exists to forbid.

5. Capabilities

  • Clock read (the RTC epoch via the kernel, as date/uptime already use).
  • Console input (the q poll) + console output for usage errors.

6. Conformance

Conforms: wait help / wait version (0_conventions.md rules 1-6); listed by the shell's top-level help under System; in NO_PATH_CMDS (its argument is a number - Tab never lists files). Pinned by selfcheck (ok on wait 1, refusals on wait, wait 0, wait 99999) and a shell-test q-abort check.

Utility: repeat

Utility: repeat - run a command again and again, q stops it Status: Built 2026-10-12. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

repeat runs a command, then runs it again: a set number of times, or until you press q, with an optional pause between runs. It is for doing the same thing more than once - kill xhci four times twenty seconds apart on a hardware card, status every two seconds while something settles - so the loop is typed as one line rather than run by hand.

The name is what it does. It replaced the library script watch (2026-10-12), which did most of this under a name for one reason to use it: watching something change. Run kill xhci through it and nothing is being watched; an action is being repeated. watch is also the POSIX word for the tool, which is the reflex this vocabulary does not carry (rule 8). Typing watch answers try repeat (FOREIGN_HINTS) and runs nothing.

2. Invocation

CommandMeaning
repeat <command ...>Run it, then again, until q.
repeat wait <seconds> <command ...>The same, with that pause between runs (1..3600, the wait utility's bounds).
repeat count <n> <command ...>Run it n times (1..10000), then stop.
repeat count <n> wait <seconds> <command ...>n runs with the pause between them. count and wait may come in either order.
repeat help / repeat versionAs every utility (rules 1-6).

count and wait are repeat's own words, read only before the command: so the count and wait commands themselves cannot be repeated, which nobody needs. A repeat of a repeat is refused - the whole count goes in one: repeat count 4 <command>.

3. Behaviour

  • Each run is announced: repeat 2 of 4: kill xhci, or repeat 2: status with no count. The command's own output follows it, as typed at the prompt. [q] quit is the shell's, once, 2 s in, not repeated on every run's line.
  • q stops it between runs: during the pause, or - with no pause - when it looks at the keys after a run. A command that waits has its own q (rule 10).
  • The end is a verdict: repeat: 4 run(s), 1 failed, with - stopped by q when it was. It is Err when any run failed, so a script can branch on it, and a failure in the middle of a long run is a count rather than a line that scrolled away.
  • Runs inside a script as well as at the prompt. It is a built-in for that reason: a library script cannot be run from another script, and gsh cannot tell a number from a word, so watch could neither serve a script nor read a count.
  • Each run is a script line. The command runs one level down (execute at depth + 1), so run, selfcheck and the library scripts are refused inside it, and the refusal names repeat (it named only scripts until backlog/84 S1). Full-screen views (help, docs) print instead of opening, because a view waiting for a key would stop the runs.
  • Its output is the caller's. The announcements, each run's output and the verdict go where the command's output was sent: the screen at the prompt, the variable when it runs in a function whose output is captured, $(f) (backlog/84 S2). A refusal of its own options still goes to the screen. $(repeat ...) itself is refused, since a capture takes a producer, and run ... save <path> saves only the run's report, for repeat as for every command.
  • Keys typed while it runs are kept. Between runs, and during its pause, a key that is not q is kept for the prompt rather than dropped, so a command typed ahead is still there when it ends (backlog/84 S3).
  • No screen clearing. Clearing was there to make watch a live view; repeating is not that. For a live view of the system, observe.

4. Pipes (rule 12)

repeat does not pipe: it runs commands, and each prints what it prints. repeat ... | ... is refused ('repeat' cannot start a pipe), so a pipeline cannot be repeated either. Recorded under rule 12 as an action.

5. Capabilities

None of its own. It runs commands with the shell's authority, as typing them would; the clock and the console, through the same pause wait makes.

6. Conformance

repeat help / repeat version (rules 1-6); listed by the shell's top-level help; in NO_PATH_CMDS, and its first word completes from the commands and its two option words (rule 9). Pinned by selfcheck (scripts/selfcheck/10-meta.gsh): a counted run that holds, one that fails, and the refusals.

Utility: whatis

Utility: whatis - what runs when a name is typed (kind + origin) Status: Built. As-built reference. Shape: shell built-in (see 0_conventions.md §2).


1. Purpose

whatis <name> answers "what runs when I type this name at the prompt?" It is the honest replacement for POSIX which: there is no $PATH here and no executables on the filesystem, so there is no path to return - a name's truth in GodspeedOS is its kind and origin. That is also an authority answer (§26.9): a built-in runs in the shell's protection domain, a library script is gsh run by the shell, a service runs in its own domain with its own capability set.

The name is one glued word (whatis, like selfcheck) - command names are single words; hyphens belong to argument keywords (all-services, kill-storm).

2. Invocation

CommandMeaning
whatis <name>Print the name's kind (one line) and return. Unknown names are a loud Err.

3. Output

gsh> whatis dir
dir: shell built-in

gsh> whatis health
health: library script (gsh, baked into the image)

gsh> whatis fs
fs: standalone service (running - task 5, core 1)

gsh> whatis pong
pong: standalone service (not running)

gsh> whatis where
where: record-pipe stage (runs only inside a pipe)

gsh> whatis banana
banana: unknown
  • Running services show their live task + core - identity is the name; the task/core is merely where it lives right now (Invariant 11), so the parenthetical is a snapshot, not part of the identity.
  • The pipe-only verbs get their own kind - "why does typing where bare fail?" is a real confusion this line dissolves. The dual commands (sort, match, count, first, last) run bare on files too, so bare they are built-ins.
  • unknown returns Err (assert fails whatis banana holds).
  • "Shell built-in" means the shell's UTILS list, which holds every command the shell dispatches (fmt, tcp, serve, random and gpio were missing from it, and called unknown, until backlog/fixed/80 H8). A service that is not running is recognised only if it is in the shell's KNOWN_SERVICES list, which is shorter than the supervisor's roster.

4. The lens (one deliberate ambiguity)

The answer is about the name at the prompt. ping is both a built-in command and a demo service; typing ping runs the built-in, so whatis ping says built-in. Similarly whatis version and whatis help cannot be asked - the universal <util> version|help subcommand intercept answers about whatis itself first (consistency across every utility beats the edge case). whatis is the one utility whose argument domain contains those two words, so its own help output carries the answer at the point of collision: a usage row noting that help/version answer for whatis itself and that both names are shell built-ins.

5. Data source

  • The shell's own command/library tables (built-ins, baked library scripts, pipe verbs).
  • Live task lookup (task_stat via the INTROSPECT cap the shell already holds) for the running-service case; a static known-services list for the not-running case.

6. Non-goals

  • Not a description. whatis says what a name is; help says what it does.
  • Not a path. If a user-editable /lib ever exists, the origin distinction (baked into the image vs /lib/x.gsh, on disk) becomes this utility's most valuable answer - the "is this something I shipped or something dropped on the disk?" question.

7. Conformance

Conforms: whatis help / whatis version; listed by top-level help under System; in NO_PATH_CMDS (its argument is a name, never a file path); its argument tab-completes from the command-name set; a pipe producer (whatis dir | write /k.txt). Pinned by selfcheck (built-in / service / pipe-stage lines, loud unknown, usage refusal).

trace - what the kernel is doing RIGHT NOW

Five views over LIVE kernel state, read through task_stat and InspectKernel. Nothing here is recorded anywhere: ask again a second later and the answer may differ, which is the point.

For what was RECORDED - the IPC ring, metrics, logs, and capturing them to disk - see utilities/47_events.md. The two commands read two different sources, which is why they are two commands.

Status: BUILT and QEMU-verified. trace blocked / chain / deps / endpoints / endpoint, exercised by osdev test shell and the selfcheck suite.

  trace blocked                 what is stuck, everywhere
  trace chain <name|slot>       what one task is stuck behind, as a tree
  trace deps <service>          what it can call, as a tree, with what it has called
  trace endpoints               every live endpoint and its owner - names to ids
  trace endpoint <id>           the inverse: who owns an endpoint, and who can reach it

trace deps and trace endpoints are record sources, so they filter and convert like any other: trace deps fs | to grid, trace endpoints | where name contains fs.


1. The question

Why is this task not progressing?

Everything below is judged against that sentence. A feature that does not help answer it is not in this utility.


2. The finding that shapes the design

The kernel already knows why a task is stuck. It is not inferred, not reconstructed, not sampled - it is recorded, right now, in the structures the reply-side death-wake (§8.6) needed:

FactWhere it already lives
Which endpoint a task awaits a reply fromipc::routing::CALL_AWAIT_EP[slot] - one entry per task, "bounded ... it never grows"
Which task is blocked on a full queueRoutingEntry::blocked_sender
Whether a task is blocked, and howTaskState::BlockedOnRecv / BlockedOnSend
Endpoint core, generation, livenessthe routing table (§8.3)
Endpoint -> namethe kernel name directory (ipc::names)
Task name, core, statetask_stat

So the chain in the requirement's own example -

42 shell  -> fs:7 / req:88
7  fs     -> block:4 / req:901
4  block  -> nvme:9 / req:55
  • is a walk of CALL_AWAIT_EP: shell awaits fs's endpoint; look up which task owns that endpoint; ask what it awaits; repeat. No trace buffer. No event history. No message ids. No propagation.

This splits the utility into two mechanisms with radically different costs, and the split is the main proposal in this document:

MechanismCost when unusedNew kernel state
A. Blocked-chain (trace blocked, trace chain)a state queryzero - nothing runs until askedone u64 per task (blocked-since), for the FOR column
B. Event history (events ipc, events failures)a bounded ring written at the IPC routing pointone relaxed atomic load + predicted brancha fixed ring + counters

A answers the question. B explains what led up to it. A is cheap enough to always be available; B is the part that needs a switch. Building A first is not a staging convenience - it is where the value is, and it may be that B is never needed.


3. Where this sits against the constitution

The requirement says trace collection must not add a kernel responsibility outside MISCIS (Memory, IPC, Scheduling, Capability, Interrupt, SMP). Taking that seriously:

Mechanism A adds no responsibility at all. It exposes state the kernel already maintains for correctness - CALL_AWAIT_EP exists so a dead replier wakes its caller with ReplyDead. Reading it is introspection, which already exists and is capability-gated (INTROSPECT, docs/introspection-capability.md). This is the same move as observe: the kernel publishes, a service interprets (§26.10).

Mechanism B is a genuine addition, and should be argued rather than assumed. The honest case:

  • The kernel is already at the routing point holding exactly these values (§8.3: validate cap, look up endpoint, enqueue, IPI). Recording them adds no new knowledge - it writes down what it just computed.
  • The precedent is the kernel log ring (§11.4): a bounded in-kernel buffer whose contents are observability, not correctness. A trace ring is that shape exactly. (Note 2026-10-09: nothing drains the kernel log ring - CLAUDE.md §11.4 makes non-drainage the design - and mechanism B was built as a SERVICE, not a kernel ring: 47_events.md §8b.)
  • It stays mechanism: (sender, receiver, endpoint, generation, event) are IPC facts. The kernel never learns what a message means.

The honest cost: it is more kernel code and more kernel state, in a project whose first rule is that the kernel does not grow. That is a real trade and it should be made deliberately, with A shipped and in use first, so the decision is informed by whether A turned out to be enough.


4. What the requirement's example asks for that the kernel MUST NOT provide

The example output is:

└─ ipc → fs.read("/etc/config")
   └─ ipc → block.read(lba=1824, count=4)

fs.read, "/etc/config", lba=1824, count=4 are protocol interpretation. The requirement itself forbids the kernel from doing that, and it is right to - it is §4.4 and §26.10 both. The kernel sees an opaque byte array.

What the kernel can honestly produce:

└─ ipc → fs:7 gen=3 op=11
   └─ ipc → block-driver:4 gen=1 op=2

Endpoint names come from the name directory (already there). op is byte 0 of the message, which this document proposes the kernel records as an opaque u8 and never interprets - see 47_events.md §7 for why that is a defensible line and where it could still be argued.

Getting from op=11 to read requires a service-published decoder - the requirement's own "services may optionally expose symbolic names". That is real work in every service and it is the right place for it: fs knows what 11 means; the kernel must not. Proposed: not in v1. op=11 is already a large step up from nothing, and the decoder can be added per service later without touching the kernel.


trace blocked - who is stuck on someone else

gsh> trace blocked
no task is blocked on another task.

That sentence is the healthy answer, not an empty result. A service parked on its OWN endpoint is idle - waiting for work - and is deliberately not listed. Only a task waiting on ANOTHER task appears:

slot  name   blocked  awaiting  held_by
9     asker  call     116       reply-server
columnwhat it holds
slotthe kernel scheduler slot of the blocked task (the same number status shows)
namethat task's service name
blockedHOW it is waiting: call (a synchronous request awaiting its reply) or recv
awaitingthe ENDPOINT ID it is waiting on - an opaque kernel id, resolvable with trace endpoint
held_bythe service that OWNS that endpoint, i.e. the one that owes the answer

held_by is the payoff: it turns "task 9 is blocked" into "task 9 is waiting on reply-server".


trace chain <name|slot> - the same, as a tree from one task

gsh> trace chain asker
task 9 "asker" BlockRecv (call)
   awaiting endpoint 116
   `- task 8 "reply-server" BlockRecv (recv)
      root: awaits no task - blocked on its own endpoint, waiting for work

Read it downward: each line is who the line above is waiting for. The walk stops at a ROOT - a task waiting on nobody - and says which kind of root it is:

root linemeans
awaits no task - blocked on its own endpoint, waiting for workidle. The chain ends here because this service is fine
awaits no task - it is runnable, so the chain is not stuck hererunning right now

The argument is a service NAME or a task SLOT; digits are read as a slot. trace chain 7 and trace chain shell are the same query.


trace deps <service> - what it can call, and what it has called

gsh> trace deps fs
fs
|-- block-driver  27 calls  (capacity)
`-- events  (trace sink - its own traffic is never recorded)
(2 reply address(es) hidden - `trace deps fs | where peer contains reply` lists them)

Indentation is the call direction: a child is a service its parent holds a SEND capability to. This is read from the live capability table, so it is what the service can do right now - not what a contract declared.

what you seewhat it means
27 callshow many exchanges with that peer are STILL IN THE RING. A recent window, never a lifetime total. 0 means "not in the last 64 events", never "never"
(capacity)the distinct operations seen, by name. A bare number is an opcode this shell has no name for
4 FAILEDof those calls, how many ended in anything other than a reply
(trace sink - its own traffic is never recorded)events always reads 0 calls: emissions to the ring are deliberately not traced, so this is the MOST used capability here, not an unused one
reply#119a send capability whose endpoint no live task owns - a return address, not a dependency. Counted below the tree rather than drawn in it
stopped at N edgesthe walk hit its bound; the graph is larger than the view

As records (| to grid), one row per EDGE:

columnwhat it holds
depth1 for a direct peer, 2 for a peer of that peer, and so on
parentthe service that holds the capability
peerthe service it points at
grantgrantable if the cap carries GRANT. That means EITHER a supervisor-wired peer OR a reply address - the two are indistinguishable from userspace, so the ambiguity is reported rather than guessed
calls / failedas above
opsspace-separated distinct operation names, or -

trace endpoints - the map from names to ids

gsh> trace endpoints
slot  name          endpoint  state      queue
0     supervisor    100       BlockRecv  0
1     events        102       BlockRecv  0
6     fs            112       BlockRecv  0

The inventory the other views assume you have. Ids reach you one at a time from caps <service> (as endpoint#N), from trace blocked's awaiting column and from trace deps' reply list - none of which answers "what endpoints exist", so trace endpoint <id> could not be used deliberately without first assembling this by hand.

columnwhat it holds
slotthe task's scheduler slot, as status shows it
namethe service
endpointthe id to pass to trace endpoint <id>
statethe task state - BlockRecv here means idle, waiting for work
queuemessages WAITING in that endpoint. Non-zero on an idle service means work is arriving faster than it drains

Only PRIMARY endpoints appear, because that is what the kernel reports per task. A reply-only mailbox has no name here - which is exactly why an unresolvable capability shows as reply#NNN in a dependency tree rather than as a service.

A record source, so it filters and pipes: trace endpoints | where name contains fs | to json.


trace endpoint <id> - what an endpoint is, and who can reach it

gsh> trace endpoint 116
endpoint 116 - owned by task 8 "reply-server" (BlockRecv)
held by:
holder     slot  rights
asker      9     send
supervisor 0     send grant

The inverse of deps: that one says who a service calls, this says who holds authority over a given endpoint. Three shapes of answer:

first linemeans
owned by task N "name" (state)a live service's endpoint
console_push (id 4) - a kernel resource, not an endpoint: it has no owning taskids 1-18 are the kernel's fixed resources (log_write, spawn, console_read, console_push, introspect, ... - AUTHORITIES in the SDK names them all). They have no owning task by design
NO LIVE OWNEReither the owning task died, or it is a REPLY-only endpoint. A task's reply mailbox is not its primary one, and only primaries are named here
columnwhat it holds
holdera live service holding a capability to this resource
slotthat holder's scheduler slot
rightsthe rights on ITS copy, spelled out: read write send recv grant revoke

no live task holds a capability to it is a complete answer, not a failure.


trace blocked - the whole point, in one screen (as PROPOSED; see the reference above for what shipped)

The sketch below is the original design. What shipped has the columns slot / name / blocked / awaiting / held_by (see trace blocked at the top) and no FOR duration: the kernel does not stamp when a task blocked (§9 question 4).

gsh> trace blocked
PID  NAME          STATE        WAITING ON              FOR
42   shell         BlockedCall  fs:7 gen=3              12ms
7    fs            BlockedCall  block-driver:4 gen=1     11ms
4    block-driver  BlockedCall  xhci:9 gen=2             10ms
9    xhci          BlockedRecv  (its own endpoint)       10ms

4 blocked. Longest chain: shell -> fs -> block-driver -> xhci (root: xhci, waiting on its own endpoint)

The last line is the answer to the question, and it is derived, not editorialised (rule 7): the chain is a fact about CALL_AWAIT_EP, and "root" is simply the end of the walk.

Two failure shapes it must name rather than hide:

  • A cycle (A awaits B, B awaits A - §8.9 says the kernel does not detect deadlock, so the utility reporting one is exactly right): print the cycle and stop, never loop.
  • A dead endpoint - the awaited endpoint's generation no longer matches, or liveness is Dead. That is a task about to be woken with ReplyDead, and saying so distinguishes "stuck" from "already losing".

trace task 42 / trace service fs (as PROPOSED; shipped as trace chain)

The same walk, rooted at one task, printed as the tree the requirement asks for. Two subcommands were proposed here, one per subject kind - they shipped as a single trace chain <name|slot>, because they printed identical output from identical code and naming them after the SUBJECT rather than the VIEW made trace service fs read oddly beside trace deps fs. The sketch below also shows a per-hop duration, which was not built: the kernel does not stamp when a task blocked, and adding that stamp is kernel growth for a diagnostic. See the reference section above for what it actually prints.

gsh> trace task 42
task 42 "shell"  BlockedCall 12ms
└─ awaiting fs:7 gen=3          (task 7 "fs", BlockedCall 11ms)
   └─ awaiting block-driver:4 gen=1   (task 4 "block-driver", BlockedCall 10ms)
      └─ awaiting xhci:9 gen=2        (task 9 "xhci", BlockedRecv 10ms)

A task that is not blocked prints one line saying so. That is a real answer, not an empty result: "it is running" is the correct response to "why is it not progressing".


8. Proposed order

  1. Mechanism A - trace blocked, trace chain. New kernel surface: two InspectKernel queries (awaited endpoint per slot; blocked-since per slot) plus one u64 per task. No ring, no switch, no cost. Answers the question.
  2. Use it. Find out whether B is needed. §26.2 - features are pulled into existence.
  3. Mechanism B, only if A proved insufficient, and only with the IPC benchmark showing the disabled path unchanged.

8a. As built (mechanism A)

What it cost, end to end:

LayerChange
ipc/routing.rsone safe reader, call_await_endpoint - no unsafe, ipc/ stays unsafe-free
task/scheduler.rsTaskStatRaw.endpoint, populated from a value task_stat already loaded for queue_depth
syscall/dispatch.rstwo match arms (queries 24, 25), INTROSPECT-gated by falling outside the ungated list
sdktwo wrappers
shellcmd_trace + completion + NO_PATH_CMDS

No new kernel state, no new kernel behaviour, no switch, and nothing on the IPC fast path.

The enforcement layer caught the queries and refused the build until they were pinned with a written answer to "why isn't this a service?" - which is the guard working as intended, and the answer is now in COMMANDMENTS.baseline.toml beside the pin. The short form: query 25 passes on impossibility (a task that is stuck is precisely the one that cannot tell a service it is stuck, so the kernel is the only possible source), and 24 is disclosure of a read task_stat already performs.

Verified in QEMU (osdev test shell, 136/0/2):

gsh> trace blocked
no task is blocked on another task.
gsh> trace chain shell
task 7 "shell" Running (-)
   root: awaits no task - it is runnable, so the chain is not stuck here
gsh> trace chain nosuchsvc
trace chain: no live task named 'nosuchsvc'

The multi-hop walk is proven - osdev test trace, 10/10. A healthy machine has nothing blocked, so that test builds a chain that IS stuck: the reply-test build already has asker send reply-server a request it is built never to answer, then block in a synchronous Call. No new service and no new build feature were needed - the situation already existed, and had only ever been used to prove the death-wake. Read live, mid-block:

gsh> trace blocked
slot  name   blocked  awaiting  held_by
9     asker  call     116       reply-server

gsh> trace chain asker
task 9 "asker" BlockRecv (call)
   awaiting endpoint 116
   `- task 8 "reply-server" BlockRecv (recv)
      root: awaits no task - blocked on its own endpoint, waiting for work

That is the question answered in three lines: asker is stuck in a call on endpoint 116, reply-server holds it, and reply-server is not waiting on anyone - it is sitting on its own endpoint. The endpoint -> owner resolution across tasks is the part one hop can never demonstrate, and it is what this test exists for. reply-dead still passes 5/5, so reading a stuck chain changes nothing about it.

What trace deps reads, and what it cannot

   contract (fs.toml)          supervisor spawn request       fs's LIVE cap table
   ipc_send = [...]     --->   send peers (IMAGES row) --->   [ SEND -> ep 42 ]
        |                            |                        [ SEND -> ep 51 ]
        |                            |                               |
   host-only:                  inside the supervisor:           task_caps(slot)
   reconciled at build         no query answers it                    |
   time, never ships                                                  v
                                                              trace deps  <-- reads THIS

trace deps walks the right-hand column: capabilities a service is holding right now, each endpoint resolved back to its owning task. The left-hand columns are what "declared" would mean, and neither is reachable today: the contract never ships, and the supervisor, which holds each service's send peers in its spawn table (CLAUDE.md 14.1), has no query that answers with them.

trace deps and trace endpoint: authority, in both directions

events ipc answers "what happened"; trace chain answers "what is stuck now". Neither answers the question the capability model makes most worth asking - who can reach whom - so two views do:

gsh> trace deps net-stack            gsh> trace endpoint 4
net-stack                            endpoint 4 - NO LIVE OWNER.
|-- nic-driver                       held by:
`-- time                             holder  slot  rights
    `-- fs                           xhci    8     write
        |-- block-driver
        `-- events  (trace sink - its own traffic is never recorded)
(3 reply address(es) not shown: reply#119 reply#107 - `trace endpoint <id>` resolves one)

(An early capture. Today id 4 answers console_push (id 4) - a kernel resource, not an endpoint, and the closing line is a count and the pipe that lists them, (3 reply address(es) hidden - ...), as in the reference section above.)

Both are built from the LIVE capability table (task_caps), not from a contract: a row is not "the toml says it may call block-driver", it is "this service is holding, right now, a SEND capability whose endpoint block-driver owns". That is 26.9 - authority inspectable as it actually stands.

A tree and a record stream are the same data. The table holds one row per EDGE (depth, parent, peer, grant, calls, failed, ops), which is what a tree IS, so the console draws it and a pipe gets the rows - trace deps shell | where peer=fs, | to json, | to grid. to grid was added for this: the grid was every record source's console rendering but could not be ASKED for, so a producer that draws something else had no way to offer the table.

Four bugs surfaced only because the tree draws structure a table hides, and each is a rule:

  1. A global "seen" set flattened it. block-driver is a direct peer of the shell AND a peer of fs, so once drawn it was never drawn again - fs 24 calls had nothing beneath it. Distinctness of children and absence of cycles are DIFFERENT problems: dedupe the edge, guard the path.
  2. Edges were duplicated. time is reached twice (directly and via net-stack) and each visit added its own time -> fs row; the renderer, which finds children by parent NAME, then drew both under every occurrence. The edge itself must be unique.
  3. The cycle guard silently failed. A short name written over a longer one in an ancestor slot left "stack" trailing, so the match missed and the cycle expanded one level further.
  4. Filtering SEND|GRANT deleted real wiring. A reply capability carries GRANT, so filtering it hid return addresses - and also hid every peer the SUPERVISOR provides at spawn, which carries GRANT because the supervisor must be able to re-delegate it. net-stack showed no nic-driver dependency at all, immediately after a ping that demonstrably used it. The two are indistinguishable from userspace, so they are included and the ambiguity is REPORTED (26.7): dropping them hides real dependencies, including them silently shows return addresses as dependencies, and an honest ambiguity beats a confident wrong answer in either direction.

What it deliberately does NOT show: DECLARED dependencies. A service's send peers live in the supervisor's spawn table, the row it sends the kernel in each spawn request (CLAUDE.md 13.6; the .toml never ships - contract_check.py reconciles it at build time). The service catalogue has moved into the supervisor, so a trace deps <svc> declared would be a supervisor query, service-to-service with the kernel uninvolved - and no such query exists yet, so the view is not built.

9. Open questions for review

  1. Is mechanism B wanted at all, given A answers the stated question? B is where all the kernel growth is. ANSWERED: yes, and with zero kernel growth - it is a service (47_events.md §8b). The question that resolved it was "why can't the trace be a service?", and every objection to B was really an objection to B being in the kernel.
  2. op/first_byte (47_events.md §7) - record it, or refuse it on principle? ANSWERED: the dilemma dissolved. The recorder is the service that owns the protocol, and a service is entitled to interpret its own messages. Nothing privileges a convention inside the kernel, because the kernel is not involved.
  3. Ring size, if built. ANSWERED: 192 events (~4 KiB) in events. Sized for "what just happened", which is the question a stalled chain asks - deliberately not for "what happened a minute ago", which needs either a much larger ring or filtering at the emitter, and filtering-in-the-middle is the first step toward putting a programmable VM where it does not belong. If longer history is ever wanted, the honest answer is a bigger arena HERE, costing one service more memory and the kernel nothing.
  4. FOR (blocked duration) needs a per-task blocked-since stamp. One u64 per task, written on block and cleared on wake - two stores on a path that already does several. Acceptable, or is the duration column not worth it?

The instrument must not hang on what it measures

Every ask to events is bounded at 5 seconds and escapable with q, advertising [q] quit once the wait lingers past 2 s (trace_ask, through the command's watch, gs::call::request_watched, or gs::call::request_within_notice between commands; a timeout is retried twice, reacquiring events by name between attempts). That matters more here than for an ordinary command: events failures is what you reach for WHEN something is wedged, so an instrument that can itself wedge takes the prompt with it and leaves you with a power button. It used to use a bare request_with_reply, which parks the shell inside the syscall where it cannot read the keyboard (backlog/fixed/29, conventions rule 10).

events - what the sink RECORDED

Six views over the events service: the 192-event IPC trace ring, the metric table, the log window, and capturing any of it to disk. Everything here is history the sink kept - ask twice and you get the same answer, until the window moves.

For LIVE kernel state - what is stuck right now, who can reach whom - see utilities/46_trace.md.

Status: BUILT and QEMU-verified. The ring, the metric table, the log window, and events persist with its recorder service.

  events ipc                    what happened - the ring, paged and pipeable
  events failures               the same ring, only the failures
  events log [n]                the last n log lines the sink kept (default 20)
  events log boot               the kernel's fixed copy of the boot - never wraps
  events metrics                published samples: owner, metric, value, age
  events status                 ring size, recorded, dropped
  events persist start|stop|status   capture the log to disk

events log boot - the boot, kept by the kernel

events log boot                  the first 32 KiB this machine ever logged, then a line saying how much
events log boot | match xhci     the lines that name xhci
events log boot | count          how many lines the boot was

events log is the SINK's window: the last lines a service sent, which on a busy machine has moved past the boot within minutes. The boot is what a reader most often wants back - what the machine found as it came up, and what each driver said about it - and on a machine with no serial cable it was gone.

So the kernel keeps it. Beside its log ring, which wraps and which nothing reads, it keeps a fixed copy of the first 32 KiB ever logged, kernel lines and service lines alike, in the order they were written. It fills once and then never changes. This view copies it out (InspectKernel query 27, INTROSPECT); it is never drained, so reading it twice gives the same answer, and it changes nothing about how logging works (CLAUDE.md 11.4, 2026-10-08).

A full record says so. A T630 reaches its prompt in about 18 KiB and the record holds the driver bring-up after it; a machine that logs more stops at 32 KiB and the closing line says the record is full and that later lines are on serial. It does not pretend to have them.

Text, not records. The record is 32 KiB and a table holds 4, so it pipes as lines, to match and count. The pipe holds 16 KiB; a full record piped is cut there, and the pipe says it was. The closing sentence goes to the console, never into the pipe.

No timestamps. The kernel keeps bytes, not times. A serial capture's timestamps are the terminal program's, not the machine's.


events persist - keeping a capture when the screen cannot

events persist start /log.txt                  everything, the default budget
events persist start /log.txt 7d               target a week
events persist start /log.txt 64MiB            an exact disk budget
events persist start /log.txt fs 7d sticky     only fs, a week, resumes after a reboot
events persist stop
events persist status

Every budget carries a unit - h/d/w for a duration, KiB/MiB/GiB for a size. Nothing is bare, because a bare number would have to mean megabytes or minutes by convention and 16m cannot be read as either without guessing. That also removes the last heuristic from the parser: a token without a unit is unambiguously a service name. MB is REFUSED rather than treated as MiB - they differ by 4.8%, and quietly giving less than was asked for is a small lie found late.

A duration is a target, never a promise. Converting 7d to bytes needs a fill rate, and that is a prediction about how chatty the machine will be - which the machine decides. So status reports the MEASURED rate and what the budget actually covers at it:

> events persist status
state      covers  kib_day  capacity_kib  lines  rotations  lost  path
recording  ~2d     11400    16384         842    0          0     /log.txt

Ask for a week on a box four times chattier than assumed and this says ~2d, rather than letting you discover it when the log you needed had already rotated away. covers reports the guaranteed FLOOR, not the best case, because a rotation discards a whole piece.

Rotation renames, so /log.txt is always the newest. The previous piece is /log.txt.1. An earlier version alternated between the two names, which meant the current file depended on a rotation count only status could tell you - the file you wanted was a coin toss. Renames are metadata only, so rotating costs no data movement however big a piece is.

Written by recorder, not by events, and that split is the design rather than tidiness. A file write BLOCKS on a reply from fs; events is a single-threaded recv loop, so while it waited it would stop draining its endpoint and drop the very events worth capturing. On a sick disk that is the full deadline, repeatedly - events would go blind exactly when it matters. recorder blocks instead, and nothing depends on recorder, so a stalled disk costs the capture and leaves the volatile window readable.

Spawned on demand, never at boot, and not restarted on death - a respawned recorder would not know its target and would be alive writing nothing while status said "running". The file opens with a header and closes with a footer, so one without a footer says it died.

Bounded, and not by counting. fs allocates a file's whole extent up front, so the size is fixed when the capture starts; two files rotate, so total disk use is twice that, forever. A forgotten capture cannot fill a disk. It ROTATES rather than stopping because stopping keeps the wrong half - a fixed file that stops when full preserves the start of a session and discards the crash at the end.

Reading it back. The file is written as owner: text, one line each, so read /log.txt shows a log. It is NOT records, and that is a deliberate choice rather than an omission: the shell pipe truncates a producer at 16 KiB (CAP_MAX) and every sink clips at 4 KiB, so a multi-megabyte capture cannot survive a pipeline whatever format it is in. read is the tool that will actually consume it, so the file is shaped for read. For records, use the LIVE window - events log | to json - which is small enough to pipe.

It PREPARES before it records, and status says so. The extent has to be made readable before a line can go into it, and on slow storage that takes a while - filling 4 MiB over the Pi 4's USB stick took longer than a caller was willing to wait, and the capture never began. So start allocates and answers AT ONCE, and the filling happens in the recorder's own loop:

> events persist start /log.txt 8MiB
> events persist status
state      covers  kib_day  capacity_kib  ...
preparing  -       0        8192
...a moment later...
recording  ~2d     11400    8192

Blocking a caller on an unbounded amount of device I/O was the defect, not the size - it would have bitten again on any slower medium. Nothing waits on recorder, so a slow disk delays only the capture.

Zero-filled on creation, and it has to be. OP_WRITE_NEW allocates the extent but writes no data blocks, so everything past the last chunk written carried a stored CRC of zero and fs refused the whole file - read returned storage error and the capture was unretrievable. Every file is written through once at creation, which is the pause you see when a capture starts or rotates, and the reason the default size is modest.

sticky records the capture in /system/services/events/sticky, a plain line the shell reads at the next boot and resumes unattended, announcing it. Plain text on purpose: read /system/services/events/sticky shows exactly what will happen, which is the difference between a setting and a surprise. An explicit stop deletes it, so the one command meaning "enough" is not the one that fails to take. Pinned by osdev test sticky, which is the only two-boot test here - a setting that survives a reboot cannot be proved inside one boot.

events log - what was printed, after it has scrolled away

This is not the console's scrollback, and the difference is the point. The console retains lines that scroll off its screen (docs/console-service.md §10), but a SERVICE log never reaches the console at all: ctx.log() writes the kernel ring and serial directly (CLAUDE.md §11.4), which is what keeps logging alive when everything above the kernel is dead. So on a Pi wired to a TV, with no serial attached, events log is the only way to see what a service said - scrolled away or never displayed in the first place.

gsh> events log 4
fs: disk capacity = 0 sectors (0 MiB)
fs: serving file API
nic-driver: dwc2 answered op 0x01 while we asked 0x10 - not our reply (3 mismatched...)
block-driver: capacity 0 - the USB host service replied 1 byte(s), not a capacity
events: 13 line(s) held (4 shown). This is a COPY - serial has the authoritative record, including everything printed before `events` started. Filter with `events log | where owner=<service>`.

This is a COPY, and never the authoritative record. ctx.log() performs its syscall first and unconditionally - the kernel's 16 KiB ring and serial are written whether or not events is alive, reachable, or has ever existed - and only then offers a duplicate for later querying. Re-pointing logs AT the service would make observing a failure depend on a service that can fail, which is CLAUDE.md §15's storage argument one layer up. Adding a copy takes nothing away from the floor.

Three consequences, stated rather than left to be discovered:

  • Nothing printed before events started is here. Those lines are in the kernel ring, which no syscall exposes to userspace. Serial has them, and the kernel's fixed copy of the first 32 KiB is events log boot (above).
  • The window is 8 KiB and wraps. The footer says how many lines are held and how many are shown; it does not say whether older lines were overwritten, though the sink's reply carries that.
  • A killed events loses the scrollback and no log output. That is the design working: the tier beneath it survives anything, including a panic that halts every core.

It prints a screenful by default rather than everything it holds - about 3 KB on a booted machine is more than anyone reads. events log 100 asks for more.

It is a RECORD SOURCE, like every other view here - owner and text:

gsh> events log | where owner=fs | to json
[
  {"owner": "fs", "text": "disk capacity = 0 sectors (0 MiB)"},
  {"owner": "fs", "text": "serving file API"}
]

The owner is a FIELD, which is what makes where owner=dwc2 match a service that writes its lines as dwc2-svc: - a text-prefix match could not, and guessing where a name ends inside a string is exactly what a field removes. The name is stripped from text for the same reason: owner already holds it, and stating one fact twice is what fields exist to stop.

Printing is a rendering, not the format. events log shows owner: text because a log should read like a log and a 240-byte column would be wider than any screen; piped, it is rows. Same data, two renderings - what events ipc already does.

This is a rule rather than a convenience, and it was ratified after being broken: the log was first built as text, and filtering it by service then needed a bespoke argument hand-rolled in the shell, duplicating where and getting it wrong on the first run. docs/observability.md §9a.

events metrics - what services are counting

The ring answers "what just happened". events metrics answers "how much, so far", and it reads the other half of the same service - the metric table in events.

gsh> events metrics
owner   metric           value  age_s
fs      requests         32     1
fs      blk.outages      0      1
events  ring.recorded    139    0
events  ring.dropped     0      0
events  metrics.held     6      0
events  metrics.refused  0      0
columnmeaning
ownerthe service that published it, as that service declared itself (trace_as). Part of the identity, not a label beside it: two services may both publish requests and they are different numbers.
metricthe name the publisher chose, up to 20 bytes. Longer is truncated, and the SDK says so once.
valuethe LAST value published. A metric is a SET, not an increment - the owning service holds the counter and publishes what it currently reads.
age_sseconds since the sink accepted it. Read this column.

Why age_s is not decoration. events keeps a sample after its publisher dies - which is the point, because the final value of a service that crashed is the one useful thing left to learn from it. The cost of that is a number frozen at the moment of death being indistinguishable from a number being maintained right now. The age is what tells them apart, and an instrument that cannot is worse than none.

A publisher chooses its own interval, and should not publish on every operation: fs publishes every 32 requests, because a metric costing an IPC send per request would double the traffic it exists to measure. So a value can lag by up to one interval on a quiet machine, and the age shows the lag rather than implying there is none.

msgs.received comes free, from the SDK. Every service that holds the cap publishes it without a line of its own code, because it is counted in the SDK's three receive paths - the same place trace emission lives, and for the same reason: it is the identical counter in every service, and ten hand-placed copies is ten chances to place it wrong.

It publishes on the FIRST message as well as every 64th, which matters more than it sounds. Publishing only on the interval left any service under 64 messages with no row at all, and no row is indistinguishable from dead - which is the one question this metric exists to answer. Paired with age_s it separates the two faults nothing else in the system separates: a block-driver still receiving and failing, versus one whose count last moved forty seconds ago.

There is a THIRD trigger, for the same reason: the first message after the sink itself comes back. events holds its table in memory, so a restart starts it empty with no knowledge of any service, and waiting out the interval leaves that service unrepresented for up to 63 more messages. On one that only receives when someone else does I/O - block-driver waits on fs - that is however long the machine stays quiet, and it read as dead the whole time. Republishing on reacquire is the metric half of "reacquire and re-establish" (CLAUDE.md 14.3).

Attribution depends on ctx.trace_as, and silently failed without it. A service that never declares its name publishes under a BLANK owner - and since the key is (owner, metric), every undeclared service collides into one row with its counters interleaving. That is exactly how it was found: a single msgs.received 1920 belonging to nobody, which turned out to be console plus nine others. Every internal service now declares itself, an undeclared one renders as ? rather than blank, and the view warns underneath when a ? row exists.

One name is too long and is reported rather than hidden: PEER_LEN is 12 bytes (sized for block-driver) and hw-enumerator is 13, so it reads hw-enumerato and the SDK says so once at start-up. Widening the field was the alternative and was rejected on cost - the dump is bounded by one 4 KiB message, so a 16-byte field drops this view from 110 events per screen to 95, and trading real scrollback for four characters of a name is the wrong way round.

It is a record source, so it filters and formats like events ipc:

events metrics | where owner contains fs
events metrics | select metric,value | to json

What is NOT in here, and cannot be: events reporting its own death. Its own rows are published by writing straight into the table it already owns, never by sending itself a message - a send is itself a reportable event, so a self-emit over IPC would feed the ring from the ring. That local write works right up until the process stops, and then nothing in it can say so. The supervisor's death notification and the kernel's unconditional serial write are what report that, and both sit beneath this service rather than inside it (docs/observability.md §9).

trace task <slot> and trace service <name> were folded into trace chain: they printed identical output from identical code while being named after the SUBJECT KIND, which made them look like two things and made trace service fs read oddly beside trace deps fs. The argument disambiguates itself - digits are a slot, anything else is a name.

events ipc and events failures - what happened

seq  sec  caller  peer          op        outcome
9    0    shell   fs            write     REPLY
10   2    shell   net-stack     1         TIMEOUT
6    2    fs      block-driver  capacity  REPLY

One row per completed exchange, oldest first, newest last. events failures is the same ring filtered to the failures.

columnwhat the NUMBER or word actually is
seqthe CALLER'S OWN event counter, starting at 0 when that service started. Not global - a mixed dump interleaves several sequences and can look unsorted; it is not, rows are in ring order. A GAP is the one loss nothing else can see: an event whose send failed on a full queue never reached the ring, so events status reports 0 dropped while events are being lost
secwhen the RING recorded the row, counted from the oldest row shown, in whole seconds. NOT a latency, and not when the call was made. It exists to show a HOLE - forty rows in one second, then a four-second gap, and that gap is the stall
callerwho made the call, as that service declared itself (ctx.trace_as). ? means a service traced without declaring a name
peerwho was called, by name. The emitter knew it, so no lookup is involved
opthe OPERATION, by name (read, write, capacity). A bare number is an opcode this shell has no table for - net-stack's protocol, for instance
outcomehow it ended - see below

Outcomes, and what each one tells you to do:

outcomemeans
REPLYthe peer answered
TIMEOUTthe peer did not answer within the caller's deadline. It is alive as far as the kernel knows
PEER_LOSTthe send failed, or the peer died while the call was outstanding (ReplyDead)
QUEUE_FULLthe peer is ALIVE and its queue is full. Congestion, not absence - answering it by reacquiring a capability that was never stale is a bug this project has already paid for once
ABORTEDthe user pressed q. Not a failure of anything

A request still IN FLIGHT is not here: one row is written per exchange, when it ends. The live view of something stuck is trace blocked.


events status - the ring itself

trace: ring 192 events; 2 recorded; 0 DROPPED (oldest overwritten before being read)
trace: the ring lives in the `events` service - the kernel records nothing.
numberwhat it counts
ring N eventscapacity. Fixed, no heap
N recordedevents ever ACCEPTED by the sink since it last started. An events restart resets it
N DROPPEDevents overwritten before anyone read them. This cannot see the other kind of loss - an event whose try_send failed never arrived, so it is invisible here and shows only as a gap in a caller's seq

Who is traced at all

Only exchanges made through the SDK's request/reply calls, by services whose contract grants ipc_send = ["events"]. Today that is thirteen services: block-driver, console, control, dwc2, ehci, fs, hw-enumerator, net-stack, nic-driver, recorder, shell, time, xhci. (events writes its own metric rows into its own table, as described above.) It was fs and shell alone when the ring shipped, and the difference is the point of trace_as: a service that does not declare its name publishes under a BLANK owner, and since the key is (owner, name) every undeclared service collides into one row. Tracing is authority: visible in caps <service>, revocable, and absent by default - not a global switch. A service holding no such capability emits nothing, at a cost of one relaxed atomic load.


6. Mechanism B: the event ring, if it is built

Event - fixed 32 bytes, no allocation, nothing variable-length:

FieldWidthNote
timestampu64the monotonic clock observe already uses (portable across arches)
senderu16task slot
receiveru16task slot, or 0 for none
endpointu32endpoint id
generationu32the restart-identity the requirement asks for - nvme:9 vs nvme:15 falls out
message_idu32kernel-assigned, monotonic, wraps loudly
eventu8SEND / RECEIVE / REPLY / BLOCKED / ENDPOINT_DEAD
opu8byte 0 of the message, opaque - see §7

Bounded (§26.6): a fixed ring in .bss, sized once. Full = drop the oldest, count the drop, and events status reports the count. A silent drop is exactly the bug just fixed in the x86 input ring; this must not repeat it (invariant 12).

Near-zero when off: one Relaxed load of an AtomicBool at the routing point, branch not taken. This must be measured, not asserted (§20 - perf claims require a benchmark). The IPC fast path is the one place this project pays for cost, so B1/B2 (IPC round-trip latency) must be run with tracing off and compared to baseline. If it is not free, it does not ship.

trace tree <message-id> is the expensive one, and it needs saying plainly: linking shell -> fs to the fs -> block call it caused requires the parent id to propagate - the SDK must carry "the request I am currently handling" and stamp it on outgoing calls. That is a change to every service's request path, and it is the same problem docs/net-tags-design.md already describes for net-stack <-> nic-driver. Proposed: out of v1, and re-read that design first if it is ever picked up, because doing it twice differently would be worse than not doing it.

Without propagation, events ipc still gives a time-ordered log per endpoint, and the requirement's failure/recovery example works, because that one is a time sequence, not a tree:

10:41:02.126 xhci:9   ENDPOINT_DEAD
10:41:02.127 supervisor -> restart xhci
10:41:02.130 xhci:15  READY
10:41:02.131 block-driver:4  req=55 PEER_LOST

xhci:9 vs xhci:15 is the generation, which the ring records for free.


7. The one line I am not certain of: recording op (RESOLVED - and the premise was WRONG)

As built: the uncertainty below was justified and its factual premise was false. Both busy protocols put something else at byte 0: shell -> fs and fs -> block-driver each PREPEND a one-byte correlation tag (added so replies are not matched by arrival order, after fs accepted one block's data as another's). So a trace recording byte 0 showed REQUEST IDS in a column labelled "op" - 183, 177, 212 against block-driver.

The answer was not to rename the column after the byte. It is that the service which speaks the protocol declares where its opcode lives - ctx.trace_op_at("fs", 1), once at startup. The SDK is generic across peers and cannot know; the kernel may not interpret a payload at all; the service can, which is where this design already puts every other piece of protocol knowledge. The shell then renders the opcode by NAME (read, write, capacity), because a number needs a lookup table the reader does not have.

The section below is kept as the reasoning that led there, per 1 (an amendment is ratified history).

op is byte 0 of the message. Every service protocol in this tree happens to put its opcode there (fs, block-driver, the block IPC protocol). Recording it is what makes events ipc readable rather than a wall of endpoint numbers.

The case for: the kernel stores a byte and attaches no meaning. It is exactly as opaque as the ResourceId badge in §7.10, where the kernel routes something whose meaning only the owning service knows.

The case against: "byte 0 is the opcode" is a convention, and the kernel recording it privileges that convention - which is the thin end of understanding a protocol. A service that puts something else in byte 0 gets a misleading trace.

Proposed: record it, name the field first_byte in the kernel and only call it op in the utility, and state in the code comment that the kernel attaches no meaning to it. If that reads as sophistry on review, drop it - events ipc is still useful without it, and the constitution is worth more than a column.


8b. As built (mechanism B) - the ring is a SERVICE, and the kernel gained nothing

Section 6 above designed the ring inside the kernel and asked whether that was worth it. It was not, and the answer arrived from the question "why can't the trace be a service?". It can, and the service version is strictly better - so what shipped is not the design in §6. That section is kept as ratified history: it is the reasoning that had to be worked through to see why it was wrong.

What changed, and why each change is an improvement rather than a compromise:

§6 proposed (in the kernel)As built (in userspace)
ring in kernel .bssa fixed array in the events service
sender/receiver task slotsthe peer's NAME, because the emitter knows it
endpoint + generationnot recorded - the name is what a reader wanted the endpoint to resolve to
kernel-assigned message_idnot assigned; see trace tree, still out of scope
a switch + AtomicBool on the IPC fast pathnothing on the IPC path at all
a control syscall to arm/read itordinary IPC to a service
first_byte, opaque to the kernel (§7's uncertainty)op, interpreted by the service that OWNS the protocol - so §7's dilemma simply does not arise

The peer is a name, and that is the whole reason this belongs out here. The kernel may not know what a message means (§4.4, §26.10), so a kernel ring could only ever have recorded endpoint 116, op 11 and left the reader to resolve it. The emitter called request_with_reply("fs", ...): it already holds the name, and putting the name in the event is what produces the symbolic output the requirement asked for. The constraint the constitution imposes and the feature the requirement wanted point the same way - which is usually the sign the design is right.

(The first attempt was a tracer service of its own. The enforcement layer refused it, correctly: the kernel holds a service_config per service, pinned as debt that may only shrink, so even a userspace ring would have cost ring 0 three lines. events is already in every one of those lists and its whole purpose is diagnostic data, so the ring there costs the kernel exactly zero.)

Layers touched:

LayerChange
sdk/rust/src/trace.rswire format (34 B: seq, at_s, caller[12], peer[12], op, kind), lazy one-time arming, per-peer opcode offset
sdk/rust/src/service_context.rstrace_emit; three emission points in request_with_reply
services/events/src/main.rsthe 192-event ring, dump + status replies
services/shell/src/main.rsevents ipc, events failures, events status
kernelnothing

Cost when not tracing: one Relaxed load, branch not taken - and nothing at the routing point, so B1/B2 cannot move. A service traces only if its contract granted it ipc_send = ["events"], so tracing is authority: visible in caps <service>, revocable, absent by default (§3.1). §6 asked for a benchmark before shipping a switch on the fast path; there is no switch and no fast-path write, so the thing that needed measuring does not exist.

Bounded and loud (§26.6, invariant 12): fixed ring, fixed events, no heap. Full = overwrite the oldest and count it; events status reports the count. Emission is try_send with the result discarded, so a full sink queue costs the emitter nothing and loses one event - the right trade on an observability path, and the opposite of the one made on a correctness path. An observer must never be able to slow, block or break the thing it observes.

The reader is not recorded. A call to events itself is never traced: events ipc reaches the ring by asking the service that holds it, so tracing those calls would fill the ring with the reader's own questions, two per dump, pushing out the traffic the reader came to see. Every other peer is still recorded and events status still counts every accepted event, so nothing is hidden.

Time is shown relative to the oldest event in the dump. The stored value is an epoch second; the absolute number says nothing on its own, and the gap between events is what a stall looks like.

Verified in QEMU (osdev test shell, 140/0/2 - 10 of them trace):

gsh> events status
trace: ring 192 events; 2 recorded; 0 DROPPED (oldest overwritten before being read)
trace: the ring lives in the `events` service - the kernel records nothing.
gsh> events ipc
seq  t+s  peer       op  event
0    0    net-stack  2   REQUEST
1    0    net-stack  2   REPLY

Real traffic, named, with no kernel involvement of any kind.

What hardware caught that QEMU did not: the ring was empty

The first cut instrumented request_with_reply. On the Wyse, after a selfcheck and two dir commands, events ipc still said no events recorded - while fs was demonstrably answering.

The SDK has eight request/reply variants, each an independent implementation, and the shell talks to fs through _abortable and _deadline - never the plain one. So nothing was ever emitted for the traffic that matters. QEMU had not caught it because the single event it did record came from the one net-stack call site that happens to use the plain variant, and one green row looked like success.

The fix is a wrapper per variant rather than an edit inside each body: eight functions with several early returns inside a wait loop each, and a wrapper cannot miss an exit because the value returned IS the outcome. All eight, not the busy ones - partial instrumentation is worse than none, because events ipc then shows SOME traffic and silently omits the rest with nothing on screen saying which, which is a silent gap in the instrument built to prevent silent gaps (26.4, invariant 12).

Two event kinds fell out of doing it completely, because two of the variants already distinguish outcomes the original four kinds could not express:

  • QUEUE_FULL - the peer is ALIVE and its queue is full. Congestion, not absence. Recording that as a lost peer is a bug this project has already paid for once (net-stack reacquiring a capability that was never stale, backing DHCP off 34 s on a healthy link), so the ring keeps them apart.
  • ABORTED - the user pressed q. Not a failure of anything, and recorded so a gap in a chain is explained rather than mysterious.

events failures shows QUEUE_FULL (a request that never arrived) and not ABORTED (a change of mind).

What the SUITE caught that neither had: the observer was stealing the shell's core

Instrumenting all eight variants took osdev test files from 222/0 to 213/9 - tab completion, move and find timing out, and Enter keystrokes being lost so commands ran together. Four experiments, in order, because the first three theories were all wrong:

ExperimentResultRuled out
wrappers + tracing213/9-
wrappers marked #[inline]212/10the extra frame and the 4 KiB Message move
wrappers with the trace calls removed222/0the wrapper structure itself
the clock read removed from emitter AND sink213/9the CMOS RTC read
events moved off core 0222/0- the answer

Every trace event WAKES the sink, and the sink was on core 0 with the shell. Two events per fs request preempted the shell twice per round trip; the paths that do many round trips blew their window, and the shell was descheduled often enough to stop draining the console.

Three things changed as a result, and each is a rule worth keeping:

  1. The sink does not live on the interactive core. ARM had already moved events off core 0 for a different reason (keeping the serial writer away from the microframe-timed USB driver); x86 now needs it for this one. An unavailable preferred core falls back to round-robin rather than failing the spawn, so a machine with fewer cores still gets its events.
  2. One event per exchange, not two. A REQUEST plus an outcome doubled the traffic through the sink's single 16-deep endpoint. Every exchange still produces exactly one event carrying its fate, so nothing is lost but the duplicate. A request still IN FLIGHT is therefore not in the ring - and that is the one question the ring was never the right instrument for: trace blocked reads it from the kernel, live (mechanism A).
  3. The sink stamps the time, from a cached clock. epoch_secs_monotonic is a CMOS RTC read on x86 - wait_update_clear can spin ~1 ms before seven port-I/O reads - so per-event it would cap the sink near a thousand events a second. events reads the cycle counter per event (one instruction) and refreshes the seconds only when one has actually elapsed.

The reader also retries a busy sink three times before reporting it, because a full queue is congestion, not absence, and saying "unavailable" about a service that is alive and busy is a lie the same size as any other silent fallback.

seq is the emitter's own counter, not a global one, so a mixed dump interleaves several sequences and looks unsorted. It is not - rows are in ring order, oldest first. The column earns its place by making one service's dropped events visible as a gap in its own numbering.

A row is an EDGE, not a fact: the caller

peer alone answers "who was called" and leaves "by whom" to inference - and that inference only works while exactly two services hold trace authority. So an event carries the CALLER too, and a dump reads as a call graph:

seq  sec  caller  peer          op   outcome
9    0    shell   fs            4    REPLY
10   2    shell   net-stack     1    TIMEOUT
6    2    fs      block-driver  183  REPLY

The caller is self-declared (gs::trace::as_name(&ctx, "fs"), once at startup), and that deserves the argument rather than an apology:

  • A service cannot ask what it is called. There is no name in its context page and no query for one, because identity is not ambient (3.1). This is the capability model being consistent, not a gap.
  • The kernel does know, unforgeably: every syscall send stamps Message.sender_ep, the sender's primary endpoint. It exists for reply-matching and its comment ends "Kernel-internal: never crosses to userspace ... so no ABI change". Surfacing it would grow the syscall surface for a diagnostic, which is not a trade this project makes.
  • The obvious objection - a self-declared name is a claim, not a fact - does not survive contact with what the event already is. A service holding ipc_send = ["events"] can already write any peer and any outcome it likes, because the whole event is its testimony. caller is exactly as trustworthy as the two fields beside it: as trustworthy as the service you granted trace authority to. It opens nothing.

A service that never declares itself reads ?, which is the honest answer rather than a guess.

Reading it: pages, pipes, and a legend

events ipc is a record source, like status or dir - one producer feeding three uses, rather than a printer plus a serialiser that drift apart:

  • Console: a grid, with a seven-line legend above it.
  • Taller than the screen: it pages, with the shared pager's keys (arrows, space, PgUp/PgDn, Home/End, q). g/G and j/k are gone, and so is b - [b] background claims that letter. The pager was help-shaped - it called help_render_line directly - and is now given a render closure, so the one screenful-at-a-time reader in the system is shared instead of copied.
  • Piped: events ipc | to json, | to yaml, | where caller=fs, | where outcome=TIMEOUT, | count, | sort. The legend is console-only; a pipe carries records and nothing else.

A dump shows the newest 64 events - what a record Table holds - while the ring keeps 192. Asking for more only produced "result exceeded the record bound - truncated" on every run, and a bound announced every time is noise that trains you to ignore it. events status remains where the true ring size and drop count live.

The shape of the whole thing

Three questions, three sources, and the kernel is only in one of them.

                    WHAT IS STUCK NOW            WHO CAN REACH WHOM            WHAT HAPPENED
                    trace blocked                trace deps                    events ipc
                    trace chain                  trace endpoint                events failures
                          |                            |                             |
                          v                            v                             v
                  +---------------+           +----------------+          +--------------------+
                  | kernel, LIVE  |           | kernel, LIVE   |          | events, a RING     |
                  | who awaits    |           | capability     |          | of past exchanges  |
                  | which endpoint|           | tables         |          |                    |
                  +---------------+           +----------------+          +--------------------+
                    read-only,                  read-only,                  written by SERVICES,
                    2 queries                   existing syscall            never by the kernel

The kernel records nothing. It answers two questions it already knew the answer to (which endpoint a blocked task awaits, and which endpoint a task owns); everything historical is written by services into a service.

How an event gets into the ring

   fs                                     kernel                    events
   |                                        |                          |
   |-- request_with_reply("block-driver") ->|                          |
   |                                        |-- deliver -> block-driver|
   |<------------- reply ------------------ |                          |
   |                                                                   |
   |   ONE event per exchange, describing how it ENDED:                |
   |   seq, caller="fs", peer="block-driver", op, outcome=REPLY        |
   |-- try_send (fire and forget, never blocks) --------------------->|
   |                                                                   |-- stamp the time
   |                                                                   |-- store in a 192 ring
   |                                                                   |   (full = drop oldest,
   |                                                                   |    and COUNT it)
   |                                                                   |
   shell -- TRACE_OP_DUMP ------------------------------------------->|
   shell <- the newest 64 events ------------------------------------ |

Three properties fall out of that picture, and each is deliberate:

  • The kernel is not on the path at all. No ring in ring 0, no retention policy, no control syscall, and nothing added to the IPC fast path.
  • The emitter never waits. try_send and the result is discarded: a full sink costs the emitting service nothing and loses one event, which the ring counts and events status reports.
  • A service that holds no events send cap emits nothing, at the cost of one relaxed load. Tracing is authority, visible in caps <service> and revocable.

Why the sink is not on the interactive core

   BEFORE                                  AFTER
   core 0: shell  events                   core 0: shell
           ^^^^^  ^^^^^^                   core 2: events
           every event WAKES events,
           preempting the shell            the wake lands on another core
           twice per fs request

   osdev test files: 222/0 -> 213/9        osdev test files: 222/0
   tab completion timing out,              (nothing changed but the core number)
   Enter keystrokes lost

What the same hardware run says about the ring's limits

events failures came back empty after a 100-round chaos max-carnage - 559 kills. That is the design working, and worth stating rather than discovering later: events was itself killed 51 times, and a restarted events starts with an empty ring. Emission is try_send, so events aimed at a dead sink are lost too.

For the question this tool exists to answer - why is this task not progressing, right now - that is correct, and no ring in a restartable service can behave otherwise. For post-mortem of a storm that killed the recorder 51 times, it cannot help, and only persistence would - which the ring deliberately is not (see the events header on why history that survives nothing is the right call).

Still out of scope, unchanged: trace tree <message-id> needs parent-id propagation through every service's request path (§6, and docs/net-tags-design.md describes the same problem for net-stack <-> nic-driver). It is not made easier or harder by this; it is simply not built.


Utility: churn - keep the filesystem in constant motion, so a power cut lands somewhere

Status: Built. Verified in QEMU by osdev test fs-churn, which runs it, kills the machine mid-churn, and checks the volume comes back consistent.

churn <seconds>     write, rename and delete continuously, then stop and report
churn verify        after a cut: is any file a MIX of two writes?
churn tear          deliberately make one file a mix, to prove `verify` can say so
churn reset         remove /system/scratch/churn and its files

Press q to quit a run early.

churn tear - proving the detector can fire

churn verify has reported NONE torn on every hardware run there has ever been. That is the right answer, and it says nothing at all about whether the detector could say otherwise. A check never observed failing is not evidence.

Every other corruption test in this project - fs-corrupt, fs-scrub, fs-hostile - damages the disk host-side, before boot, which is not available on a machine whose disk you cannot take out. churn tear is the same proof reachable from the shell:

gsh> churn tear
churn tear: /system/scratch/churn/f0.bin now holds generation 12 from byte 0 and generation 49 from byte 508 - a MIX
churn tear: the blocks are well-formed and their CRCs are correct, so `drives check`
churn tear: and `drives scrub` will BOTH still report clean. Only `churn verify` sees it.
churn tear: nothing repairs this - detection is the guarantee. `churn reset` removes it.

Why it is called tear and not corrupt

Because it is not corruption, and the difference is the entire point. It writes a well-formed block of a different generation: every CRC is correct, the tree is correct, the accounting is correct. drives scrub reports 0 bad afterwards, truthfully. What the file has is a tear - a mix of two writes - which is the word this whole programme already uses (fs-tear, "torn write", and verify's own TORN). Calling it corruption would name it after a thing it deliberately is not.

Nothing repairs it, and drives check least of all

The obvious next sentence to write would be "run drives check to fix it". That would be wrong twice over: drives check cannot fix it, and it cannot even see it. It validates structure - tree, bitmap, free count - and the structure is immaculate.

That is not a limitation, it is the design. gsfs-carnage.md §6 puts repair explicitly out of scope: detection is the guarantee; silent repair of data whose correct value is unknown is the second half of the mission statement. The accounting can be rebuilt because the free bitmap is a derived view of one irreducible source, the tree (CLAUDE.md §26.4). File content has no second source, so there is nothing to rebuild it from. A filesystem that "fixed" this would be inventing bytes and calling them yours.

churn reset removes the torn file when you are finished looking at it.

It does not ask [y/N], deliberately

seal asks because there is no unseal; drives flash asks because it erases a disk. churn tear can only ever write to /system/scratch/churn/fN.bin - files churn itself created, in a directory churn reset exists to delete. It cannot reach anything else, so a confirm would be ceremony rather than a guard.

Pinned by osdev test fs-tear-detect, which asserts the interesting half: churn verify says TORN while drives check and drives scrub both still say clean. That is the demonstration that the two questions below are genuinely different, rather than the assertion of it.

The two questions after a power cut, and why both are needed

drives check validates structure - the tree, the bitmap, the CRCs. churn verify validates content.

They are not the same question, and the second had no answer until now. A file holding the first half of one write and the second half of another has perfectly valid block CRCs (each block was written whole), sits in a perfectly valid directory, and occupies correctly accounted blocks. Every check this project had would pass it.

How verify knows. Every byte churn writes encodes the generation that wrote it: byte[k] = (gen + k) mod 251. So one read decides it - take the generation from byte 0, and every later byte is predicted. The first disagreement is the tear point:

gsh> churn verify
churn verify: TORN - /system/scratch/churn/f7.bin diverges at byte 1216 of 3000 (block 2, offset 200 within it)
churn verify: 1 of 8 file(s) are TORN - each holds a mix of two writes. This is a DATA integrity failure, not a space-accounting one: report it with the serial log.

251 is the largest prime under 256, chosen so the pattern does not align with the 508-byte block payload. A tear on a block boundary therefore still lands mid-pattern and stays visible, rather than looking like a continuation.

It has been seen to fire. A file was corrupted mid-way host-side and the block CRC re-stamped, so the damage was structurally invisible. drives check reported 0 bad, consistent on that disk; verify named the exact byte. A detector nobody has watched fail is not evidence.

Why it exists

A power cut is only interesting if it lands somewhere interesting, and by default it does not.

Three real cuts on a Dell Wyse, pulled at the wall partway through a selfcheck run, produced three clean mounts and not one journal recovered line. Nothing was wrong: the window between a commit record becoming durable and the last home block being written is sub-millisecond, and a human with a plug samples a fraction of a percent of a run. The filesystem was never asked to recover, so nothing was learned about whether it can.

There are two ways to fix that, and they answer different questions:

what it doeswhat it answers
write /cutme.txt ... (the crash-window test build)holds ONE known window open for ten secondsproves the recovery path works
churn <seconds>runs thousands of transactions of every shape for as long as you asksearches for the windows nobody thought to aim at

A proof and a search. Maximum carnage wants both.

And churn runs on a shipping build, with no test feature compiled in. That matters more than it looks: a fault that appears only in a build nobody ships is a fault about that build.

What it actually does

A fixed rotation of eight files in /system/scratch/churn, rewritten in place, so the volume never fills however long it runs. Four payload sizes - 64, 500, 1200 and 3000 bytes - because the allocator takes different paths for a single data block, a partial extent and several blocks, and a one-size churn would only ever exercise one of them.

Every fifth iteration it renames the file and then deletes it, rather than only overwriting. Overwrites exercise one journal path; renames and deletes move directory entries and free extents, which is where the interesting interrupted states live. The torn-write sweep found its only real finding in a delete, not in a write.

The payload varies per iteration rather than being a block of one repeated byte, so if a torn write ever does surface the bytes say which iteration wrote them.

Using it

gsh> churn 60
churn: writing continuously for 60s - CUT THE POWER AT ANY POINT
churn: 1s elapsed, 47 writes
  [q] quit
churn: 2s elapsed, 95 writes
...
churn: done - 2840 writes, 355 renames, 355 deletes, 3456120 bytes, 0 refused

(Counts illustrative.) <seconds> is 1 to 3600. A run that finishes also names the two follow-ups (churn verify, drives check) and that /system/scratch/churn was left in place. A churn can run detached: background churn <seconds> (55_background.md).

Pull the plug at any point. On the way back up:

gsh> drives check

What the answer means, from the permitted-outcome table in docs/gsfs-carnage.md:

  • nothing was repaired - clean. The journal did its job, or the cut fell between transactions.
  • journal recovered N block(s) from an interrupted write in the boot log - the case worth hunting. The cut landed in the window and recovery ran.
  • REPAIRED ... held as used but are unreachable - a leak - permitted. Wasted blocks, reconciled, no data at risk. QEMU produces exactly this at three of twenty-one tear points on a delete.
  • REPAIRED ... marked free but are IN USE - stop and report. Blocks belonging to a live file were handed back; the next allocation would overwrite them. Never yet observed.
  • A volume that will not mount, or N bad - stop and send the serial log.

What it is not

Not a benchmark. It reports writes and bytes because they say whether it was actually working, not because the number means anything comparable. Throughput here is dominated by the serial log and the journal barriers, so a bigger number is not a better disk.

Not a soak test for the rest of the system. It hammers storage and nothing else. chaos is the utility for killing services; this one never kills anything.

It leaves /system/scratch/churn behind, deliberately - after a cut AND after a clean finish. churn reset removes it when you want it gone.

Not automatic, and the reasoning is worth stating because the opposite looks tidier. Those files are the evidence: after a cut they are what churn verify reads. A run that ends normally is indistinguishable from one somebody walked away from, so auto-deleting would mean coming back to find the thing you meant to examine gone. And the set is bounded at eight files rewritten in place, so nothing accumulates however many times it runs - there is no hygiene problem to solve. Deleting data because a command reached its end is the kind of silent helpfulness this project avoids.

No size or path parameters

Asked for, and declined for now (26.2 - a feature is pulled into existence, not anticipated).

The four sizes are not arbitrary: 64, 500, 1200 and 3000 bytes span a sub-block write, a single block, a partial extent and a multi-block extent, because the allocator takes a different path for each. A size parameter would let a run NARROW that, which is the opposite of what carnage wants by default.

A path parameter matters when multi-drive ships and not before. If a specific test needs either, it arrives then with a reason attached.

Conventions

Obeys utilities/0_conventions.md: churn help and churn version, a word-not-flag argument, raw facts without editorialising, and q aborts (rule 10) - a command that runs for a minute and cannot be interrupted is one the operator has to reboot out of. Bounded (26.6): a fixed file rotation and fixed stack buffers, no heap (26.6.1).

The wording is [q] quit, matching observe and ping - the house phrasing for a CONTINUOUS run - and now the ONLY form. One key, one word, because the letter IS the mnemonic, which is the whole reason the key is q. "abort" would have earned the letter a and never had it, yet the shell advertised (press q to abort) in six places and rule 9 mandated it. Both corrected. Unix makes the same association from the other direction: ctrl+C says "cancel".

Utility: docs - the manual

Status: Built + QEMU-verified (osdev test shell). A shell built-in, full-screen. Trails CLAUDE.md; does not amend it.


1. What it is

docs opens the manual: what GodspeedOS is, what the kernel does and does not do, and what you can rely on. It is a document you read; help is a reference you consult.

docs 0.4.0 - GodspeedOS

  What this is
  MISCIS - the whole of what the kernel does
  What you can rely on
  Seeing it work

[ 1-22 of 46 ]  [up/down] line  [PgUp/PgDn] page  [t] contents  [a] about  [/] find  [q] quit

2. Why it is not part of help

They were one command for about an hour, and that was the wrong shape.

help answers "what is the word for the thing I am trying to do" - consulted mid-task, many times a day. docs answers "what is this system" - read once, deliberately. Putting the second inside the first means help opens on prose when somebody wanted dir's byte-count word, and it makes help the place every explanatory thing accumulates. That is the dumping ground CLAUDE.md §4.4 and §26.2 exist to prevent, and the phrase that gave it away was "help can grow organically".

Split, each has one job. help's first screen names docs, so it is still found by typing the obvious thing.

One browser, two documents. The contents, the search, the keys and the pinned section line are parameterised over the document, not copied. A second implementation of a pager is how the first one went stale.

3. Keys

keydoes
arrowsscroll a line
PgUp / PgDn, spacea page
Home / Endthe ends
tcontents - then a digit jumps to that section
aabout: the boot banner, and this machine's live architecture
/find; n for the next match
q, Escleave

docs <word> opens with that find already run, so docs capabilities lands on the paragraph rather than at the top. help <word> does the same for the command list. This is not a second spelling of <util> help, which gives one command's detail: it is what man means, find this in the manual. The browser lists the commands, so reading one there and typing help <it> is an expectation the interface creates - and before this the argument was simply discarded, which made "you asked for something specific" and "here is the general thing" the same screen (§26.7). A word that matches nothing is reported as (no match); without that, a miss and a hit on the first line look identical, because both leave you at the top.

The section you are in is pinned at the top. That is the thing a scrollback buffer structurally cannot give you: scrolled into the middle of a document you would otherwise have no idea which part you were reading. It is the same reason trace still pages its own views, with a column header pinned.

4. [a] about is drawn from live state, deliberately

The architecture view asks the kernel what is running, on which core, and lists it. It is not a drawing.

A hand-drawn box diagram would be a second copy of CLAUDE.md §4.1, and a copy rots. Nine utilities had fallen out of help with nothing watching, in this same session; a picture of an idealised system would go the same way and nobody would notice, because a diagram cannot fail to compile. Derived from live state it cannot be wrong - and it is more useful, because it describes the machine in front of you rather than the one in the document.

The banner is include_str! of assets/godspeed-banner.txt, the same file the kernel prints at boot. One source; a second copy of eight lines of ASCII is still a second copy.

5. The conceptual half IS a copy, and is gated

§1's layering and the six MISCIS responsibilities do restate CLAUDE.md §4.3, because a manual that cannot describe the model is not a manual.

So it is checked. scripts/facts_check.py (help_philosophy_problems) derives the six from the constitution - using the same slice scripts/commandments.py uses, so there is one way to read that list - and fails if this text stops naming one. Removing "SMP routing" from the manual fails the build, which is what makes the copy affordable rather than merely tempting.

6. In a script it prints

depth > 0 - a script, run, assert or selfcheck - dumps the document and returns. A full-screen browser waiting for a keypress with nobody there does not degrade, it hangs the run. Same guard paginate carries, and the reason paging belongs to things you ask for.

7. Implementation

cmd_docs and help_browser in services/shell/src/main.rs; the document is the static DOCS table, the same HelpRow type HELP uses. Sections are derived from the table itself, so adding a Sec(...) puts it in the contents with nothing else to edit.

Utility: paginate - read long output a screenful at a time

Status: Built + QEMU-verified (osdev test files). A pipe-only sink in the shell. Trails CLAUDE.md; does not amend it.


1. What it is

<producer> | paginate shows a long stream one screenful at a time, with keys to scroll.

paginate 0.4.0 - read long output a screenful at a time (pipe stage)

usage:
  <producer> | paginate         page any long output - text or records
      e.g. read /long.txt | paginate
  dir /big | paginate           a record stream keeps its column header pinned while you scroll
      e.g. dir /big | paginate
                                arrows scroll a line; PgUp/PgDn and space move a page; Home/End jump; q quits
                                in a script or a capture it just prints - there is nobody to press a key
      e.g. run /s.gsh
  paginate version
  paginate help

It takes no arguments and must be the last stage: it reads the stream, it does not pass it on. dir | paginate | write /f.txt is refused rather than quietly doing one of the two things it might have meant.

2. Why it is a stage and not a mode

The obvious design is to build paging into the commands that produce a lot of output - dir, read, find, help. That design has a defect that only shows up later, and it is not a small one: a command that pages itself has to guess whether a human is watching, and the guess is wrong exactly when it matters.

  • dir /big | write /list.txt has a reader, but the reader is the shell.
  • run /nightly.gsh has no reader at all, which is often why it was started.
  • selfcheck runs 357 cases and nobody is pressing a key for any of them.

A built-in pager must therefore carry a guard at every call site, and the failure mode when a guard is missed is not a wrong character on screen - it is a run that hangs forever waiting for a keystroke nobody will type.

Asking for it cannot make that mistake. A pipe that captures does not contain the word paginate, so there is nothing to guard. And because it is a stage rather than a feature, every producer gets it at once - find, read, status, caps, events log, and whatever is added next - instead of a pager wired separately into each, which is the same fact stated six times waiting to disagree with itself (backlog/35).

It also composes, which a built-in pager never could:

dir /projects | where type=file | sort size | paginate

3. It still refuses to hang

paginate is safe to leave in a script. Two conditions must both hold before it waits for a key:

  • the shell is not inside a script, run, assert or selfcheck (depth 0), and
  • the output is going to the console - not $( ) capture, not save <path>, not a captured function body.

When either fails the stream is simply printed, exactly as an unsinked pipe prints it. That is a no-op rather than an error on purpose: a pipeline copied out of an interactive session into a script should not start failing because of a word that only ever concerned a screen.

4. Records keep their column header

A record stream (dir, status, caps, find, trace deps) pages with its column header pinned - repainted at the top of every frame, never scrolled.

This is the one thing a pager does that the console's scrollback structurally cannot. Scroll back through a grid in a scrollback buffer and the column names are off the top, leaving columns you have to count. paginate keeps them in view at every position.

gsh> dir /many | paginate
name        type  size  sealed
f0.txt      file     1  false
f1.txt      file     1  false
...
[ lines 1-22 of 45 ] [up/down] scroll [PgUp/PgDn] page [Home/End] ends [q] quit

5. What it does to the content, and why

A pager owns the whole screen and repaints it by homing the cursor, so it cannot let the content drive the terminal. Two things are filtered, both for the same reason dir sanitises filenames - the view that is supposed to reveal the content must not be something the content controls:

  • Control bytes render as . A single ESC [ 2J inside a file would clear the frame mid-paint, scrolling itself and everything after it out of the view meant to show it. A tab becomes one space; expanding it properly needs a column model this does not have.
  • An over-long line is cut at the screen width, with a trailing > to say so. A line that wrapped would silently push every following row down, making the row count the pager just computed wrong - and the status line would then be lying about which lines you are looking at.

read on its own is unchanged. It does not own the screen, so it has no reason to filter.

6. Relationship to the console's scrollback

They are complements, not alternatives.

scrollbackpaginate
when you decideafter the output has gone pastbefore you run the command
what it needsnothingthe word in the pipeline
coversevery command, including ones that already ranthe pipeline you asked about
column headersscroll away with everything elsestay pinned
boundthe ring, oldest lines age outthe stream, whole

Scrollback is the safety net for output you did not expect to be long. paginate is what you reach for when you already know it will be.

Between them they retired help's built-in pager (2026-09-18, once scrollback was verified on a board). trace still pages its own views, through the same paginate_table (section 7), and the pinned column header in the row above is exactly why.

7. Implementation

paginate_sink in services/shell/src/main.rs, dispatched from pipe_run alongside the other two sinks (write, assert). Bytes go through paginate_bytes, records through paginate_table; both drive line_pager, the single screenful-at-a-time reader the shell has had since help needed one. trace routes through paginate_table too, so there is one table pager rather than two copies of it.

No line index array. Lines walks the buffer with a one-entry cached cursor instead. An index over a 16 KiB buffer of one-byte lines would want 16,384 offsets, and pipe_run's frame already sits at 68% of the user stack - §26.6.1 says the move is to change the representation, not to find room for a big one, and two words beat sixteen thousand.

One frame, a dozen syscalls. Every line of a repaint goes into a shared 256-byte FrameBuf and out in batched writes. Writing each row straight to the console costs two syscalls per row, which against a 16-deep console queue meant holding a scroll key outran the sink and the keyboard stopped responding.

Utility: scrollback - read back what has scrolled off the screen

Status: Built + QEMU-verified (osdev test shell for the view, osdev test files for save, which needs a real disk) for the data path; the repaint-cost behaviour is only observable on real hardware (§4a). A shell built-in, full-screen. Trails CLAUDE.md; does not amend it.


1. What it is

The console keeps the lines that scroll off the top. scrollback shows them.

scrollback 0.4.0 - what has scrolled off the screen

  fs: journal recovered 4 block(s)
  drives check - 0 bad, nothing repaired
  gsh> churn verify
  churn: 0 empty, NONE torn

[ 340-372 of 512, older lines aged out ]  [up/down] line  [PgUp/PgDn] page  [Home/End] ends  [q] quit

2. How it opens

howdoes
PgUp at the promptopens the view, one page back
scrollbackopens the view at the newest line
scrollback save <path>writes the whole history to a file
PgDn at the promptnothing happens

PgUp opens it one page back because the key already meant "go back a page". Making you press it twice to see one page is the interface forgetting what it was just told.

PgDn at a live prompt is inert on purpose. You are already at the bottom; opening a viewer that is already scrolled to the end is a no-op dressed as an action.

Inside, every key is the one it is everywhere else (0_conventions.md rule 10a): arrows move a line, PgUp/PgDn a page, Home/End the ends, q or Esc leaves. Leaving restores the prompt and the half-typed line, cursor where you left it.

3. The history is BOUNDED, and the status line says so

32 KiB or 512 lines, whichever runs out first. When anything has been evicted the status line reads older lines aged out.

That is not a detail. A view that begins in the middle of a session while presenting itself as the beginning is the same class of wrong answer as a truncated directory listing (CLAUDE.md §26.7). The console's ring has always counted what it dropped precisely so a reader could be told.

4. Why it is a utility and not a mode

It was a mode, and the mode is what broke on real hardware.

The shell used to drive the console's own view: one blocking request per keypress, each asking the console to move its view and report back. To answer, the console had to paint_view and present before it could reply - a full repaint of the framebuffer, inside the caller's deadline. On the Dell Wyse that framebuffer is 3840x2160, the console is contracted to core 0, and the shell was round-robined onto core 0 too. So the shell blocked for one second on the core that was painting, then declared a console that was merely busy to be dead (backlog/fixed/37).

Holding PgUp issued one of those per key repeat. No deadline makes that shape robust.

Now the console is only ever asked for bytes - a bounded copy out of its ring, no painting - and this utility paints its own screen with ordinary output, which is a send and carries no deadline at all. A keypress costs the console no repaint whatsoever.

It also takes the view offset out of the console. That was a second place holding a derived view of where the operator is looking, which is what §26.4 is about, and it was exactly the state that could disagree with the shell's idea of it.

4a. Fetched ONCE, so a keypress costs no IPC at all

Three failures on a Dell Wyse, each a different shape, all with one thing in common:

  1. a per-keypress request to move the console's view, waiting on a full 4K repaint;
  2. then a per-frame fetch, queued behind the painting the previous frame had asked for;
  3. then a reply larger than the buffer class the call had declared.

Every one of them needed a request in the hot path. The console is both the service this reads FROM and the service it draws TO - one service, one 16-deep queue - so a request made mid-frame can always end up behind painting this very utility just caused. Fixing each instance moved the problem rather than removing it.

So the whole history is read when the view opens, and nothing is asked for afterwards. Every keypress is local: paint from memory, read a key, paint. There is no request left in the hot path to go wrong.

Why it fits, and why it is not allocated

The console's ring is SB_BYTES = 32 KiB over SB_LINES = 512 lines. The shell's user stack is USER_STACK_PAGES * 4 KiB = 256 KiB. So the entire history fits in a stack arena with room to spare - the whole thing, never a window onto it. stack_fit_check.py holds that claim rather than this paragraph: cmd_scrollback does not reach the top five deepest frames in the tree, and cmd_edit is larger.

Allocating was considered and rejected for a reason worth keeping. alloc_mem exists, but a service cannot use it: it returns a raw address and services/ is #![deny(unsafe_code)], so it would need a new safe arena wrapper in the SDK's audited layer. More decisively, allocating creates a failure mode that reserving does not have. "Insufficient memory, minimum N KiB" is a branch that can only exist because we chose to allocate. The bound is known when the image is built, so the space is reserved when the image is built, and there is nothing to be short of. That is §26.6.1's point exactly: a fixed footprint you can read off the source, with no allocator to fail mid-operation.

A file was considered too, and is the right answer to a different question - a dump you can keep, search and pipe. It is not the right answer to "show me what just scrolled past", because it makes an everyday view depend on storage being present and healthy.

What the status line promises

It counts what is actually held. If the arena ever could not take everything the ring reports it says truncated rather than naming lines that are not there (§26.7) - the same discipline as older lines aged out. Both numbers come from the same 32 KiB, so it should never fire.

4b. save <path> - the history as a real file

save is the word three other utilities already use for exactly this (selfcheck save, run ... save, chaos kill-storm ... save). A fourth word for one concept is what rule 4 exists to prevent, which is why it is not dump.

gsh> scrollback save /docs/sb.txt
scrollback: saved 512 line(s), 19819 bytes to /docs/sb.txt

The saved copy is not clipped. The view clips lines to the screen because a wrapped line would make its own row count a lie; a file has no rows and no reason to lose characters, so save fetches separately rather than writing out whatever the viewer happened to be holding.

It is the answer to a different question from the view: a dump you can keep, find in, pipe, or open in edit. It is deliberately NOT what the everyday view depends on - that would make reading what just scrolled past require storage to be present and healthy.

Unlike the view, it needs fs, and says so rather than half-writing: a failed create reports scrollback: could not create <path> - <reason>, and a failure part-way through reports scrollback: the write failed part-way - <path> is INCOMPLETE (<reason>) (§26.7 - a recovery that fails is still a failure).

5. In a script it prints

depth > 0 - a script, run, assert or selfcheck - dumps the retained lines and returns. A full-screen browser waiting for a keypress with nobody there does not degrade, it hangs the run. Same guard help, docs and paginate carry.

6. Testing it: nothing it prints can be a marker

Worth recording, because it caught the suite three separate ways in one afternoon.

A scrollback viewer displays everything the terminal has ever shown. So any string a frame contains may also be sitting in the history that frame is rendering:

  • [q] quit matched a pager's chrome that had scrolled into the ring;
  • " of " matched ordinary history text;
  • gsh> matched a prompt inside the replayed dump.

Each time the collect stopped mid-stream and the following case reported the failure, which is how Home is the line editor got blamed for a bug it had no part in.

A fourth instance came from save, and it is the one that names the real habit. The check was contains("sb.txt") over the output of dir / | where name=sb.txt - and it matched the echoed command line, passing while dir had truncated and found nothing. Together with a check that passed on an empty string and one that passed on the words "storage unavailable", the pattern is: asserting the ABSENCE of a failure, or a string the echo also contains, confirms nothing. Assert something only the real output can say - here read ... | count reporting N lines,.

Two kinds of marker are safe. An escape sequence, because the ring stores the rendered grid (sb.push(&s.grid[0][..cols])) and escapes are consumed by the terminal, never stored - the frame cases wait on ESC[J. Or a string introduced after the output under test, which is what the dump case does.

There is a second lesson underneath, and §4a is the proof it was not learned the first time: the old mode's tests passed through the entire bug, because what broke was a repaint cost on a 4K panel and QEMU has no such panel.

What the suite covers now is the DATA path - ask for lines, get bytes, page them - and that much is deterministic on any framebuffer. What it still cannot see is anything whose cost is a repaint, and §4a is exactly such a bug: green before the fix, green after, same 202 cases. So the honest statement is that this utility is well covered where coverage is possible and uncovered where the hardware is the variable, and the standing rule stays what it has been all day - a green suite is not evidence about a 4K console.

7. Implementation

cmd_scrollback in services/shell/src/main.rs, over ServiceContext::console_history, which is opcode REQ_HISTORY in services/console/src/main.rs and Term::history_into in services/console/src/term.rs. Frames go through the same FrameBuf and LineBuf that paginate and help use, so a repaint is a dozen console messages rather than two per row.

Lines are clipped to the screen width, never wrapped: a wrapped line pushes every row below it down and makes the count in the status line a lie about what is on the screen.

gsh - the GodspeedOS shell language

Status: COMPLETE. The whole language is built, and it lives entirely on the stack (no heap anywhere in the interpreter), with a fixed, readable bound on every buffer and a LOUD refusal on every overflow: a 10 MB script is read through a bounded minifier, and when its code exceeds SCRIPT_MAX (7112 bytes) it is refused loudly with none of it run, never OOMs. (2026-10-25: it used to truncate at ~7 KiB and run the part that fitted, which osdev test big-script (6/6) pinned; the refusal replaced that in 914b4329, backlog/fixed/80 H10-H21, and the suite has pinned the refusal since 2026-10-25: refused loudly, none of it run, no panic, 5/5.) Scripts use the .gsh extension (GodspeedOS shell; .gs is reserved for a future general-purpose Godspeed language). Built on the run/run_lines interpreter and the command Result model (execute returns Ok/Err). Not POSIX - see CLAUDE.md Appendix B.3 / D.

What is built

  • Tier 1 (§3, §4, §6, §8) - let/let mut + reassignment; $-expansion ($name, "...") and params ($arg1..$arg9, $args, $argcount, $self); fail; if/else if/else with comparisons (== != < > <= >=), in, !, and result as a comparable value; switch with multiple values per arm, a _ default, and switch result. Pinned by osdev test files (a greet-shape param+if+in+switch script runs end to end, both paths).

  • Arithmetic (§3) - checked integer + - * / %, ( ) grouping, precedence.

  • Command capture (§3) - $( ) captures a bare producer ($(date), $(read /f), $(greet)). A pipeline capture ($(greet | count)) is refused loudly, because its nested pipe buffers would overflow the bounded 256 KiB user stack (§26.6); stage it through a file instead - greet | count | write /t.txt then let n = $(read /t.txt), the materialize-then-capture idiom.

Corrected 2026-09-26. Nine worked examples in this document captured a pipeline directly (let n = $(greet | count)), which is the thing this very bullet says is refused. They are staged now. The bound is real - a nested pipe inside a capture is what the bounded stack cannot afford - so an example that hides it fails on first use.

  • Functions (§7) - fn name params { … } called like a command; named params; scoped locals plus immutable-global access; return; and bounded recursion via explicit call frames (no native recursion, §9). Function-valued conditions (if fn { }, §4) and function output-capture ($(fn …), §3) are included.

  • Libraries - import <path> (all functions) and from <path> import <name> [as <alias>] … (selective, aliased), resolved at LOAD time: each library is minified and its requested functions appended to the buffer so the pre-scan indexes them. Explicit paths, flat namespace, loud on a name collision (as resolves it).

  • Loops (§5) - for <var> in <words | range N | range A B | $args | (producer)> { … }, unbounded loop { … } with a 100k-iteration backstop, and break/continue. A mutable loop counter lives in a fixed slot (overwritten in place, so a long loop grows no arena) and each pass resets the body's locals, so a let inside is fresh. The byte-line stream form (for line in (producer)) is built.

  • defer (§5) - defer <command> runs cleanup when the current scope exits (a function's return, or the whole script), LIFO, even on fail. Each defer records only an (offset, len, scope-depth) into the resident script.

  • Record aggregators (§5) - count is dual (rows of a record stream, lines of a byte stream); sum/min/max/avg <col> reduce a numeric column, loud on a non-numeric or missing column, never a silent 0.

  • Console input (§8) - input "prompt" and input secret "prompt": invisible entry plus an echo-taint guard rail (sealed reserved).

The output-capture cluster (for line in (producer), if myfn, $(fn)) closed the set, so Tier 2 is complete. Capture behaviour is pinned by osdev test files plus the baked osdev test script.

Deferred follow-ons, collected as future work under §11: the record-row form for row in (pipeline); sealed enforcement plus a secret consumer; and input pipe-else-prompt.

Contents

  1. Spirit
  2. Lexical
  3. Variables, parameters, arithmetic
  4. Conditionals
  5. Loops
  6. Switch
  7. Functions
  8. Builtins
  9. Bounds
  10. Out of scope
  11. Tiers and effort
  12. Worked example
  13. Example programs - setup.gsh · greet.gsh · report.gsh · check.gsh · retry.gsh

1. Spirit

  • Simple, explicit surface. Braces for blocks, newline-separated statements, no trailing ;, no parentheses around conditions, a tiny keyword set.
  • Conditions are command results. Ok is true, Err is false. The thing that makes if cheap is that we already produce a Result for every command.
  • Errors are checked, not hidden. No set -e-style invisible mode - you inspect result right where the error can happen. See §4.
  • Explicit and loud (the Godspeed part). Referencing an undefined $x is an error, not a silent empty string. Every limit is fixed; overflow is a loud error, never silent truncation or a runaway loop.
  • Bounded on purpose. Fixed storage, no heap (§9). The ceiling is a feature: it forces small, composable, readable scripts and keeps gsh from becoming the cryptic, magic-laden sprawl POSIX shells drift into. A script that needs more than the ceiling is a program - use the Godspeed language (.gs), not the shell.
  • Not bash. No fi/esac/done, no word-splitting surprises, no $? quirks.

2. Lexical

  • A newline ends a statement. You don't write a trailing ;. Use ; only to put two statements on one line - never as a line terminator (a trailing one is just ignored).
  • # to end of line is a comment; blank lines are ignored.
  • Tokens: bare words (no spaces, literal), "double quoted" (spaces ok, $var expands), 'single quoted' (literal, no expansion).
  • { and } are structural - they delimit blocks (if/else, and later for/loop/fn/ switch). To use a brace literally in a value or command argument (e.g. a JSON literal), quote it: write /f '[{"n":1}]'. Inside '...'/"..." a brace is literal, never a block.
  • Multi-line content uses a triple-quoted block - for writing real files. It expands $vars like "…". It is for a command argument (bounded by MAX_FILE_BYTES); storing one in a let var that exceeds the value-size bound (§9) is a loud error, not a silent truncation.
echo one                        # a statement; the newline ends it
mkdir /x; cd /x                  # two statements on one line, joined by ;

echo "spaces kept, $name expands"
echo 'literal $name - no expansion'
write /work/config """
mode  = normal
cores = 4
owner = $name
"""

3. Variables, parameters, arithmetic

Command position vs value position (the one disambiguation rule). The interpreter is always in one of two places, and that decides how bare text reads:

  • Command position - a statement, or right after if / for … in. Bare text is a command; no marker needed.
  • Value position - the right of let x =, or inside "…". Bare text is a literal. To run a command here and use its output, promote it with $( … ).

The sigil rule that ties it together: $ means "the value of." $name = the value of a variable; $(cmd) = the value of running a command. (This is why $(…) is the capture form and a bare (…) is not - one obvious way.)

let name = "Matthew"
let n    = 3
let lit  = greet               # value position, no $( ): the literal string "greet"

echo $name                     # Matthew
echo "hi $name, n=$n"          # hi Matthew, n=3

Capture - promote a command into a value with $( ):

greet | count | write /t.txt               # stage it - a capture cannot hold a pipeline
let count = $(read /t.txt)                 # 3
let when  = $(date)                         # the date stamp
status | where state=Running | count | write /t.txt
let live  = $(read /t.txt)
echo "running: $live, at $when"

fn make_greeting name { echo "Hello, $name!" }
let msg = $(make_greeting Bankole)         # capture a FUNCTION's output: "Hello, Bankole!"
echo $msg

Immutable by default. let x = … is an immutable binding - reassigning it is a loud error. Opt into mutation with let mut x = …, and reassign a mutable binding with bare x = … (no let):

let ROOT = /work               # immutable
echo $ROOT/logs
# ROOT = /tmp                  # error: cannot reassign an immutable binding

let mut i = 0                  # mutable
i = $i + 1                     # reassign - no 'let'

There is no const: the default let is the constant. Mutation is explicit and visible at the declaration - and that let / let mut line is exactly the scope boundary functions key off (§7).

Script parameters - run greet.gsh Matthew core (params are immutable, like a function's):

echo "name=$arg1 role=$arg2"         # name=Matthew role=core
echo "got $argcount args: $args"          # got 2 args: Matthew core
echo "script: $self"               # script: greet.gsh
  • let x = <value> declares an immutable binding; let mut x = <value> a mutable one; x = … reassigns a mutable binding (loud error on an immutable or undeclared name).
  • A value is a literal word, a "string", a capture $( … ), or an arithmetic expression (below).
  • $x expands; undefined $x is a loud error (never a silent empty string).
  • $arg1 $arg2 … positional, $argcount count, $args all, $self script name.
  • $home is the session's home, /users/default (docs/filesystem-layout.md 6), so a script can write cd $home/notes. Reserved like the others: let home = ... is refused.

Arithmetic. Integer expressions, evaluated wherever a value is expected - the right of a let/let mut/reassignment, and the operands of a comparison. Operators are + - * / %, with bare ( ) for grouping and the usual precedence (* / % before + -). Operands are integer literals or $vars that expand to integers.

let mut i = 0
i = $i + 1                     # 1 - replaces the old $(add $i 1)
let area = $w * $h
let half = $n / 2              # integer division - truncates
let odd  = $n % 2
let cost = ( $base + $tax ) * $qty    # bare ( ) groups; $( ) is still capture
if $i + 1 > $max {
    echo "next step would overflow the window"
}
  • Signed 64-bit, checked. Divide-by-zero and overflow are loud errors, never a wrap or a silent zero; a non-integer operand is a loud error too.
  • Space-separated operators, so text is never mistaken for math: $dir/sub is a path, $n / 2 is division. String building stays interpolation ("$a$b", "$dir/sub"), never +.
  • Arithmetic is value-position only. To use a result as a command argument, assign it first (let d = $n + $n → echo $d) - compute it, then use it.

4. Conditionals

A condition is either a comparison (its first token starts with $, ", or a digit), a command (true iff it returns Ok; pipelines are commands), or a function call (if myfn args { … }

  • the function is run and you branch on its result: Ok -> then, Err -> else).
if read /sc/a.txt {
    echo "exists"
} else if dir /sc {
    echo "dir but no file"
} else {
    echo "nothing"
}
if $role == "core" {
    echo "the core service"
}

if $n >= 3 {
    echo "enough"
}

if !read /sc/secret {            # ! negates a command condition
    echo "absent - good"
}

A function as a condition - branch on what it returns, not what it prints:

fn writable f { … }              # a function whose last statement is Ok/Err-valued
if writable /sc/log {
    echo "can write"
} else {
    echo "read-only"
}

if !writable /sc/rom {           # ! negates it too
    echo "confirmed read-only"
}

Membership - the condition form of a one-arm switch:

if $role in core worker courier {
    echo "known role"
}
  • if <cond> { … } else if <cond> { … } else { … } - braces, no parens, no fi.
  • Comparison ops: == != < > <= >= (numeric if both sides parse as integers, else string). Either side may be an arithmetic expression - if $i + 1 > $max { … } (§3).
  • $x in a b c - true if $x equals one of the listed words.

Failure model - explicit (no hidden set -e). A failed statement is tallied (the run summary already does this) and execution continues; you handle errors where they happen by checking result right where it can fail:

mkdir /sc
if result == Err {
    fail "could not create /sc"
}
read /sc/cfg
if result == FileNotFound {        # the SPECIFIC failure kind - flat, no Err( ) wrapper
    write /sc/cfg "defaults"
}

if result != Ok {                  # "anything failed" - the negation form
    echo "something went wrong"
}

To branch on several specific kinds, switch the result (§6) instead of chaining ifs:

read /work/config
switch result {
    Ok           { echo "loaded" }
    FileNotFound { write /work/config "defaults" }
    Denied       { fail "no permission to read config" }
    _            { fail "unexpected: config unreadable" }
}
  • result is a first-class value: after every statement it holds that statement's outcome. It is the Result the shell already threads between commands - so result == Err is just prev.is_err(), nearly free to implement.
  • Compare result with == / != against one of: Ok, Err (any failure), or a specific kind - FileNotFound, Denied, AssertFailed, Unknown (the set assert fails-with uses).
  • Flat, no wrapper. It is result == FileNotFound, never result == Err(FileNotFound) - the kind names are self-evidently errors, and Err alone already means "any failure". One spelling per check.
  • fail <msg> prints <msg> loudly and ends the script with Err. return <cond> ends a function (§7) with a result.
  • Optional one-liner sugar, addable later (desugars to the result check): a and b (run b iff a was Ok), a or b (run b iff a was Err). Not required.

5. Loops

Iterate a stream - byte lines, or record rows with $row.<col>:

for line in (greet) {            # the LINES of a byte stream
    echo "> $line"
}

for row in (status | where state=Running) {   # the ROWS of a record stream
    echo "$row.name on core $row.core"
}

Built: the byte-line form for line in (producer) - a bare producer, iterated line by line. The record-row form (for row in (pipeline) { $row.col }) is a design sketch, still deferred (see §11): it needs a capturable pipeline and $row.<col> field access.

Iterate params or a literal word list:

for arg in $args {
    echo "arg: $arg"
}

for svc in events fs block-driver {
    echo "checking $svc"
}

Counting loops with range:

for i in range 3 {               # 0 1 2
    echo $i
}

for i in range 2 6 {             # 2 3 4 5
    echo $i
}

loop repeats until you break - the one unbounded loop:

let mut i = 0
loop {
    i = $i + 1
    if $i == 3 { continue }
    if $i > 5 { break }
    echo $i                       # 1 2 4 5
}

A loop that should stop on a condition just breaks on it:

loop {
    if read /work/ready { break }
    let waited = $waited + 1
}
  • for x in ( … ) iterates lines of a byte stream or rows of a record stream; record rows expose columns as $x.<col> (the payoff of typed pipes - nothing in POSIX has it).
  • Not $( ) here. for … in is command position - the live stream goes in directly (the parens are optional readability). $( ) would flatten the command to text and lose $row.col; for … in keeps the stream so it can iterate rows. ($( ) = "the text"; for … in = "the stream".)
  • for x in a b c iterates literal words; for x in $args iterates the script's params.
  • for i in range N / range A B counts.
  • loop { … } repeats until break. There is no while - a conditional loop is loop { if !cond { break } … }, which keeps the exit explicit and visible.
  • Two loops, one job each - and the keyword tells you if it terminates. for is bounded: it walks something finite. loop is the unbounded one: it runs until break, with the hard iteration cap (default 100k) as a loud backstop, never a silent hang.
  • break / continue.

6. Switch

switch $role {
    core           { echo "the core" }
    worker courier { echo "a helper" }      # multiple values per arm
    _              { echo "unknown" }        # _ = default
}
switch $arg1 {
    start  { echo "starting" }
    stop   { echo "stopping" }
    status { echo "ok" }
    _      { fail "unknown command: $arg1" }
}

No fallthrough; _ is the default; multiple values per arm. The matched value is any value - including result, which is the clean way to branch on several error kinds at once (§4).

7. Functions

fn greet name {
    echo "hello $name"
}

fn ensure_dir path {
    if !dir $path {
        mkdir $path
    }
}

greet Matthew                  # a function is just a command
ensure_dir /sc

Output is the value ($( )), result is the control (used by if):

fn full name surname {
    echo "$name $surname"
}

fn double n {
    let d = $n + $n             # arithmetic is value-position; assign, then output
    echo $d
}

let who = $(full Matthew Levi)  # capture the output
echo $who                       # Matthew Levi
echo $(double 5)                # 10

A function is just a command - that's what keeps it coherent with the rest of the language:

  • fn <name> <param…> { … } - named positional params, bound as $name in the body ($arg1 … $args $argcount also available).
  • Output is the value, result is the control. $(f …) captures what the function printed; its Ok/Err works in conditions like any command. return <cond> ends early with that result; falling off the end returns the last statement's result (so a helper ending in an assert returns the assert's verdict). No separate "return value" concept.
  • Scope = params + locals + immutable globals (mirrors constitution invariant 9). A function sees its parameters, its own locals, and the script's immutable (let) globals - but not mutable (let mut) globals. Invariant 9 is "no unowned global mutable state; immutable globals are fine," applied one layer up: immutable config (let ROOT = /work) is ambient-readable everywhere (no spooky action - it can't change); a value the script mutates must be passed in, so a function's mutable inputs are explicit, exactly like a service's capabilities (§3.1). The let / let mut distinction is this scope boundary. Assignments inside are local and vanish on return.
  • Defined anywhere. A one-pass pre-scan over the (resident) script indexes every fn block, so a call can precede its definition - put helpers at the bottom and the main logic at the top if you like. (This is why the script is loaded whole, not streamed - a deliberate trade for ergonomics over arbitrarily-huge scripts; §9.)
  • Bounded, not natively recursive. Calls use the interpreter's explicit call-frame stack, not native Rust recursion - so the native stack stays flat regardless of gsh call depth. Recursion is allowed; depth is capped (loud error on overflow).

8. Builtins

Each returns a Result, so they compose with if.

  • let / let mut - declare an immutable / mutable variable (reassign a mutable one with x = …).
  • result - the previous statement's outcome, as a value (compare == Ok / == Err / == <Variant>).
  • fail <msg> - print loudly and end the script with Err; return <cond> ends a function (§7).
  • defer <command> - run a command when the current scope (function, or the whole script) exits, including on fail; deferreds run LIFO.
  • Ok / Err and the variant names (FileNotFound, Denied, AssertFailed, Unknown).
  • range N / range A B - the counting iterator for for.
  • break, continue.

Arithmetic is inline (+ - * / %, value position) - see §3, not a builtin.

defer - cleanup that always runs:

mkdir /tmp/build
defer delete /tmp/build recursive    # runs no matter how we leave
write /tmp/build/out done

Record aggregators (typed-pipe stages). Because our pipes carry a typed Table, a pipeline can reduce - something byte pipes can't. count is dual (rows of a record stream, lines of a byte stream); the column reducers are record-only:

roster | count | write /t.txt            # row count (stage, then capture)
let rows  = $(read /t.txt)
dir /work | count | write /t.txt          # entries in a directory
let files = $(read /t.txt)
status | sum mem | write /t.txt          # sum a numeric column
let used  = $(read /t.txt)
dir /work | max size | write /t.txt       # largest file
let big   = $(read /t.txt)
status | avg queue | write /t.txt        # average a column
let avgq  = $(read /t.txt)
  • count - rows (record stream) or lines (byte stream).
  • sum <col> min <col> max <col> avg <col> - reduce a numeric column; a non-numeric or missing column is a loud error, never a silent 0.

Console input (input / secret). A script reads a line from the user with input, a producer captured like any other with $( ):

let name = $(input "Your name: ")        # prompt to the console, capture the reply
echo "hi, $name"
  • input "prompt" - print the prompt, read one line from the console, emit it (captured, or piped).
  • input secret "prompt" - the same, but keystrokes are invisible (nothing echoes as you type, like sudo). The captured value is tainted (below).
  • input secret sealed "prompt" - reserved, not built (the maximum-lockdown escalation; below).

The value is captured, not written into a variable by side effect, so it follows gsh's one binding model (let x = $(…)) - there is no bash-style read var reaching in to set a variable.

Secret taint (a guard rail, honestly labelled). A value from input secret is marked, and the mark does exactly one thing: it may not be printed to the console. Expanding it into an echo is refused loudly and masked as [secret]; everything else - writing it to a file, assigning it, passing it to a command - is allowed, and the taint rides along on assignment (let y = $pw gives y the same mark, so it cannot be laundered to the console in one hop):

let pw = $(input secret "Password: ")
echo $pw          # refused, loud: "refusing to echo secret 'pw'"   ->  [secret]
write /vault $pw  # allowed (your disk, your call)
let y = $pw       # allowed; y is also secret, so `echo $y` is refused too

This is a guard rail against the accidental echo, not a vault - and honest about it: because write and assign are allowed, a determined author can always move the value back out (write, then read it back), so airtight non-leakage is impossible here by construction. We catch the common accident loudly and claim nothing more - no general taint tracking; propagation stops at assignment.

sealed (reserved). input secret sealed will additionally forbid write and assignment, so the value can only ever be consumed through a designated secret-aware door (a future login/auth command, or a secret-eq compare). Until such a consumer exists a sealed value would be a black box you cannot use, so the keyword is reserved and not yet built (§26.2) - the escalation is on record, to be built the day a real crown-jewel consumer justifies it.

Interactive only. input blocks for a real person, so an automated suite cannot use it (it would hang). It is tested by injecting keystrokes over the console - the way the harness already injects commands - not by a bypass.

assert and the whole existing command set are unchanged, and a script's own result is still Ok iff every top-level statement was Ok (the PASS/FAIL summary keeps working).

9. Bounds

Bounded by design, and the bounds are the point. gsh is no_std, no heap - fixed storage, loud on overflow. Every limit below is a deliberate ceiling, generous for real shell scripts and tunable; hitting one is a clear error, never a silent truncation or a runaway. (Values:)

ThingBound (loud on overflow)
variablesfixed count × bounded value size
block nestingfixed
loop iterationshard cap (no runaway; configurable per loop later)
function call depthfixed frames (each = its params + locals)
deferred actionsfixed per scope
script sizeembedded: rodata; on-disk file: SCRIPT_MAX (7112 B of minified code)

This is a feature, not a limitation. A finite ceiling forces good script hygiene - small, composable, readable scripts - and rules out the cryptic, sprawling, magic-laden scripts the POSIX world drifts into. If a script needs more than the ceiling, it isn't a shell script anymore - it's a program, and that's the job of the general-purpose Godspeed language (.gs), not gsh. Choosing the right tool is the discipline; the loud overflow is what tells you you've crossed the line. (No silent stack→heap fallback, ever - §26.7.)

Execution model: the script is loaded whole (resident in a fixed buffer - small file or embedded rodata), pre-scanned once for fn definitions (a tiny name→offset index, so functions are callable before they're defined, §7), then executed top-to-bottom. Control flow uses an explicit control stack (a flat array of frames), not native Rust recursion - loops and blocks re-seek within the resident buffer, conditions are a command result or a comparison. No native recursion - the native stack stays flat regardless of gsh nesting or call depth, the same discipline behind every #[inline(never)] in the shell today.

Streaming huge scripts (read-at-offset from a multi-block file, buffering only the active block, the way bash reads a file descriptor) is deliberately not done - it would mean abandoning whole-script residency, which is what makes "defined anywhere" cheap. That trade-off lands on the side of small- script ergonomics; the streaming path belongs to Godspeed lang + a v2 fs.

10. Out of scope

For now:

  • Capability delegation in script syntax (pipes-as-caps, spawn with explicit grants) is Appendix D.3 territory and is not built. (2026-10-25: the service-initiated spawn it waited on exists - the shell asks the supervisor to spawn a stage with named send peers - but no script syntax names a grant.)
  • A string toolkit (length, slice, split) - interpolation covers building strings; richer string ops wait. (Integer arithmetic is in - §3.)
  • Cross-file include / source, and record values held in variables (hold a whole table in a let) - bigger storage stories; iterate with for for now. (2026-10-09: function libraries are the exception - import <path> and from <path> import <name> are built, see "Libraries" under What is built. A general source of a script's statements is still not.)
  • A heap, and huge / streamed scripts. gsh stays no_std/fixed-storage (§9). Heap-backed values, multi-block-file streaming, and 10K-line scripts are explicitly not gsh's job - by the time a script wants them it's a program, and that's the Godspeed language (.gs). The .gsh/.gs split is this boundary. The loud overflow is the signal you've crossed it.

These are the genuinely balloon-prone parts, or they belong to the general-purpose language. (Functions and integer arithmetic are not here - both are Tier 2.)

11. Tiers and effort

  • Tier 1 (~2-4 days): let/let mut + $-expansion + params, if/else if/else, comparisons + in, result/fail, switch.
  • Tier 2 (~6-8 days): $( ) capture, multi-line """…""", inline integer arithmetic (+ - * / %, precedence, ( ), checked), for (lines / rows / words / range), loop, break/continue, functions (fn, pre-scan index, call-frame stack, return), defer, and the record aggregators (count/sum/min/max/avg).
  • Tier 3 (resist): a string toolkit (length/slice/split), cross-file include / source, record-valued variables.

Deferred follow-ons (future ergonomics)

These are intended for gsh (unlike §10, which is genuinely out of scope), but not built yet - the language is already complete enough to work in fully without them. They are recorded here so the intent is not lost; each is pulled into existence when a real script wants it (§26.2), not before.

The output-capture cluster - COMPLETE. Three features that let a script use what a sub-computation produces. Each was deferred while it looked like it needed a nested ~64 KiB capture buffer on the stack; each turned out to need something smaller once actually built - the constitution's own lesson (§26.2): pull a feature into existence, then size it to the real need, not the feared one (§26.6.1 - change the representation, do not reach for more stack or a heap).

  • $(fn …) - BUILT. Capture a function's output into a value: let g = $(make_greeting Bankole). The function runs via the Call machinery under a CaptureCall frame, with its body output routed to a bounded 512 B buffer (it lives in the executor frame for the whole run, so it stays small to coexist with the pipe path - a captured value is a name, a number, a short line); the trimmed buffer becomes the value. One capture at a time (a nested $(fn) is refused loudly). No heap

    • scratch space, filled then dropped. ($( ) also still captures a bare producer: $(read /f).)
  • Function-valued conditions - BUILT. if myfn args { … } [else { … }] and if !myfn { … } branch on the function's RESULT directly (Ok -> then, Err -> else), instead of calling it as a statement then checking result. The function runs via the executor's Call machinery under an IfCall frame; a comparison else if after a function-if still works. This one needs no capture buffer - it branches on the result, not the output.

  • Stream loops - BUILT for byte-line producers. for line in (producer) { … } captures the producer's output and iterates its lines: for line in (read /f) { echo "> $line" }. The producer must be a bare producer (read, date, tree, a producer service, …); a pipeline (a | b) is not captured (bounded stack) - stage it to a file first. Bounded to a 16 KiB capture; loud if larger. The record-row form (for row in (pipeline) { $row.col }) is still deferred: it needs a capturable pipeline and $row.<col> field access.

    For the record-row case, the workaround stands: materialize-then-pipe - write the pipeline to a file, then for line in (read <file>) { … }.

Secret handling - input secret today is a guard rail that only blocks console echo (not a vault - once a value may be written to a file it can be read back). Deferred until a real consumer exists to justify more (§26.2):

  • sealed enforcement - input secret sealed would additionally refuse write + assign of the secret, not just echo. A reserved keyword today, currently a no-op.
  • A secret consumer - e.g. auth / secret-eq: something that compares a secret without exposing it. This is the use that would make sealed worth enforcing.

Automation input - input pipe-else-prompt: input reads from a connected pipe if one is present, else prompts - so the same script can run interactively or be fed non-interactively.

12. Worked example

# a helper: its mutable inputs are explicit params (immutable globals it could read)
fn ensure_dir path {
    if !dir $path {
        mkdir $path
        if result == Err {
            fail "could not create $path"
        }
    }
}

# provision once, then report - re-runnable
ensure_dir /sc

for row in (roster | where seat > 0) {
    echo "$row.name is a $row.role at seat $row.seat"
}

greet | count | write /t.txt
let lines = $(read /t.txt)
echo "greet emitted $lines"

13. Example programs

Complete scripts - the kind the language is for, using only Tier 1-2 features. The bar for each milestone is "these run."

13.1 setup.gsh

Provision a workspace; fail loudly if it can't.

fn ensure_dir path {
    if !dir $path {
        mkdir $path
        if result == Err {
            fail "cannot create $path"
        }
        echo "created $path"
    }
}

ensure_dir /work
ensure_dir /work/logs

if !read /work/config {
    write /work/config """
    mode  = normal
    cores = 4
    """
    echo "seeded default config"
}

echo "workspace ready"

13.2 greet.gsh

Parameters, usage check, and switch.

# run greet.gsh <name> <role>
if $argcount < 2 {
    fail "usage: run greet.gsh <name> <role>"
}

let name = $arg1
let role = $arg2

if $role in core worker courier {
    switch $role {
        core           { echo "$name runs the core" }
        worker courier { echo "$name is a $role" }
    }
} else {
    fail "$name has an unknown role: $role"
}

13.3 report.gsh

Iterate and aggregate a record stream.

echo "running services by core:"
for row in (status | where state=Running) {
    echo "  $row.name  core=$row.core  queue=$row.queue"
}

status | where state=Running | count | write /t.txt     # row count (record-aware)
let running = $(read /t.txt)
status | sum mem | write /t.txt                          # reduce a column
let memuse  = $(read /t.txt)
echo "$running running, $memuse KiB in use"

13.4 check.gsh

Test helper (functions + assert) with defer cleanup.

let DIR = /check                   # immutable
mkdir $DIR
defer delete $DIR recursive        # cleaned up however we leave - even on fail

# a helper returns its last statement's result - here, the assert's verdict
fn roundtrip path text {
    write $path $text
    read $path | assert contains $text
}

roundtrip $DIR/a hello
roundtrip $DIR/b "two words"

# negative case: the file must NOT exist after delete
delete $DIR/a
if read $DIR/a {
    fail "delete did not remove $DIR/a"     # defer still runs DIR cleanup
}

echo "all checks passed"

13.5 retry.gsh

loop, break, and a bounded counter.

let mut tries = 0
loop {
    if read /work/ready {
        echo "ready after $tries retries"
        break
    }
    if $tries >= 3 {
        fail "never became ready"
    }
    tries = $tries + 1
}

Utility: fmt - format a script to the GodspeedOS standard

Status: BUILT and MERGED - the .gsh formatter, one canonical layout, non-negotiable, applied in place. STREAMING (no file-size cap), semantics-preserving, and idempotent. Pinned by osdev test files (207/0), osdev test fmt-demo (6/0 - jar before/after, a medium multi-flush file, and a 10 MB file formatted with fmt check = Ok), and osdev test fmt-idem (format-twice diff = IDENTICAL). Trails CLAUDE.md; does not amend it.


1. What it is

fmt <path> rewrites a .gsh script in place to the single GodspeedOS canonical layout. You run it knowing exactly what happens - there is nothing to decide. It is the gofmt / terraform fmt / rustfmt idea, for gsh: the point is not prettiness, it is uniformity. A canonical format kills bikeshedding and makes diffs mean something - you can see what changed instead of squinting past ten differently-cramped lines.

# before  (valid gsh, but jarring - crammed onto lines with ; and inline blocks)
let n = 7 ; if $n > 5 { echo big } else { echo small }
  for i in range 1 5{echo $i}

# after   fmt /script.gsh
let n = 7
if $n > 5 {
    echo big
} else {
    echo small
}
for i in range 1 5 {
    echo $i
}

(fmt fixes layout - the ; becomes newlines, inline blocks expand and indent, a space is inserted before {, runs of whitespace between tokens collapse. It does not re-space inside a token, so a $n>5 written without spaces would stay $n>5 - see the scope note in §2.)

Boring on purpose - zero style options

There is exactly one canonical layout and no knobs to change it. The second fmt grows a style flag, you have re-introduced the argument it existed to end. fmt has subcommands (below), but none of them change how the code is formatted - only what fmt does with the result.

2. The canonical standard (what "formatted" means)

  1. Indent 4 spaces per block depth. No tabs.
  2. Braces, K&R. header { on one line, body indented, } at the block's own indent. } else { and } else if <cond> { stay on one line.
  3. One statement per line. A ; statement separator becomes a newline (the biggest de-jarring win). cmd ; cmd ; cmd becomes three lines.
  4. One space between tokens. Runs of whitespace between tokens collapse to a single space; leading/trailing whitespace per line is stripped.
  5. # comments keep one space after the #; a full-line comment sits at the current indent, a trailing comment is one space after the statement.
  6. Blank lines collapse (2+ become 1); no leading or trailing blank lines; the file ends in exactly one newline.
  7. Strings are sacred. Content inside '…' / "…" is never touched - not its spacing, not its case, nothing.
  8. Idempotent. fmt of already-formatted text is a no-op. fmt(fmt(x)) == fmt(x) is an acceptance test - met, proven from 8 KB to 10 MB by osdev test fmt-idem (format a chunk-spanning file twice and diff = IDENTICAL).

Scope note (honest). fmt canonicalizes layout - indentation, statement-per-line, brace placement, inter-token whitespace, blank lines. It does not re-space the insides of a token (blk-16, val-$x), because gsh uses -/+ inside words as well as as operators, and guessing which is which would risk changing meaning. Micro-spacing of arithmetic is a later slice, gated on the tokenizer's exact rules. Layout is the jarring part; that is what ships first.

3. Surface

Subcommands are words and come before the path (fmt is a subcommand tool like drives, not a file-first verb like write). There is no write subcommand - the default is the write.

fmt <path>            format the script IN PLACE (the default; no subcommand)
    e.g. fmt /script.gsh
fmt check <path>      is it already canonical? Ok + silent if yes; Err + loud if no. Never writes.
    e.g. fmt check /script.gsh
fmt <a>,<b>,...       format (or check) several files - comma-separated, done one at a time
    e.g. fmt /x.gsh,/y.gsh
fmt version           print the version
fmt help              print usage
  • fmt <path> - the default. Format in place, on the fly. Safe to run blind: it only moves whitespace and newlines, so it can never change what the script does (§2.7-2.8).
  • fmt check <path> - the discipline / CI gate. Returns Err (via the Result model, 32_result.md) and names the file if it is not canonical, so a script can gate on it (fmt check /s.gsh ; if result == Err { fail "run fmt" }). Changes nothing on disk.
  • Bare fmt prints usage (a path is the actionable arg; without one there is nothing to do).

4. Guardrails & safety (loud, never silent - §26.7)

Because the default writes in place with no preview, fmt is all-or-nothing and never leaves a damaged file. It streams (constant memory, no file-size cap), and the write is temp-then-rename, so a failed write can never touch the original:

  • Won't parse -> refuse, file untouched. A syntactically broken script (unbalanced braces, a bad block header) gets a loud error and no write. fmt never lays a half-formatted result over a broken one; fix the syntax, then format.
  • One statement too long -> refuse, file untouched. The streamer holds one statement across a read boundary; a single statement bigger than the hold buffer is refused loudly. This is a per-statement bound, not a per-file one - the file itself can be any size (a 10 MB script formats fine).

Neither guardrail is an option - they are the safety that lets the zero-preview default be trusted.

5. Later (separate so it can grow)

  • fmt diff <path> - print the changes fmt would make without writing (needs a bounded diff).
  • fmt <dir> - format every .gsh under a directory in one call.
  • Host osdev fmt + VS Code format-on-save. The single definition of "GodspeedOS standard" is factored into a no_std core crate so the shell built-in and a host osdev fmt share it - one canonical format, never two that drift. This is where the gofmt/terraform muscle-memory lands.

6. Implementation shape & conformance

A shell built-in (0_conventions.md §2), using caps the shell already holds - no widened authority. It is a fully streaming, token-level re-emitter: fmt_stream_pass reads the source in chunks (fs_read_at) and fmt_walk_window formats each, emitting to a closure - holding only ONE partial statement + the indent depth across a read boundary (no native recursion). Constant memory, any file size. It never evaluates the script; it only re-lays-out its tokens, which is why it is provably semantics-preserving.

The write is two streaming passes: pass 1 counts the output size (for OP_WRITE_NEW), pass 2 stream-writes 508-aligned chunks (OP_WRITE_AT) into a temp, which is then renamed over the original - so a failed write leaves the original intact. fmt check formats to a temp and compares the two files sequentially (never two concurrent reads of the same file). Pinned by osdev test fmt-idem (the format-twice boundary diff) and osdev test fmt-demo (the 10 MB).

Has its own help_block and is listed under Console in the top-level help, and conforms to 0_conventions.md rules 1 and 5: fmt is in the shell's UTILS list, so fmt help and fmt version are intercepted before cmd_fmt. (Found 2026-10-09 missing from UTILS, so fmt help took help as a PATH to format; fixed in backlog/fixed/80 H8.)

Persistence: Block Driver + Filesystem (v2)

Status: Design doc, non-normative. Like everything in docs/, this trails the spec (CLAUDE.md) and does not amend it. Where this doc and the constitution disagree, the constitution wins. Items that would change the constitution (a new capability mechanism, dropping a service from the TCB) are called out as requiring a recorded CLAUDE.md amendment with sign-off.

1. Scope and milestone

This is v2 work, not v1. The v1 milestone (§23) is complete - multi-core boot, ping↔pong cross-core IPC, supervisor restart. Real persistence was explicitly out of scope for v1 (§23.4, "Filesystem persistence beyond the trusted block driver"), and §6.3 names the remaining v2 work directly: "the remaining v2 work is block-driver / fs."

So this effort is the defining piece of v2: make block-driver and fs real, and put them on the path out of the TCB (§6.3). It is held to the v2 bar (restartability, honest crash semantics), not the v1 "a trusted stub is acceptable" bar.

Terminology note. To avoid colliding with the project's version numbers, the filesystem's internal build stages are called Phase 1, Phase 2, …, never "v1/v2". The component is called the filesystem (service name fs); it has no invented brand name.

2. Decisions at a glance

DecisionChoiceOne-line reason
FilesystemOur own, from first principlesext4/btrfs are POSIX + enormous; both fights the constitution (§2.4, §3.3, §26.11). No interop requirement to justify the cost.
Authority modelFile = capability (north star)Authority by capability, not by mode bits (§3.3). Extends the capability model instead of bolting a permission model beside it.
Block deviceATA PIO (legacy IDE)Simplest correct device; no DMA → least-privilege by construction; works in QEMU and has a hardware path; stepping-stone to AHCI.
NamespaceHierarchical (directories + paths) - adopted; Phase 1 shipped flatFlat name→blob isn't realistic for real use; real directories from the start (§6.2). Still no links/permissions (authority is by capability, §3.3).
BoundsFixed, boundedLike the rest of the system (queue depth 16, MAX_ENDPOINTS): fixed file count / name length, no unbounded trees (§26.6).
Crash model (Phase 1)Write-through, honest lossA crash mid-write may lose that write; refuse to mount on bad magic (§3.12). Transactional recovery is Phase 3 (§6.3).

3. Why not ext4 / btrfs / a standard filesystem

A standard on-disk format buys exactly one thing - interop (read Linux's disks, let Linux read ours) - and GodspeedOS has no interop requirement (it runs on a dedicated machine/VM, Appendix A.5, storing its own services' state). Against zero benefit, the costs are disqualifying:

  1. They encode identity-based authority. Every ext4 inode carries uid/gid/mode. That is "user X may read this file" - precisely the identity-based authority §3.3 forbids ("All authority is explicit. Capabilities, not identity."). In our world the authority to touch a file is holding the capability to it; those inode fields would be dead, contradictory machinery.
  2. They blow the whiteboard rule. §26.11 makes "explainable by one engineer in 30 minutes" a hard requirement. ext4 is tens of thousands of lines (extent trees, htree directories, journaling); btrfs is ~140k lines of CoW B-trees, checksums, subvolumes, snapshots, RAID. Neither is whiteboardable. Importing either violates the one thing §26 exists to protect - "The Model Is The Product" (§26.1).
  3. "Porting" is really a rewrite (the Appendix D.6 argument, applied to a filesystem): they assume a POSIX VFS, a page cache, kernel locking, errno. You reimplement from the on-disk-format spec - and that format is the part you marry forever.

So: a deliberately minimal filesystem, pulled into existence by what §15 needs (§26.2), not by what "a real filesystem has."

3.1 Why not just FAT (we already need it for the boot ESP)?

FAT is the sharpest version of the question - the UEFI boot ESP must be FAT, so why not use FAT (or exFAT) for data too and skip GSFS? Note first that the §3 permission argument does not apply here: FAT has no uid/gid/mode, so it does not clash with capability authority the way ext4 does. The real reasons:

  1. "We already need FAT" is softer than it looks. GodspeedOS implements zero FAT today - the ESP is built by host tooling (osdev image) and read by Limine; the kernel has never touched a FAT byte. And a minimal, write-once boot ESP (a few static files, or a stamped prebuilt blob) is far simpler than a general read/write/grow/delete data filesystem. Making a boot ESP does not hand us a free general FAT.
  2. A correct FAT is not "simple." It carries 40 years of compatibility cruft - 8.3 names, the VFAT long-filename + checksum scheme, FAT-chain allocation, FSInfo, two FAT copies. A clean inode+directory GSFS is comparable-or-less code than a correct VFAT, and it is ours: our fields (the drives DEFAULT flag + drive label), our evolution path (file-as-capability, §7), no legacy, no exFAT patents.
  3. No interop requirement (§3) - FAT's one real win is universal readability on other OSes, which we do not need.

Reconsider-if: the deciding factor is not "FAT is bad" - it is that we'd have to implement FAT inside the OS anyway and a correct one isn't simpler, plus we have no interop need. If reading GodspeedOS data drives on Windows/Mac/Linux ever becomes a goal, FAT/exFAT deserve a serious second look. Recorded so a future contributor sees the trade was made with eyes open, not by reflex.

3.2 Capacity is the wrong axis - the real difference is features

A natural question is "how much can GSFS hold vs ext4/NTFS/APFS/ZFS?" The honest answer is that capacity is just field width and a poor way to compare:

FilesystemMax fileMax volume
FAT324 GiB2 TiB
exFAT16 EiB128 PiB
NTFS8 PiB8 PiB
ext416 TiB1 EiB
APFS8 EiB8 EiB
ZFS16 EiB256 ZiB (2¹²⁸-addressable)
GSFS (Phase 1, u32 fields)4 GiB (u32 size)2 TiB (u32 blocks × 512 B)
GSFS (today, u64 fields)16 EiB~8 ZiB

GSFS was FAT32-class in Phase 1 only because Phase 1 chose 32-bit fields; widening size + total_blocks to u64 (done, §6.3) was a one-line change that vaults it past ext4. So capacity does not distinguish these filesystems. What ext4/NTFS/APFS/ZFS actually buy is features - journaling (crash-safety), checksums (bit-rot), snapshots, copy-on-write, B-tree metadata that stays fast at millions of files. GSFS deliberately had none of these at Phase 1 - that, not size, was its real trade-off: §26.6/§20 minimalism first, with journaling arriving as the Phase-3 transactional-recovery work (§6.3) only when a real need pulled it in. (2026-10-25: it has since gained a metadata redo-journal (§6.8) and checksums on every block (§6.6, §6.10); it still has no snapshots, copy-on-write or B-tree metadata.)

4. Layering and responsibilities

  clients (supervisor, services)         hold a cap to fs (Phase 1)
        │  ipc: ReadFile / WriteFile / Stat / List / Delete   or, later, a per-file cap
        ▼
  fs service        owns the on-disk FORMAT: superblock, entry table, free bitmap,
        │            name→blob logic, capability minting (Phase 2). No hardware access.
        │  ipc: ReadBlocks(lba,count) / WriteBlocks(lba,data)
        ▼
  block-driver      owns the DEVICE: ATA PIO port I/O. No knowledge of files.
        │  port I/O (hw_pio cap)            Translates LBA ↔ sectors. No DMA.
        ▼
  ATA disk (QEMU if=ide; real disk later)

The line between fs and block-driver is the same line every microkernel draws: policy (file layout) above, mechanism (move sectors) below. block-driver never learns what a file is; fs never touches a port.

5. Block driver - ATA PIO

Note (2026-10-09): §4's diagram and all of §5 are the Phase 1 bring-up backend, which is RETIRED. block-driver is AHCI on x86 (MMIO + a DMA arena, services/block-driver/src/ahci.rs, docs/ahci.md) and a USB stick through the dwc2 or xhci service on every other board (usbdisk.rs, xhciblk.rs). There is no hw_pio capability, no PortRead/PortWrite syscall, no capability/hw_pio.rs and no SDK pio.rs in the tree; the driver's own header says ATA PIO was retired once AHCI proved out. "No DMA" and "least-privilege by construction" therefore describe a driver that no longer exists - see §9's 2026-09-26 correction for the TCB consequence.

5.1 Why ATA PIO

  • No DMA → least-privilege by construction. A PIO driver moves sectors through I/O ports one 16-bit word at a time; it cannot DMA anywhere. So it never holds the DMA-anywhere reach that forced H1 (the IOMMU work) into existence - it is not kernel-equivalent even without an IOMMU. That is a strictly cleaner TCB story than a DMA block driver, and it is what eventually lets block-driver leave the TCB on its own merits (§6.3), independent of IOMMU presence.
  • Simplest correct device (§2.4, §20): no feature negotiation, no descriptor rings, no command lists. Set LBA, issue command, poll status, transfer.
  • Hardware path + stepping-stone: works in QEMU (if=ide, already the boot transport) and potentially on the T630 in legacy/IDE mode; ATA's command set (READ/WRITE SECTORS, LBA) carries directly into a future AHCI driver. (virtio-blk, by contrast, is a QEMU-only paravirtual device that runs on no real hardware.)

The cost - PIO is slow - is irrelevant for v2 persistence (§20: correctness over performance). We optimize transfer width later, never at the cost of clarity.

5.2 The new mechanism it needs: hw_pio

ATA PIO uses port-mapped I/O (ports 0x1F0-0x1F7 / 0x3F6 for the primary channel), not memory-mapped I/O. Today the SDK exposes MMIO (Mmio::read32 etc.) but not port I/O. So Phase 1 adds a small, audited mechanism, mirroring the existing MMIO story:

  • A hw_pio capability in the contract: a port range the driver may touch, validated and granted at spawn exactly like hw_mmio.
  • SDK port-I/O wrappers (Pio::inb/outb/inw/outw/insw/outsw) in an audited sdk/rust/src/pio.rs, each block carrying a // SAFETY: comment - the same isolation §18.1 already grants the MMIO/DMA accessor modules. The driver service itself stays unsafe-free behind the safe wrappers.

The kernel already performs port I/O for the serial console, so the kernel-side cost is modest. This is a real new authority surface; it must be added to §18.1's audited hardware/ABI layer and the unsafe audit, and hw_pio documented as a capability kind.

5.3 ATA PIO read/write (the whole protocol)

  read sector (LBA28, polled):
    outb(0x1F6, 0xE0 | ((lba >> 24) & 0x0F))   # drive 0, LBA mode, top nibble
    outb(0x1F2, count)                          # sector count
    outb(0x1F3, lba & 0xFF)                     # LBA low
    outb(0x1F4, (lba >> 8) & 0xFF)              # LBA mid
    outb(0x1F5, (lba >> 16) & 0xFF)             # LBA high
    outb(0x1F7, 0x20)                           # READ SECTORS
    poll 0x1F7 until (status & BSY)==0 && (status & DRQ)!=0   # or error bit → IoError
    insw(0x1F0, buf, 256)                       # 256 words = 512 bytes
  write sector: same addressing, command 0x30 (WRITE SECTORS), outsw, then CACHE FLUSH (0xE7).

Polled completion in Phase 1 (no interrupt handler) keeps it dead simple; the hw_interrupt cap in the contract can stay unused until we want interrupt-driven completion (a later optimization, not a correctness need).

6. Filesystem - on-disk format

READ THIS FIRST: §6 IS THE ORIGINAL DESIGN, AND THE SHIPPED FORMAT IS NOT IT. What follows describes a flat name-to-blob store with 4 KiB blocks and a fixed 256-entry table. GSFS has been GSFS0008 for a long time: 512-byte blocks, real directories as a tree (§6.2), a free bitmap rather than an entry table (§6.4), self-describing 64-byte records seven to a block with a CRC trailer (§6.6), extent lists for fragmented files (§6.12), and a metadata redo-journal (§6.8). Every one of those numbers below is superseded by the section named beside it.

It is kept because §1's rule holds here as in the constitution - the history of how a format reached its present shape is worth more than a tidy document - but it was NOT marked, and a reader arriving at "§6. Filesystem - on-disk format" reasonably takes the section with that title as the format. That is the §26.7 failure in its documentation form: a claim left standing because it is labelled "proposed" and nearly true, relied on by whoever trusts the document. The label was doing less work than it looked: "Final numbers set in Phase 1" was written before Phase 1, and Phase 1 finished several formats ago.

A flat name → blob store. Proposed geometry (concrete but tunable in Phase 1):

  filesystem block = 4 KiB = 8 ATA sectors      (matches the system's 4 KiB granularity)

  ┌───────────────┬──────────────────┬───────────────┬────────────────────────┐
  │ Block 0       │ Blocks 1..E       │ Blocks E..F    │ Blocks F..end           │
  │ Superblock    │ Entry table       │ Free bitmap    │ Data region             │
  │ magic,version,│ MAX_FILES entries │ 1 bit / data   │ file contents,          │
  │ geometry,     │ fixed-size        │ block          │ block-aligned           │
  │ checksum      │                   │                │                         │
  └───────────────┴──────────────────┴───────────────┴────────────────────────┘

Superblock - magic (refuse to mount if wrong - loud failure, never auto-reformat, §3.12), version, block_size, total_blocks, entry_table_blocks, bitmap_blocks, data_start, and a checksum over the superblock.

Entry (fixed-size, fills the entry table) - name[NAME_MAX], size_bytes, first_block, block_count, generation, flags (in-use / free).

Allocation (Phase 1): contiguous extents. Each file occupies a contiguous run of data blocks (first_block .. first_block+block_count). This is the whiteboard-simplest scheme and a perfect fit for the actual workload - mostly write-once blobs (service binaries, state snapshots). The known limitation is fragmentation and that growing a file may require relocation; both are acceptable Phase 1 and revisited only if a real need pulls (block lists / extents) into existence (§26.2).

Proposed bounds (in the spirit of queue-depth-16, MAX_ENDPOINTS - bounded everything, §26.6): BLOCK_SIZE = 4096, NAME_MAX = 64, MAX_FILES = 256. Final numbers set in Phase 1; the point is they are fixed and stated, not elastic.

SUPERSEDED. The shipped numbers are BLOCK = 512 and NAME_MAX = 38 (services/fs/src/main.rs), and MAX_FILES does not exist at all - a directory is a tree of record blocks, so there is no global file ceiling to state. Only the principle in that last sentence survived; all three numbers did not.

6.1 Why bulk data is chunked and copied (a constitution consequence)

Two invariants shape the data path and are worth making explicit:

  • §8.5: max IPC message is 4 KiB, kernel-copied.
  • §2.5: zero-copy IPC is permanently rejected - so fs and block-driver may not share a buffer; bulk data crosses between them as copied messages.

Therefore a large read (e.g. a 200 KiB service binary) is inherently a sequence of message-sized, copied transfers - fs requests blocks from block-driver in chunks that fit one message (≤ ~4 KiB minus header; Phase 1 may start at a single 512 B sector per message for simplicity, widening later to several sectors per message). This is slower than a shared-buffer design by construction, and that is the accepted cost of the no-shared-memory invariant - a clean illustration of §20 (correctness and clarity over performance) and §26.7 (the copy is the honest, bounded behavior).

6.2 Hierarchical evolution - real directories (adopted; built)

The flat single-entry-table format above shipped in Phase 1 (mount, named files, reboot survival - all working). It was evolved to a real hierarchical filesystem (magic GSFS0002) - flat name→blob isn't realistic for actual use, so GSFS gets real directories from the start. (See docs/drives.md for the user-facing path/addressing model, [N:]label/path/to/file.)

Status: built + QEMU-verified. services/fs is the hierarchical format below (inode table + per-directory blocks + path walking); osdev mkfs formats it; the block-IPC LBA is u64 (§6.3). osdev test blockdev pins it ([GSFS.E] mkdir + nested /etc/motd path-walk) and blockdev-reboot proves the tree survives a reboot (root → /etc → /etc/motd + /greeting walked back from the persisted inode table). Geometry: 256 inodes (64 B each, 32 blocks), one 512-B block per directory (16 entries, name[27]), files = contiguous extents via the bump allocator.

The hierarchical format, still GodSpeed-minimal (bounded, no POSIX permissions - authority is by capability, §3.3 - and no hard links). All capacity-bearing fields are u64 from the start (block counts, file sizes, block pointers, the block-IPC LBA) - see §6.3:

  Superblock (LBA 0): magic "GSFS…", version, block_size, total_blocks:u64,
      inode_table_start/blocks, data_start, next_free_block:u64,
      root_inode, flags (DEFAULT bit), label[N]      ← drives.md: default + identity
  Inode table: fixed array of inodes. Each inode:
      type (free | file | dir), size_bytes:u64, first_block:u64, block_count:u64
  Directory: a file (inode.type = dir) whose contents are entries:
      { name[NAME_MAX], inode_number }               ← name → inode within this dir
  Data region: file contents + directory blocks.

6.3 Capacity: u64 fields from day one (decided)

Phase 1 used u32 for block counts + file size - FAT32-class limits (2 TiB volume, 4 GiB file, §3.2). The hierarchical format widens every capacity-bearing field to u64: total_blocks, next_free_block, inode size_bytes/first_block/ block_count, and the block-IPC lba (ReadBlock/WriteBlock carry an 8-byte LBA, and block-driver/AHCI already use a u64 LBA). These must move together - widening only some unlocks nothing.

The resulting ceiling is effectively unlimited: ~8 ZiB volume (2⁶⁴ × 512 B) and a 16 EiB max file - past ext4, into ZFS territory. The cost is a handful of bytes per superblock/inode; the bound is stated and fixed, not elastic (§26.6). This is the "capacity is field width" point (§3.2) acted on: pick the wide field once, at the format's birth, and never revisit the ceiling.

Why now, not retrofitted onto the flat format: the flat Phase-1 format is being replaced by this hierarchical one, so u64 is baked into the new format at design time rather than churned into throwaway code. u64 is the convention from here on.

  • Path walking: resolve /a/b/c by reading the root inode (a directory), finding a → its inode, reading that directory, finding b, … Each component is one directory lookup. Bounded path depth + entries-per-directory (§26.6).
  • Operations: mkdir (allocate a dir inode + add an entry to the parent), create/write (allocate a file inode + entry), dir (read a directory's entries), read (walk to the file inode, read its extent), cd (resolve a directory, update the session's current-directory inode).
  • Still bounded & loud: fixed inode count, fixed name length, contiguous extents (no reclamation yet, Phase-1 carry-over); bad magic still refuses to mount (§3.12).
  • The label + flags(DEFAULT) superblock fields are what drives uses for drive identity (invariant 11) and the persistent default drive (docs/drives.md).

This supersedes the flat §6 format as the target; the flat version remains the historical Phase-1 record.

6.4 Phase 3 - the scalable format (GSFS0003): free bitmap + self-describing entries, no inode table

Decided 2026-06-14, from first principles. The §6.2 inode-table format shipped and works, but it reaches for two POSIX ideas it doesn't need and one that doesn't scale: the word/concept inode (a metadata "node" - index-node - kept apart from the name, which exists in Unix mainly for hard links, which GSFS doesn't have), and a fixed global inode cap (256) - absurdly small on a 122 GB disk. This phase replaces both.

First-principles structure set - exactly three things on disk:

  1. Superblock.
  2. Free bitmap - one bit per block (set = used). The only global structure, and it is a free map, not a file index. Read on demand (one ~512-byte bitmap block covers 4096 data blocks) + a small "last allocated" hint, so the allocator's RAM footprint is a few hundred bytes regardless of file count. This is not a POSIX reflex - it answers a physical constraint (you cannot cheaply enumerate N allocations by walking the tree or caching every record in RAM) that applies to any OS. Battle-tested because of physics, not because Unix.
  3. The directory tree. A directory is a file (type = dir) whose blocks hold self-describing entries - each entry carries its own metadata, so there is no inode table and no inode number:
    Superblock (LBA 0): magic "GSFS0003", version, block_size, total_blocks:u64,
        bitmap_start:u64, bitmap_blocks:u64, data_start:u64,
        root_first_block:u64, root_block_count:u64,   ← root has no parent → its extent here
        flags (DEFAULT bit), label[N]
    Directory entry (64 B): type u8 (free|file|dir), name_len u8, name[38],
        size:u64, first_block:u64, block_count:u64
    

Why no separate file_record and no global index (the questions that drove this):

  • No separate record - the self-describing entry is the record. Name and metadata travel together; there is no second copy to reconcile (on-disk duplication is what creates inconsistency, so we keep a single on-disk truth).
  • No global index - its two possible jobs are already covered. Finding a file = walk the path from root: the directory tree is the index, hierarchical and distributed (each directory indexes its own children). Free space = the bitmap. Nothing needs a flat global list.
  • A global accelerator (skip the walk for hot paths) would be a performance cache. If a measured need ever pulls one in (§26.2), it lives on disk, rebuildable, disposable, never RAM-resident, never authoritative (the tree stays truth) and visible, not a hidden layer (§26.4). Not built now. (2026-10-20: fs keeps ONE path walk in RAM, the last (WalkCache), so a streamed file is not looked up from the root per chunk. It is not this accelerator: one entry, cleared at every metadata write and when a transaction begins or aborts, never used inside one.)

Bounds (still bounded, §26.6) - but local, not a global wall: the only ceiling is the disk itself (the bitmap). Directories grow (allocate a bigger extent when full), so there is no per-directory entry cap either; name length is fixed (38). Reclamation is intrinsic: delete/overwrite flip bits free; the allocator finds a free run in the bitmap.

Renaming: the metadata record is a file_record (self-justifying - it says what it is), never an inode. "Index node" hid implementation jargon ("node") in a user-facing word.

This supersedes §6.2 as the target. §6.2 (GSFS0002, inode table) and the flat §6 (GSFS0001) remain the historical record.

6.5 fs_index - the deferred global index (built when search pulls it in)

Specified 2026-06-14, deliberately NOT built (§26.2). A design the conversation converged on and want on record, so the day it's needed it snaps on cleanly.

Two distinct things, two names - keep them apart:

  • file_record - the per-file/dir metadata: the self-describing directory entry {type, name, size, first_block, block_count} of §6.4. One record per file, lives in the tree. This is the unit (never inode - "index node" smuggled impl-jargon into a user word; file_record says what it is).
  • fs_index - a global index built over all file_records, for fast whole-filesystem enumeration. This is the aggregate. (fs_index, not fs_record: "index" is the honest word for an aggregate lookup structure and doesn't collide with the per-file file_record; it also keeps the half of "inode" that made sense - index - and drops the half that didn't - node.)

Why it's deferred. The three GSFS0003 structures already serve every current operation: mount (superblock + bitmap), path lookup (walk the path), dir (one directory), free space (the bitmap). The only thing fs_index accelerates is whole-FS enumeration - a find, a global search, an "every file" view - which GodspeedOS did not have when this was written. So by §26.2 it is built the day such a command pulls it into existence, not before. (2026-10-25: the shell's find exists now, and it walks the tree directly; no walk has yet been measured too slow, so fs_index is still not built.)

How it works when built (the part worth recording now):

  • Single owner, single-threaded → no concurrency to reconcile. fs serves one IPC request at a time; "operations in different places" are just different clients, but they queue at fs and run serialized. No structure is ever mutated in parallel - no races on the tree, the bitmap, or the index (the §8-ownership simplifier).
  • The truth - tree + bitmap - is ALWAYS strongly consistent. fs updates them inline, per op, before serving the next request. A million ops = a million serialized; there is never an eventual window on the truth. Any reader needing the authoritative answer reads the tree, always current.
  • fs_index is lazy, version-invalidated, rebuilt-from-truth - and that is the eventual part, safely. Writes do not touch the index; each mutation just bumps a cheap tree-version counter, so the hot path is untaxed even under a million ops. When the index is actually read (a find), fs compares versions; if stale, it rebuilds the index by walking the tree once - bounded by tree size, not by the churn that invalidated it. Eventual consistency is harmless here because the index is never authoritative: a fresh index, a stale index, or a fallback to the tree all yield a correct answer. (This refines an earlier "update inline on every write" framing - taxing every write to keep a rarely-read index live is the wrong trade; mark-stale + rebuild-on-read keeps writes fast and is still always-correct.)
  • No events, interrupts, or new caps. fs is the sole mutation point, so "the index changed" is just "fs did an op + bumped the version." Crash repair is the same mechanism: a stale/dirty version → rebuild from the tree.
  • On disk, disposable, rebuildable, non-authoritative, and visible (§26.4) - never RAM-resident (a trillion 64-byte records won't fit), never the source of truth, never a hidden layer.
  • Cross-instance falls out for free. Unplug a drive, replug into another GodspeedOS instance: it reads fs_index, trusts it if the version matches, rebuilds from the tree if stale - a fast remount without re-walking the whole tree.
  • Escape hatch for a giant tree: if even the rebuild-on-find walk ever gets too slow, then add incremental maintenance - an append-only change log + a background applier that drains it into the index (classic write-ahead-log eventual consistency). A heavier machine pulled in only by a measured need (§26.2), not now.

Specified, not built. GSFS0003 ships the three structures (§6.4); fs_index is the layer that snaps on when search arrives.

6.6 GSFS0004 - integrity checksums (the "robust filesystem" Phase A)

Built 2026-06-17. The first phase of the robustness program (crash-consistency + large files + checksums + restartability - all four selected). GSFS0004 keeps the three GSFS0003 structures and the whole directory-tree/bitmap design unchanged; it adds integrity checksums and bakes the final geometry once so later phases add behaviour, not new on-disk formats.

What changed vs GSFS0003 (magic GSFS0004, version 4):

  1. Superblock CRC32 - a sb_crc32 (u32 @124) over the superblock's first 124 bytes, verified on mount. A corrupt superblock is a loud refusal (§3.12), never trusted or silently repaired - alongside the existing bad-magic refusal. Re-stamped on every superblock write (persist_super). (2026-10-09: the CRC has since moved to @136, SB_CRC_OFF, widened to cover the GSFS0008 feature masks at @124..@136 - §6.15.)
  2. Per-directory-block CRC32 - each 512-byte directory block now holds 7 file_records (was 8) and reserves its last 64 bytes as a trailer; the first 4 trailer bytes are a CRC32 over the block's 448-byte record region. Verified on every directory read (Fs::td_read) and stamped on every write (dir_write), so corruption in the metadata that defines the tree surfaces loudly instead of returning garbage records. The file_record layout is otherwise unchanged - names stay 38 bytes. (The other 60 trailer bytes, 452..512, went unused until Phase O put timestamps there - past this CRC and under one of their own, so nothing above is invalidated. See §6.17.)
  3. Reserved journal region - journal_start/journal_blocks (u64 @108/@116) carve a fixed 64-block (32 KiB) region between the bitmap and the data region. Empty and unused in Phase A; it is where Phase C's crash-consistency redo-journal lives. Baking it now means the on-disk geometry is fixed once, not churned across phases.

Scope of Phase A checksums: the superblock and the directory tree (the structural metadata whose corruption breaks the whole filesystem). Bitmap blocks and file data blocks are not yet checksummed - a corrupt bitmap is reconstructible from the tree, and per-file data integrity is a deferred follow-on (a per-file-record data CRC), pulled in only when a need does (§26.2). The CRC32 is the standard IEEE 802.3 algorithm (services/fs/src/crc32.rs); osdev carries a byte-identical copy (osdev/src/crc32.rs) so host-baked images (mkfs, script-disk) checksum exactly as fs would.

Migration: GSFS0004 is reformat-only - there is no GSFS0003→0004 upgrader. There is no interop requirement (§3) and flash is user-initiated, so an existing GSFS0003 data drive is simply re-flashed. (Mounting a GSFS0003 image under GSFS0004 fails loudly on the magic check, as it should.)

This supersedes §6.4 (GSFS0003) as the on-disk target; §6.4/§6.2/§6 remain the historical record. The remaining robustness phases (B large files, C crash-consistency, D restartable/TCB-drop) build on this format.

6.7 Large files - streaming offset-addressed I/O (Phase B)

Built 2026-06-17. Phase B of the robustness program. The 3584-byte file ceiling was never an on-disk limit (a file is already a contiguous u64 extent; block-driver moves one sector per IPC) - it was the client↔fs message boundary: the whole file rode in one ≤4 KiB IPC message and one stack buffer. Phase B lifts it with a streaming API, no on-disk format change.

Mechanism - stateless offset-addressed ops (fit the single-owner, per-request model, §8; no open-file/session state):

  • WriteNew(path, total) (op 24) - create/truncate path to a file sized total bytes, allocating the whole contiguous extent up front (record size = total; data filled next).
  • WriteAt(path, offset, chunk) (op 25) - write chunk at a block-aligned byte offset; whole blocks only (the last block of a partial chunk is zero-padded), so no read-modify-write. Bounded to the file's extent - a write past it is a loud error.
  • ReadAt(path, offset, len) (op 26) - read up to len bytes from offset (clamped to size).

A large write = WriteNew then a sequence of WriteAt chunks; a large read = StatFile (for the size) then a sequence of ReadAt chunks. The streaming chunk is MAX_FILE_BYTES (3584) - now the per-message size, no longer a file cap. The one-shot WriteFile/ReadFile remain for small files.

Clients. The shell streams read, copy (incl. recursive), and the pipe write sink through these ops - so it views/copies files far larger than one message with only a single chunk buffer (important given the shell's tight stack). run (which must hold a whole script to parse) stays on the one-shot path, bounded by what it can hold; the embedded selfcheck continues to run from rodata, not from a disk file.

Crash behaviour (until Phase C). A large write is many separate writes; a crash mid-write leaves a partial file - the same honest-loss as any write today. Phase C's journal makes the metadata commit atomic.

Verified: osdev test fs-large writes + reads a 200 KiB file in streaming chunks and re-reads it across a reboot (boot 1 writes, boot 2 re-verifies on the same disk); files 130/0 and script 2/2 confirm the shell read/copy streaming is regression-free.

6.8 Crash-consistency - the metadata redo-journal (Phase C)

Built 2026-06-17. Phase C of the robustness program - the headline property: any single power loss leaves the filesystem consistent. Every metadata mutation runs as one atomic transaction through the reserved journal region (§6.6); a crash either leaves the filesystem entirely unchanged or, once a transaction has committed, is replayed to completion on the next mount. Realizes the CLAUDE.md §15 promise of transactional metadata recovery (the TCB-drop that this unlocks is Phase D).

The transaction. A metadata-mutating op (write, mkdir, mkdir -p, rename, move, delete, label) STAGES all its structural block writes (directory blocks, bitmap blocks, superblock) in an in-memory buffer instead of writing them to disk - with read-your-writes, so the op sees its own staged changes. commit_txn then:

  1. writes the staged blocks into the journal region;
  2. writes a checksummed commit record (magic + the block count + the home LBAs + a CRC of the staged payload + a CRC32 over all of that at @508, COMMIT_CRC_OFF) - the atomic point;
  3. checkpoints each staged block to its home LBA;
  4. invalidates the commit record.

File data blocks are written directly (not journaled): they land in a freshly-allocated extent that nothing references until the metadata transaction commits, so a crash before commit just leaves harmless garbage in free space.

Recovery (at mount, before serving). Read the commit record: a valid magic and CRC means a committed-but-unfinished transaction → replay its blocks to their home LBAs (idempotent) and invalidate. An absent/torn commit (no magic, or a CRC mismatch) → do nothing. So a crash before the commit record is discarded (home untouched); a crash after it is completed. There is no third outcome on a backend that attests durability (see the note below). (2026-10-09: recovery also re-reads the staged blocks and checks them against the payload CRC before applying any of them. A mismatch applies nothing and leaves the journal intact for the next mount, with a loud log saying the tree is not known consistent - Fs::recover.)

Backends that cannot be ordered (2026-07-25, corrected 2026-09-23). The rule is BACKEND-specific rather than architectural: a device that refuses or lies about a flush cannot enforce the journal-data -> commit -> checkpoint ordering, so commit_txn asks explicitly (three barriers) and fs warns once per mount when the answer is no.

The ARM USB backend was named here as the example, and it is not one. That claim was never tested when it was written; when it finally was, the device accepted SYNCHRONIZE CACHE across seven sessions and an unassisted power cut on the Pi 2 landed inside the commit window and REPLAYED - journal recovered 4 block(s), nothing torn, nothing repaired. So no board in this project is currently known to be an unorderable backend. The rule stands and is worth keeping; the example is withdrawn. CLAUDE.md §6.1 (amendments 2026-07-25 and 2026-09-23) carries the constitutional form, and fs-lyingflush models an unorderable device in QEMU so the case stays tested without one.

Why ordered durability is free. block-driver flushes every sector write to the medium before replying (FLUSH EXT), and fs serializes requests, so the journal-data → commit → checkpoint ordering the protocol needs holds without any extra barrier op. (2026-10-09: no longer true, and the paragraph above is history. Since 2026-08-22 ahci issues FLUSH CACHE EXT only when fs sends OP_FLUSH, and commit_txn sends it at its barriers (durable_or_warn) - staged blocks before the commit record, the commit record before any home block moves. The ordering rests on those explicit barriers, not on a flush per sector. CLAUDE.md §6.1, the 2026-07-25 amendment as amended.)

Bounds (§26.6). A transaction stages at most TXN_CAP (56) blocks - comfortably more than any single op needs; an op that would exceed it fails loudly rather than committing partially. delete of a whole subtree (delete_tree) is the one unbounded case: it commits the unlink as one atomic transaction (after which the subtree is unreachable), then reclaims the subtree's blocks in bounded per-extent transactions - a crash mid-reclaim only leaks blocks (safe, never corruption).

Verified: osdev test fs-journal (11 checks). Part 1 (REPLAY): a journal-crash-test build writes a file through a transaction that halts right after the commit record is durable (simulated power loss); the next boot's mount replays it from the journal and the file is present with the exact bytes. Part 2 (REJECT): a journal whose commit record has a bad CRC is ignored - the fs mounts clean and serves normally, no spurious replay. All five earlier suites (files, blockdev-reboot, fs-large, fs-corrupt, script) stay green with the journal active on every mutation.

6.9 Restartable - fs + block-driver leave the TCB (Phase D)

Built 2026-06-17, with a CLAUDE.md §6 amendment (signed off). The §6.3 / §9 goal: with crash-consistent recovery in hand (§6.8), fs and block-driver no longer need to be trusted root - their death becomes a supervisor restart, not a panic+reboot. The non-restartable set shrinks to just init + supervisor + kernel.

Mechanism (mirrors the kernel name-directory recovery path).

  • The kernel's death path notifies the supervisor for fs and block-driver (sending the dead service's name); nothing on that path treats them as unkillable, so killing fs/block-driver never panics. (2026-10-09: no longer by name - the kernel notifies for any task the supervisor spawned with SPAWN_FLAG_WATCHED, which it sets from its MANAGED roster; CLAUDE.md 12.3, 2026-10-03.)
  • The supervisor's death-notification loop respawns the named service. block-driver respawns before fs (fs's send-peer cap to it wires from the kernel name table at spawn).
  • fs and block-driver are recorded in the kernel name directory at spawn, so clients can reacquire a fresh cap by name after a restart.
  • On restart, fs re-mounts - replaying the journal if the crash interrupted a commit (§6.8) - so it always comes back consistent. The persisted data is intact.
  • Clients reacquire on EndpointDead or ReplyDead (§14.3), and re-send only what is safe to repeat. fs's block I/O (block_rpc) reacquires block-driver: it is a synchronous request/reply bounded at 30 s (request_with_reply_deadline_outcome_into), so if block-driver dies after receiving the request but before replying, the kernel wakes fs with ReplyDead (the reply-side twin of EndpointDead, §8.6) instead of hanging it on a reply that will never come - fs then reacquires block-driver by name and retries once, as for a failed send, because a block transfer of the same sectors is safe to repeat. A reply that does not come within the 30 s is NOT re-sent: the request may still be in flight, and a second one would desync the replies. The shell's fs_request reacquires fs and retries read-only operations only: a mutating op (op_is_mutating: write, mkdir, rename, delete, move) whose reply was lost is reported as possibly done and never re-sent, because a repeat can repeat a destructive operation. The next command covers the window before the service has re-registered.

Verified: osdev test fs-restart (§22 Test 13, 7 checks) - the shell writes a file, KILL fs over the control channel, the supervisor respawns fs, fs re-mounts + re-registers, and the shell reacquires fs and reads the file back; the kernel never panics. The journal suite and all file suites stay green.

This completes the four-part filesystem robustness program (A integrity, B large files, C crash-consistency, D restartable). The earlier "Phase 1/Phase 3 TCB trajectory" framing in §9 is now realized.

6.10 Data-block checksums - every block self-verifies (GSFS0005)

Built 2026-06-17. Phase A checksummed the structural metadata (superblock + directory tree); file data blocks were the one thing still unchecksummed (a noted follow-on). This closes it: each file-data block now carries its own CRC32, so silent bit-rot in file content is caught loudly on read (§3.12) - exactly like the superblock and directory blocks.

Format change (magic GSFS0004 → GSFS0005, reformat-only). A file-data block is now 508 bytes of payload + a 4-byte CRC32 trailer @508 (mirroring the directory-block trailer). A file of N bytes spans ceil(N/508) data blocks; each block's CRC covers its own 508-byte payload, verified on every read (data_read) and stamped on every write (data_write). This makes every block in the filesystem self-verifying - superblock (CRC @124, @136 since GSFS0008 - §6.15), directory block (CRC @448), data block (CRC @508) - a uniform, whiteboardable model with no side structure and no read-modify-write.

Consequences, all localized:

  • The per-message streaming chunk is now 7 × 508 = 3556 bytes (MAX_FILE_BYTES, and the shell's IO_CHUNK) - 7 whole data-block payloads, so a streaming WRITE_AT offset stays block-aligned (no RMW). The offset-addressed ops and the shell streaming loops are otherwise unchanged.
  • Data writes stay direct (not journaled), so the Phase-C crash model is unchanged: a pre-commit crash leaves the data (and its CRCs) in an unreferenced extent - harmless.
  • ~0.8% space overhead (4 bytes per 512). The host baker (osdev gsfs_add_file) writes data the same 508-payload-+-CRC way, so baked files (script-disk, mkfs) verify identically.

Scope. Bitmap blocks remain unchecksummed - they are reconstructible from the tree, and a bitmap bit-flip is a space-accounting error, not data loss; a dedicated bitmap CRC is a future add only if a need arises (§26.2).

Verified: osdev test fs-corrupt case 3 (now 11 checks total) - bake a file, flip a payload byte in its data block, boot: fs logs a "data block CRC mismatch" and the read is refused (never returns the corrupt bytes), no panic. files 130/0, fs-large, fs-journal 11/0, fs-restart 7/0, script 2/2, blockdev-reboot all stay green with 508-byte data blocks.

6.11 Further robustness - roadmap (recovery layer)

Adopted 2026-06-17. Phases A-E gave the filesystem detection (every block self-verifies), crash-consistency (the metadata journal), restartability, and persistence - all hardware-proven on the T630. The remaining robustness work is mostly the next layer: recover after detection, and remove the single fatal block. These are pulled in cheap-first; the heavy items stay deferred until a real need pulls them (§26.2).

Doing now (cheap, high value):

  • Phase F - Backup superblock (GSFS0006). ✅ Built + verified 2026-06-17. The superblock is a single block at LBA 0; if its CRC fails the whole volume won't mount. A second copy at the last LBA (total_blocks-1, reserved in the bitmap at format) lets mount fall back to it when the primary fails - and heal the primary from it. The backup is located via the block-driver's reported capacity, so it works even when the primary is unreadable (no chicken-and-egg). format writes both; persist_super keeps them in sync (staged in the same transaction, so they commit atomically); drives reset wipes both. Removes the single most catastrophic point of failure for ~no cost. Reformat-only bump 0005→0006. Verified by osdev test fs-corrupt (now 14 checks): corrupt the primary → recovers from backup; corrupt both → loud refusal. No regression (files 130/0, fs-journal 11/0, fs-restart 7/0, drives 9/0, script 2/2, blockdev-reboot).

  • Phase G - drives check (fsck / repair). ✅ Built + verified 2026-06-17. A CRC failure is detected loudly but was not recoverable, and a drifted bitmap/free-count had no repair. Because the directory tree is the source of truth, fsck falls out: zero the bitmap, mark the system blocks + backup, then walk the tree marking every referenced extent - rebuilding the free bitmap + free count from the tree (healing drift) - verifying every block's CRC and reporting (not deleting) files/dirs that fail, and refreshing both superblock copies via persist_super. Turns "detected, dead" into "detected, repaired." A new drives check shell command (op OP_CHECK); no on-disk format change. Writes are DIRECT (not journaled): the rewrite is larger than one transaction and idempotent, so a crash mid-check is harmless (the tree is still truth; re-running converges). This subsumes a dedicated bitmap-block CRC - if the bitmap is rebuildable from the tree, checksumming it is redundant (§6.10's deferred bitmap-CRC is folded into fsck, not built separately). Verified by osdev test fs-check (5/0): drift the free count host-side (both copies, CRC re-stamped), boot, drives check rebuilds the correct value, 0 bad, the file survives. the selfcheck suite also runs assert ok drives check on a populated tree (in the script test). No regression (files 130/0).

  • Phase H - block I/O error handling. ✅ Built + verified 2026-06-17. A failed block read/write just failed the op. Now block-driver issues every read/write/zero through issue_io, a bounded retry (MAX_IO_ATTEMPTS = 3) with port recovery between attempts (recover_port clears PxSERR/PxIS and restarts the command engine if it halted): a transient error (marginal sector, controller hiccup) is recovered transparently and logged; a persistent one is reported loudly (§3.12) and returns an error. fs also logs the failing LBA on a block error. Bounded (§26.6) - never an infinite retry. No format change. Verified by osdev test fs-ioretry (5/0): a io-error-test build forces the first read/write commands to fail (QEMU never fails a real disk), and the driver retries + recovers (the boot self-test read still succeeds; fs mounts + round-trips), no panic. The persistent-failure branch (report + Err after N attempts) is the same loop's exhaustion path. No regression (files 130/0, reboot).

  • Phase I - Extent lists (GSFS0007). ✅ Built + verified 2026-06-17. Files were a single contiguous run, so a big file needed a big contiguous free span - fragmentation could refuse a write the disk had room for. Now a file is contiguous (ITYPE_FILE, the fast path, byte-for-byte unchanged) or, when no contiguous run is free, fragmented (ITYPE_FILE_FRAG): its first_block points to a single CRC'd extent block (block_count = 1) that lists up to EXT_MAX = 31 scattered (start, len) data runs. So a file is stored across whatever free space exists. Full §6.12. Reformat-only bump 0006→0007. Verified by osdev test fs-frag (11/0): fill a small disk, delete every other file to scatter free space into ~2-block gaps, write a 20-block file that cannot fit contiguously → it lands fragmented, reads back exactly, and survives a reboot. No regression (files 130/0, fs-large 1/1, fs-journal 11/0, fs-check 5/0, fs-ioretry 5/0, fs-corrupt 14/0, fs-restart 7/0, drives 9/0, script 2/2, blockdev-reboot).

  • Phase J - Opt-in data journaling. ✅ Built + verified 2026-06-17. The metadata journal made every structural mutation atomic, but streaming write_at wrote data direct with no transaction - a crash mid-chunk left torn data (caught by the data CRC on read, but not recovered). Phase J adds a journaled write_at variant (OP_WRITE_AT_J) that stages the chunk's data blocks in a transaction so the chunk commits atomically - a crash replays or discards it whole, never torn. Opt-in per write: the caller picks the contract; default WRITE_AT stays direct (the fast path). Bounded to one chunk (≤ 7 blocks) by the 64-block journal - whole-file atomicity across chunks is out of scope and stays honest (§20/§26.6). (WriteFile and overwrite were already crash-atomic - copy-on-write to a fresh extent, data flushed before the metadata commit - so only the streaming path had a torn-data window.) Full §6.13. No on-disk format change, no amendment. Verified by osdev test fs-djournal (7/0): a journaled write_at halts right after its commit record (data only in the journal, home blocks still zero), and the next boot replays the data home - a correct read proves the journal supplied it. No regression (fs-large 1/1, fs-frag 11/0, fs-journal 11/0, files 130/0, script 2/2).

  • Phase K - Scrub (read-only integrity sweep). ✅ Built + verified 2026-06-17. Every block already self-verifies on read, but rarely-read blocks could rot undetected until you needed them. drives scrub (OP_SCRUB) walks the tree and verifies every referenced block's CRC, reporting (files, dirs, bad, scanned) and changing nothing - distinct from check, which repairs the bitmap (writes). Read-only ⇒ safe to run at any cadence on a healthy filesystem. Without redundancy (no RAID, by design) scrub detects bit-rot but cannot repair it: a bad block is reported and any read of it stays a loud refusal (§3.12), the data is already lost. The cadence is operator-driven - GodspeedOS has no background-task primitive, so "periodic" is policy (run drives scrub on a schedule), not a hidden timer (§26.4: no silent complexity). Full §6.14. No format change, no amendment. Verified by osdev test fs-scrub (6/0): a corrupt data block is reported (1 bad), a second scrub still reports it (read-only - no repair), and the clean file is untouched; the selfcheck suite also runs assert ok drives scrub on a clean tree.

Deferred (heavier; pulled in only when a real need arises, §26.2 / §26.11):

  • Snapshots / copy-on-write / mirroring (RAID) - real features, but each strains the 30-minute-whiteboard rule (§26.11); revisit only with a concrete need. (RAID would also let scrub repair, not just detect - but it needs a second disk and is out of scope.)

6.12 Extent lists - files across scattered free space (GSFS0007)

Adopted 2026-06-17 (Phase I). Lifts the contiguous-extent fragmentation limit (§6.11 roadmap) while leaving the common case untouched.

Through GSFS0006 a file was always a single contiguous run: first_block .. first_block + block_count. Simple and fast, but it meant a file needed a contiguous free span as large as itself - on a fragmented disk a write could be refused for lack of one big run even when the total free space was ample. GSFS0007 removes that limit with a minimal, opt-in second representation, chosen so the existing fast path is byte-for-byte unchanged:

  • Contiguous file - ITYPE_FILE (= 1), the fast path. Exactly as before: the record's first_block/block_count are the data extent; the n-th data block is first_block + n. Every existing test exercises this path, and the allocator still tries it first.
  • Fragmented file - ITYPE_FILE_FRAG (= 3). Engaged only when no contiguous run is free (alloc_run returns "no space"). The record's first_block points to a single extent block and block_count is 1. The extent block is a normal 512-byte block: n_extents:u32 @0, then up to EXT_MAX = 31 (start:u64, len:u64) runs from @8, and a CRC32 @508 over [0..508) - verified on every read, loud refusal on mismatch (§3.12), exactly like a data or directory block. The n-th data block is found by walking the runs.

Allocation (alloc_file). Try one contiguous run (fast path). On failure, alloc_extents grabs free runs greedily (find_free_run scans the bitmap; take = len.min(need)) until the request is satisfied - bounded to EXT_MAX runs, beyond which the write is refused loudly (§26.6) - then one more block holds the CRC'd extent block. A file fragmented into more than 31 pieces is rejected, not silently mishandled. Freeing (free_file) mirrors it: a contiguous file frees its one run; a fragmented file frees each listed run, then the extent block.

Where it plugs in. The extent block is metadata, so it is staged in the crash-consistency transaction (ext_write via tb_write) and commits atomically with the record and bitmap - a pre-commit crash leaves the data blocks unreferenced and the bitmap unchanged, identical to the contiguous case (§6.8). read_path/read_at/write_at resolve the extent list once per op when fragmented (a contiguous file maps by arithmetic, zero overhead), and delete / recursive delete_tree / drives check (fsck) all walk the runs so blocks are freed, verified, and accounted for whichever representation a file uses. The host writer (osdev) only ever bakes into a fresh disk, so it always produces contiguous files; the fragmented path is fs-only.

Bounds & honesty (§26.6). One extent block ⇒ ≤ 31 runs per file - a hard, loud ceiling, not an unbounded indirection tree. This is deliberately not a btree/multi-level extent map: it fixes the fragmentation limit that can actually bite while staying inside the 30-minute whiteboard rule (§26.11). A file too fragmented for 31 runs fails loudly; drives check can report it. Verified by osdev test fs-frag (11/0) + full regression green.

6.13 Opt-in data journaling - atomic streaming chunks (Phase J)

Adopted 2026-06-17 (Phase J). Closes the one torn-data window left by the §6.8 model - the streaming write_at path - without pretending to whole-file atomicity the 64-block journal cannot give.

The crash-consistency journal (§6.8) makes every metadata mutation atomic, and the common file operations are already crash-safe for data too - almost by accident of the design:

  • WriteFile allocates a fresh extent, writes its data (flushed), then commits the metadata. A crash before the commit discards the metadata and leaks the data; after it, the data was already durably home. The file either fully appears with correct bytes or not at all.
  • Overwrite is copy-on-write: a new extent is filled, the record is re-pointed, the old extent freed - so the same "data-before-commit" ordering holds, and the old file survives a crash intact.

The exception is the streaming large-file path: write_new commits the metadata (file sized, extent allocated) up front, then a sequence of write_at chunks fills the data direct, outside any transaction. A crash mid-chunk leaves some blocks new and some stale - torn data, caught by the per-block CRC on read (a loud refusal, never silent - §3.12) but not recovered.

Phase J makes that recoverable, opt-in per write:

  • OP_WRITE_AT_J - a journaled write_at. It stages the chunk's data blocks in a transaction (data_stage: stamp the CRC, tb_write into the staged set) and commits through the journal, so the chunk's data rides the same atomic mechanism as metadata: commit_txn writes it to the journal, lands the checksummed commit record (the atomic point), checkpoints it home, and invalidates. A crash before the commit record discards the chunk; after it, the next mount's recover replays it home. The chunk is applied whole or not at all - never torn.
  • Default WRITE_AT stays direct - the fast streaming path is unchanged. The caller chooses the guarantee: pay the double-write (journal + home) for atomicity, or take the fast path and rely on the read-time CRC. The durability contract is explicit, not hidden (§26.5/§26.7).

Bounded & honest (§26.6/§20). The journal is 64 blocks (TXN_CAP = 56 staged), and a chunk is at most 7 × 508 payload bytes (7 data blocks), so a journaled chunk always fits - but whole-file data journaling does not, and Phase J does not pretend otherwise. The guarantee is per-chunk atomicity; a multi-chunk file written with OP_WRITE_AT_J is a sequence of individually-atomic chunks, not one atomic file. That is the honest ceiling of a fixed 64-block journal, stated plainly rather than papered over. Verified by osdev test fs-djournal (7/0): a journaled write_at halts right after its commit record - the data is only in the journal, the home blocks are still zero - and the next boot replays it; because a zero block fails the data CRC, reading the file back correctly can only mean the journal supplied the data. No on-disk format change; no constitutional amendment (within §15/§26.6, no TCB or §7 change).

6.14 Scrub - a read-only integrity sweep (Phase K)

Adopted 2026-06-17 (Phase K). Turns the per-block CRC from a read-time guard into a proactive one, honestly bounded by what GodspeedOS actually has.

Every block self-verifies on read (§6.6/§6.10): a corrupt block is a loud refusal, never silent garbage. But a block you never read can rot undetected until the day you need it. A scrub closes that window: read everything periodically and verify it, so bit-rot surfaces early.

drives scrub (OP_SCRUB) walks the directory tree from root and verifies every referenced block's CRC - directory blocks (td_read), extent blocks and the data runs they list (ext_of + data_read), and contiguous file data (data_read) - reporting (files, dirs, bad, scanned). It writes nothing: scrub/scrub_subtree are &self, no transaction. This is the deliberate difference from check (fsck, §6.11 Phase G), which repairs - rebuilds the bitmap and free count (and so must write). Scrub only detects and reports, which makes it safe to run routinely on a healthy mounted filesystem without churning the disk.

Two honesty points the constitution forces (§26.4/§26.7):

  • Detect, not repair. GodspeedOS has no redundancy (no RAID - §6.11, deliberately out of scope), so there is no second copy to heal a rotted block from. Scrub reports N bad; the data is already lost and the read stays a loud refusal. (RAID is exactly what would let scrub repair - and exactly what the whiteboard rule keeps out for now.)
  • Operator-driven cadence, not a hidden background task. "Periodic scrubbing" in other systems rides a background scheduler GodspeedOS does not have, and inventing a silent kernel/daemon timer would be precisely the hidden complexity §26.4 forbids. So the mechanism is a plain command and the cadence is policy: an operator (or a script on a timer they control) runs drives scrub. The trigger is explicit and visible, which is the point.

Verified by osdev test fs-scrub (6/0): a disk with one clean file and one whose data block was flipped host-side - scrub reports 1 bad, a second scrub still reports 1 bad (proving it is read-only and repaired nothing), and the clean file is untouched. the selfcheck suite runs assert ok drives scrub over a populated clean tree (the 0-bad path). No on-disk format change; no constitutional amendment (within §15/§26.6, no TCB or §7 change).

6.15 Versioning & compatibility - feature flags, and the last magic (GSFS0008)

Adopted 2026-06-18 (Phase L). The final monolithic format bump. After this, the format evolves by feature bits, not by minting a new magic - so a version difference no longer forces a reformat.

The problem. Through GSFS0007 the 8-byte magic did two jobs at once: "is this our filesystem?" and "which exact version?" Because all 8 bytes changed on every bump, any version difference failed the magic check, which made every bump reformat-only - upgrade the OS and your old disk won't mount. Fine for a learning OS with no production data, but it doesn't scale: there is no "GSFS00014" worth minting.

The fix (the ext/btrfs model). Separate identity from capability:

  • The magic is FROZEN at GSFS0008. It answers only "is this GSFS?" It never changes again.
  • Three feature-mask u32s in the superblock (@124 compat, @128 ro_compat, @132 incompat, all under the widened CRC @136) answer "what does this disk use?" Each bit is a feature, set on disk only when actually used. Future capabilities are new bits, not new magic.

The mount policy - explicit and loud (invariant 12), the opposite of a silent fallback:

MaskA bit this build doesn't recognise means…
compatmount read-write normally - ignoring the feature is safe
ro_compatmount READ-ONLY - reading is safe, but writing could corrupt the feature (every mutating op is refused loudly; reads pass)
incompatREFUSE to mount, loudly - the on-disk structure is fundamentally different

This is more honest than the old scheme, not less: an old build meeting a newer disk gets a precise reason (refuse / read-only / fine) instead of a version mismatch indistinguishable from a foreign disk.

The bits defined so far. compat bit 0 = a backup superblock exists (Phase F). incompat bit 0 = some file is fragmented, so an extent list must be read to find its blocks (Phase I). ro_compat bit 0 = some file on this volume is SEALED (Phase O, §6.17).

Features earn forward-compatibility by being lazy. The bit is set only when the feature is exercised. Extent lists (Phase I) are an incompat feature, but the FEAT_INCOMPAT_EXTENTS bit is set the first time a file actually fragments - a freshly-formatted disk that never fragments anything has the bit clear, so a build that predates extents could still mount it. You only lose compatibility once you actually use the new capability. (Backup-superblock, Phase F, is a compat bit - an old reader ignoring it is harmless.)

The honest caveat. This is forward-looking from 0008. It cannot retroactively rescue pre-0008 disks (0003-0007 predate the masks, and 0004/0005 genuinely changed per-block layout), so those need one last reflash. But 0008 is the last time the format ever forces that - every future GSFS feature is additive, governed by the masks. "GSFS00014" never happens; it would just be "GSFS0008 with more feature bits set."

Verified by osdev test fs-compat (12/0): three disks, each carrying an unknown bit in a different mask (CRC re-stamped so they still validate) - the unknown incompat disk is refused, the unknown ro_compat disk mounts read-only (reads work, writes refused), the unknown compat disk mounts normally read-write. No regression across the whole suite.

6.16 Commandment VIII, completed in the fs - the mount loop's failure-truth exit + AHCI self-heal

Built 2026-07-09. Robustness surfaced by a real T630 run: a rapid second kill all-services (the whole restartable set killed twice within ~6 s) wedged the storage stack for good - not a crash, a permanent thrash. Three defects compounded, and fixing them completes Commandment VIII (wait for truth, not time; and the truth must include failure) in the fs. No on-disk format change, no amendment.

The wedge. After the second respawn, fs's read of the superblock (LBA 0) returned an I/O error and fs proceeded with a garbage capacity: it accepted a stale block-driver reply as the disk's sector count (a nonsense value near 2^62), computed a backup-superblock LBA far past the end of the disk, and asked block-driver to read it. The AHCI controller could not read a nonsense LBA - its command-issue bit (PxCI) stuck without setting BSY, so recover_port's BSY-only recovery never escalated - and the mount loop retried the impossible read forever. The keyboard looked dead because the whole system was buried under an infinite storage thrash. (The trigger only reached because the shell's startup history read - since made lazy, see docs/console-service.md - fired into the re-init; but a masked race is still a race, so the storage stack itself is hardened here.)

The three fixes:

  1. AHCI self-heals a CI-stuck controller. recover_port now escalates its COMRESET on PxTFD.BSY != 0 || PxCI != 0, not on BSY alone. A stuck PxCI is hardware-owned - a soft recovery (clearing PxSERR/PxIS) cannot clear it; only a port COMRESET does. So a command that wedges the controller without setting BSY (exactly the garbage-LBA case) now self-heals instead of retrying into the identical stuck state. This extends the §6.11 Phase-H recovery (issue_io's bounded retry) to the one failure mode it did not cover.
  2. fs rejects a mis-shaped capacity reply. block_capacity now accepts the block-driver's answer only if it is exactly [BLK_OK, sectors:u64] (a bounded, precise shape) with a sane count. A stale or malformed reply - one whose leading byte merely happens to be BLK_OK - is rejected (returns "unknown"), never misread as a capacity; read_superblock then bounds-checks the capacity before deriving the backup-superblock LBA from it. The false success that fed the garbage read is gone: fs waits on the right reply, not merely on a reply. (This scopes out the specific wedge; full request-id correlation of the fs↔block-driver channel is a later fs-IPC follow-on, deferred by §26.2.)
  3. The mount loop has a failure-truth exit. Both mount waits - the capacity fetch and the read/E_IO retry - were unbounded success-only loops: they waited for the truth of success forever and never treated a persistent read failure as terminal. They now cap at MOUNT_MAX_ATTEMPTS real block-driver round-trips, after which fs comes up degraded - it logs loudly and serves storage-unavailable to clients ("data intact; do NOT run 'drives flash'") instead of spinning forever on an unreadable disk. A persistent read failure is now a first-class terminal truth.

This is the executable form of Commandment VIII's second half - "a wait that cannot observe failure has quietly become an infinite wait on time." The mount loop already waited on truth, not time (it never timed out; see the loop's own note) - but only on the truth of success. Teaching it that failure is also a truth is what closes the wedge. Verified: osdev test identity 24/0 (Test 13 fs-restart and the whole file suite stay green); the live CI-stuck self-heal is T630-only (QEMU's emulated AHCI never raises the CI-stuck condition).

6.17 Timestamps and SEALED - the first two features added under the frozen magic (Phase O)

Built 2026-09-17. The first change to the on-disk format since §6.15 froze the magic, and therefore the first test of whether that policy actually works. It does: SB_MAGIC is still b"GSFS0008", no volume needs reformatting, and the two features arrive by the two routes §6.15 prescribes - one additive and invisible to an older build, one behind a ro_compat bit.

Timestamps, in the 60 bytes every directory block already wasted. A 64-byte record is FULL - type @0, name_len @1, name[38] @2, size @40, first_block @48, block_count @56 - so times could not go inside it without shrinking NAME_MAX or halving how many entries a directory block holds. They did not have to: seven 64-byte records are 448 bytes and the record CRC is a u32 at 448, so bytes 452..512 have been unused since the format was written. Two u32 epoch times for each of seven records is 56 of them, and their own CRC32 is the four at 508.

Directory block (512 B):  records [0..448)  |  record CRC32 @448  |  7 x (mtime:u32, ctime:u32) @452  |  times CRC32 @508
  • mtime - when the content last changed. ctime - when the record last changed (rename, move, size), which is what distinguishes "the file changed" from "the file was moved".
  • No atime: recording a read turns every read into a write, and on a journaled filesystem into a transaction. The cost is real, the value is low, and it is refused on purpose.
  • u32 epoch seconds, good to 2106. Four bytes rather than eight is what let both times fit beside a CRC in 60 bytes.

Why this needs no feature bit at all. The record CRC still covers exactly [0..448) and still lives at 448, so a build that predates times reads the directory perfectly and never looks past the CRC it knows about - it will also overwrite the times region without stamping its CRC, which is precisely why the times carry their own. A build that knows times, reading a volume that has none, finds the mismatch and reports unknown rather than inventing 1970: a wrong date is worse than an absent one, because a date you can see is a date you will act on. No migration pass runs and nothing back-fills a missing time - the first write to an old directory block stamps a valid CRC over zeros, and zero is TIME_UNKNOWN, so old entries keep reading as unknown while new ones carry real times.

The clock behind it is the time service, which owns the wall clock, sets it from SNTP and persists a floor across reboots (/system/services/time/floor); a file stamped on a machine that has never seen the network gets the floor rather than zero, and the floor only moves forward.

SEALED - content frozen, permanently. A sealed file can be read, listed, renamed, moved and deleted; its bytes can never change again, and there is no unseal (a seal a holder can lift is a request, not a guarantee). It freezes CONTENT, not existence: refusing deletion would make a sealed file unremovable, so a disk could be filled with rubbish nobody is permitted to clear - a denial of service bought with a guarantee nobody asked for.

The record had no spare bit either, so the flag rides the top bit of the 64-bit size - room no file can reach, since 2^63 bytes is eight exabytes. Every size read goes through an accessor that masks it off, which is correctness rather than tidiness: write_at bounds a fragmented file's extent by its size, so the flag leaking into that arithmetic would let a write run past the file's own blocks.

Three refusals enforce it, and the first is the one that matters:

  1. fs will not mint a writable capability to a sealed file - refused at open, not per write, because a capability that looks writable and fails on use is a worse answer than a plain refusal, and §7.3 says rights narrow, so a capability that cannot be honoured should never exist.
  2. Every write path (write, the streaming write_at, the journaled variant) refuses, because the seal is carried on the Entry each of them already walks. A write route added later cannot forget to ask.
  3. The volume sets ro_compat bit 0 the first time anything is sealed, so a build that does not know the bit mounts the whole volume READ-ONLY (§6.15) instead of writing through a flag it cannot see. Not incompat: refusing to mount a volume because one file is read-only forever is a punishment out of proportion to the risk, and read-only is exactly the honest middle the mask policy exists to express. The one thing such a build gets wrong is cosmetic - a nonsense SIZE for a sealed entry - on a mount that can change nothing.

The version number this work does NOT have. During the build these two features were repeatedly called "GSFS0009", in this branch's scope note, in code comments and in commit messages. That was wrong and is corrected: §6.15 froze the magic and states that every later feature is additive under the masks - "GSFS00014 never happens". Nothing here mints a new magic, and there is no GSFS0009 on any disk. Recorded rather than quietly renamed, because a version a reader could build against is exactly the kind of claim §26.7 says to write down instead of papering over.

Verified by osdev test fs-time (15/0), which stamps a file, reboots the machine, and proves the date survived, that a file with no time is still unknown afterwards rather than given an invented one, that the write to the times region did not break the record CRC every build reads, and that a seal survives the reboot with the write still refused and the content still original.

6.18 What TWO CLIENTS see - the ordering guarantee, stated

Written down because it was not. docs/gsfs-carnage.md §3.5 asked for two clients' observable ordering to be checked "against what is documented - which currently is nothing, so documenting it is part of the gate". This is that.

The guarantee, in one sentence: every fs operation is atomic with respect to every other client.

It falls out of two facts rather than any locking:

  1. fs is single-threaded and serves one request to completion before dequeuing the next. The serve loop is loop { let msg = gs::ipc::recv(&ctx); ... } - there is no second thread, because nothing in this system has one (§9: a task is a service). A request is received, served, replied to, and only then is the next taken.
  2. Every mutating op commits through the redo-journal (§6.8). Its blocks are staged, the commit record is made durable, and only then does any home block move. A reader either sees the state before the transaction or the state after it.

So there is no read-modify-write window a second client can enter. Two clients cannot interleave inside one operation, because there is no inside to reach: the operation is the unit fs serves.

What is NOT atomic, and this is the part worth knowing. A single client's multi-request IDIOM is not a transaction. Between a client's two requests, fs may serve another client. So:

client A:  write /x.txt  ......................  move /x.txt /y.txt
client B:  ..................  delete /x.txt  ...

is a legal ordering, and A's move will correctly report that its source is gone. Each of the three operations was atomic; the SEQUENCE was not, and nothing promises otherwise. A client that needs a compound operation to be indivisible does not have one available - that would be a transaction API, and none exists (nothing has needed it: §26.2).

Ordering between clients is arrival order at fs's endpoint, which is the kernel's per-endpoint queue - FIFO, depth 16 (§8.3, §8.5). Not priority, not fairness: whoever's message is dequeued first is served first. A client whose queue slot is taken while fs is busy blocks in send until space frees (§8.2), which is backpressure rather than loss.

What this is NOT a claim about. Nothing here says two clients cannot produce a surprising RESULT - B deleting the file A is about to move is surprising and entirely legal. The guarantee is about state, not about outcomes being agreeable: the filesystem is never left half-applied, and no client ever observes a partial operation.

Pinned by osdev test fs-twoclient (8/0), which puts recorder and the shell on the same directory - a genuine second client writing a capture on its own schedule - and churns one path through create / read / rename / read / delete for six rounds while it does. Every read returns its own round's payload, the other client's file survives every round, and drives check reports the volume consistent with no corrupt blocks.

7. File = capability (the north star)

The spine that makes this filesystem ours rather than a generic store: a file is named and reached by capability, consistent with §3.3 and §7.

7.1 The mechanism problem

The kernel has no concept of a file (§4.4 anti-scope - no filesystem logic in the kernel), yet only the kernel can mint unforgeable capabilities (§7.3). "A file is a capability" must bridge those two facts. Three options were weighed:

OptionWhat it isVerdict
Bearer tokenfs returns a 128-bit unguessable handle, presented per callA service-level token, not a kernel cap - forgeable by guessing, sits beside the capability model. Rejected as the north star (weak claim).
Endpoint-per-open-filefs creates a kernel endpoint per open file; client holds a real SEND capGenuinely unforgeable, but every endpoint costs ~64 KiB (our own efficiency measurement) - heavy with many open files. Rejected for cost.
Kernel-delegated resource capsExtend the kernel's ResourceId+Rights+Generation model so a service can ask the kernel to mint a cap for a service-defined resource it ownsChosen. Real kernel caps (unforgeable, revocable, generationed) with no file logic in the kernel - it tracks an opaque resource owned by fs. Generalizes the capability model; useful beyond files.

7.2 How kernel-delegated resource caps work

The kernel already keys capabilities on ResourceId with a Generation (§7.2, §7.5). The extension: a service (fs) owns a band of resource IDs and asks the kernel to mint caps for them with chosen Rights. The kernel:

  • mints/validates/revokes exactly as for any resource - generation bump invalidates every outstanding file cap at once (§7.5), giving fs clean revocation (delete a file → bump → all its caps go stale, surfacing as the usual CapRevoked/EndpointDead-class error);
  • never learns what the resource means. fs maps ResourceId → file. A read/write cap to a file is then a first-class capability the holder can validate, be denied, or have revoked - identical machinery to endpoint caps.

This keeps every capability property (§7.3: unforgeable, non-escalating, scoped, revocable, generationed) true for files, while honoring §4.4 (kernel stays file-agnostic).

This is a capability-model extension and will require a recorded CLAUDE.md amendment (§7 / §4.4) with sign-off before Phase 2 lands. It is called out here as a known constitutional change, not slipped in.

7.3 Phasing

  • Phase 1 - authority = the cap to fs. Holding ipc_send=["fs"] is the authority; files are addressed by name in the request. This ships working, reboot-surviving persistence without the new kernel mechanism.
  • Phase 2 - per-file capabilities via §7.2. ✅ Built + verified end-to-end + HARDWARE-PROVEN on the T630 2026-06-18 (osdev test file-cap 9/9; on-hardware shell selfcheck = ran 163, failed 0, which includes the fcap self-check - CLAUDE.md §23.3). fs Open(path, rights) mints a delegated resource and returns a real file capability; the holder operates the file by invoking that cap (ResourceInvoke), the kernel badges the request with (resource_id, right) via an unforgeable Message field, and fs resolves it through its open-file table, enforcing op ≤ right. Unforgeable, non-escalating (kernel and fs layers), revocable on close/delete/rename. File-as-capability is now true, not approximate. See §7.4 (kernel mechanism) and §22 Test 14.

7.4 Phase 2 - concrete kernel mechanism (design, amendment-approved 2026-06-18)

The CLAUDE.md §4.4/§7.10 amendment is landed and signed off. This section is the implementation spec, grounded in the actual kernel (capability/table.rs, ipc/routing.rs, syscall/dispatch.rs). It records two non-obvious correctness decisions so the code that follows is right, not just fast.

What already exists (reuse, don't reinvent). ResourceId + Rights + Generation, mint_cap, register_resource(_at_gen), revoke_resource (gen bump + Revoked liveness), the per-task CapTable, and lazy generation invalidation. The global table is pre-sized (DIRECT_CAP = 8192, "P2 adds ~1000 entries"). Embedded-cap transfer (SendWithCap/TakePendingCap) already moves a cap to a client - that's how fs will hand over a file cap. An endpoint cap's resource_id.0 is reused directly as its EndpointId.

The delegated band. Reserve ResourceIds [DELEGATED_BASE, DELEGATED_BASE+DELEGATED_CAP) (within [0, 8192) so they stay direct-indexed in the global table). A new capability/delegated.rs owns: a [Option<EndpointId>; DELEGATED_CAP] owner array + an allocation cursor, under a SpinLock. allocate(owner) -> ResourceId finds a free slot, records the owner, registers the id in the global table, and returns it. is_delegated(id), owner_of(id), revoke_owned(id, caller).

Correctness decision 1 - ABA-safe id reuse. A freed delegated id may be reallocated. If it re-registered at generation 0, a stale file cap (also gen 0 from the id's previous life) would spuriously re-validate - a cap-reuse hole. So allocate re-registers a reused id at get_resource_generation(id).bump() (via register_resource_at_gen), keeping the generation monotonic across lives. A stale cap can never match a future life of the same id. (First use of a never-allocated id starts at gen 0.)

Three syscalls (each validates a capability first - §3.1, no exceptions):

  • ResourceMint(rights_bits, out_id_ptr) -> cap_slot - gated by a new RESOURCE_MINT stable resource (ResourceId(7), gen 0 forever, granted only to fs + a test probe; validated by holds_resource, like SPAWN). Allocates a delegated id owned by the caller's endpoint, mints a cap with rights_bits into the caller's table, writes the id to *out_id_ptr, returns the slot. fs calls this on Open, recording ResourceId → file, then GRANT-transfers a narrowed copy to the client in the reply (existing embedded-cap path).
  • ResourceInvoke(cap_slot, right_bits, msg_ptr, msg_len, reply_cap…) -> i64 - the "use = send" of §7.10, but as its own syscall rather than overloading send. It validates the cap carries right_bits (CapTable::get(slot, right)), then routes the message to the owning endpoint with the badge [resource_id:u64, right:u8] prepended to the payload, reusing routing::enqueue with the owner endpoint's current generation (so the routing gen-check passes; the file gen was already validated against the global table). A reply cap is embedded exactly as SendWithCap does today, so fs replies on the normal path. (2026-10-09: as built, the badge is NOT prepended to the payload - it rides in kernel-set Message fields and the owner reads it with LastRecvBadge (syscall 33), which is what makes it unforgeable; kernel/src/syscall/dispatch.rs. The RESOURCE_MINT grant is a privilege bit in the spawn request now, held by fs, net-stack and the resource-server example in the supervisor's spawn table, not a name-keyed mint.)

Correctness decision 2 - rights are enforced by the kernel, per operation, without the kernel knowing what a file is. A read needs READ, a write needs WRITE. The kernel can't read the file op, so the caller declares the right it intends (right_bits) and the kernel validates the cap actually holds it - a READ-only cap doing ResourceInvoke(.., WRITE, ..) fails CapInsufficientRights at the kernel (this is the non-escalation enforcement, §7.3). The kernel then badges the message with the validated right, and fs refuses any op whose required right exceeds the badged right. So fs never has to trust the client, and the kernel never learns the op.

  • ResourceRevoke(resource_id) -> i64 - owner-gated (ownership is the capability check: revoke_owned only proceeds if the calling task's endpoint owns the id). Bumps the generation (Revoked) so every outstanding file cap to it goes stale → next ResourceInvoke returns CapRevoked (§7.5). Frees the owner slot. fs calls this on delete/close.

Why a dedicated ResourceInvoke rather than overloading send: a file cap carries READ/ WRITE/GRANT, not SEND, and handle_send requires SEND. Overloading would force file caps to carry SEND and lose per-operation rights enforcement. A distinct invoke keeps the file cap's rights meaning exactly the file operations, and keeps handle_send untouched (lower TCB risk).

Phase 2a deliverable: the three syscalls + delegated.rs + the RESOURCE_MINT grant, exercised by two probe services (mint → grant a cap → invoke with READ ok / WRITE denied on a read-only cap → revoke → invoke returns CapRevoked) - no fs needed, so the mechanism is proven in isolation before 2b wires fs onto it. Then 2b (fs issues file caps), 2c (SDK File + shell demo), 2d (§22 Test 14).

8. IPC protocols (proposed)

Note (2026-10-09): the proposal, not the protocol. What shipped is opcode-addressed with a correlation tag at byte 0. fs <-> block-driver: [tag, op, lba:u64 LE, ...] -> [tag, status, ...], ops 1 OP_READ_BLOCK, 2 OP_WRITE_BLOCK, 3 CAPACITY, 4 OP_WRITE_ZEROS, 5 FLUSH - one block per request, not a count - and since 2026-10-20 6 OP_WRITE_BLOCKS, a run of 1 to 7 consecutive blocks (count:u8). Client <-> fs: ops 10-31 (OP_WRITE_FILE 10, OP_READ_FILE 11, OP_STAT_FILE 12, directories, rename/move/delete, drives, streaming OP_WRITE_NEW/OP_WRITE_AT/ OP_READ_AT 24-26, check, scrub, OP_OPEN 30, OP_SEAL 31). The constants are the definition: services/fs/src/main.rs and services/block-driver/src/main.rs. (2026-10-25: the client range now runs to 32, OP_QUIESCE, which power sends before a shutdown, docs/shutdown.md.)

Client ↔ fs (Phase 1, name-addressed):

RequestArgsReply
WriteFilename, data (chunked)Ok / NoSpace / IoError
ReadFilenamedata (chunked) / NotFound / IoError
StatFilename{exists, size}
ListFiles-names (chunked)
DeleteFilenameOk / NotFound

fs ↔ block-driver:

RequestArgsReply
ReadBlockslba, count (message-bounded)sector data / IoError
WriteBlockslba, data (message-bounded)Ok / IoError

All replies are exactly one of {Ok-with-data, defined error} - never silent (§3.12, mirrors the IPC send discipline of §8.6).

9. Crash, restart, and the TCB trajectory (§6.3)

  • Phase 1 (write-through, in the TCB). Writes go straight to disk; no journal. A crash mid-write can corrupt the file being written (and the entry table if it strikes during a metadata update). On mount, a bad superblock magic/checksum is a loud refusal, never a silent reformat. block-driver and fs remain TCB members (§6.1); their death is a panic+reboot (§6.2) - the v1 posture, carried because nothing transactional exists yet.
  • Phase 3 (transactional, out of the TCB). The §6.3 goal: give fs atomic-commit semantics so a restart can recover to a consistent state, then drop fs and block-driver from the TCB (a recorded CLAUDE.md §6 amendment with sign-off). A log-structured layout is the natural route - appends with an atomic commit record make crash-consistency fall out for free, and pair well with the no-overwrite discipline. Corrected 2026-09-26: this said the opposite of the truth and is the reverse of a safety claim, so it is replaced rather than softened. It read "because the ATA PIO driver has no DMA reach (§5.1), block-driver can leave the TCB without depending on IOMMU presence". The ATA-PIO backend is retired: block-driver is AHCI, it takes a DMA arena (services/block-driver/src/ahci.rs, arena: Dma), and it is spawned unconfined (hwclass::pci(0x01_06_01, 5, false) in the supervisor's spawn table). By CLAUDE.md 6.4's own rule any service granted a DMA arena on a machine that cannot confine it is trust-critical, so block-driver is IN the TCB on every machine, IOMMU or not - which is what docs/ahci.md and the 2026-09-12 DMA-census amendment both say.

10. Phased build plan

(Note 2026-10-09: the record of Phase 1 as it was built. Steps 1-3 name the ATA-PIO machinery - hw_pio, PortRead/PortWrite, capability/hw_pio.rs, pio.rs, if=ide - all since retired with that backend; see the note at the head of §5.)

  1. Block driver read path. ✅ done (osdev test blockdev). Added the hw_pio grant (kernel-mediated PortRead/PortWrite syscalls validated per access, grant store in capability/hw_pio.rs), SDK pio.rs (Pio), and block-driver ATA-PIO reads sector 0 of a QEMU secondary-channel if=ide disk and logs it - verified by reading back a host-written magic. Port I/O is kernel-mediated because ring-3 drivers cannot run in/out (granting IOPL would be ambient authority).
  2. Block driver write + round-trip. ✅ done (osdev test blockdev, case P1.2). The driver writes a known pattern to a scratch LBA (WRITE SECTORS + FLUSH CACHE), reads it back, and asserts equal - proving the device read/write path end to end. All in the driver via the existing Pio wrapper; no new kernel surface.
  3. Filesystem mount + format. ✅ done (osdev test blockdev, case P1.3). Host-side osdev mkfs <image> writes the superblock (magic GSFS0001, version, block_size, total_blocks) into LBA 0; block-driver gained an IPC server loop (ReadBlock / WriteBlock, the per-request reply-cap pattern); fs mounts by reading LBA 0 over that IPC (SDK request_with_reply), validating the magic loudly, and logging the geometry. The entry table + free bitmap are written by mkfs in a later step (no files exist yet). Forward fs → block-driver cap wires via send_peers (block-driver spawns first, kernel auto-registers its endpoint name); the reply rides a per-request cap fs embeds.
  4. Filesystem read/write (name→blob). ✅ done (osdev test blockdev, case P1.4). On-disk entry table (16 entries × 32 B, one block) + a bump allocator (next-free-block in the superblock; contiguous extents, no reclamation yet). fs stores/retrieves named files: write_path allocates an extent, writes the data blocks + entry table + superblock through block-driver WriteBlock; read_file walks the entry's extent via ReadBlock. Verified by a mount-time round-trip (greeting). fs also serves the client API (WriteFile/ReadFile/StatFile, ops 10-12) over IPC via the reply-cap pattern.
  5. Reboot survival (the headline). ✅ done (osdev test blockdev-reboot, case P1.5). Format once; boot 1 - fs creates greeting; reboot on the same disk image without reformatting - boot 2 mounts (superblock persisted: next_free=3, 1 files) and reads the file back byte-for-byte (fs: persisted file 'greeting' verified across boot). The disk is a host file, so this is the real durability guarantee. Phase 1 complete.
  6. Phase 2: file-as-capability. Kernel-delegated resource caps (after the §7 amendment is signed off); fs returns/validates per-file caps.

11. Test plan - QEMU is sufficient (and authoritative here)

Unlike the H1 IO_PAGE_FAULT (which QEMU's lenient amd-iommu could not show), persistence is a case where QEMU gives a real, trustworthy answer and the whole feature can be built and verified headless, away from the T630:

  • The disk is a host file (-drive file=disk.img,if=ide). It survives across QEMU runs by definition, so reboot-survival is the real thing: boot → write → quit → boot → read. No flashing.
  • ATA PIO is faithfully emulated - real BSY/DRQ handshake, real status/error bits, real sector semantics. What passes in QEMU behaves the same on an ATA controller.
  • Format + filesystem logic are hardware-independent - bytes in blocks.

The single thing QEMU cannot answer is whether the T630's storage controller speaks ATA PIO in legacy mode - a separate, later hardware bring-up question, not a filesystem question. Test layout follows the §22 pattern: an identity-style reboot-survival test (write, reboot, read, assert) is the executable form of "persistence persists," plus property tests (round-trip any bytes), fuzz (malformed superblock → loud refuse, never panic), and chaos (crash mid-write → mount refuses or recovers, never silently corrupts).

12. Open questions (to resolve as phases land)

  • Final bounds (MAX_FILES, NAME_MAX, transfer width per message).
  • Exact hw_pio capability syntax in the contract schema (range form, like hw_mmio).
  • The precise §7 amendment wording for kernel-delegated resource caps (Phase 2).
  • Whether Phase 3 is log-structured or journaled-update (decide when transactional recovery is actually built; both reach the §6.3 goal).
  • Bare-metal storage controller for the T630 (ATA legacy mode vs. an eventual AHCI driver)
    • deferred; does not block any QEMU phase.

AHCI (SATA) Block Driver

Status: Design doc, non-normative (trails CLAUDE.md). Records the AHCI backend for block-driver, the modern-hardware disk path.

1. Why AHCI

The ATA PIO backend (docs/persistence.md §5) works in QEMU's legacy IDE but the T630's SSD is AHCI-only - its firmware exposes no legacy IDE, so a read-only probe of ports 0x1F0/0x170 returned status 0xFF (no drive) on real hardware. AHCI is the standard SATA interface: it works on the T630 and in QEMU (ich9-ahci), so it is the portable, modern path.

AHCI replaces only block-driver's bottom half - how sectors move. The block IPC (ReadBlock/WriteBlock), the on-disk format, mkfs, fs, and reboot survival all live above the block layer and are device-independent.

2. Shape of the driver (DMA + MMIO)

Unlike ATA PIO (port I/O), AHCI is MMIO + DMA, exactly like the USB drivers:

  • The kernel finds the AHCI controller in the PCI scan (class 0x01, subclass 0x06, progif 0x01), records its ABAR (BAR5) + BDF (pci.rs), and at spawn maps the ABAR (HBA registers) into block-driver and grants it a small physically-contiguous DMA arena (task/mod.rs, the same path as xhci/ehci). The driver takes the window with gs::driver::registers and the arena with gs::driver::arena, laid out in four named parts (docs/driver-library.md step 3).
  • Per-port DMA structures the driver builds in its arena: a command list (32 command headers), a received-FIS area, and per-command a command table (a command FIS + a PRDT - physical region descriptors pointing at the data buffer). A transfer = build a Register-Host-to-Device FIS (READ/WRITE DMA EXT), point the PRDT at the data buffer, set the command-issue bit, wait for completion, check the task-file for errors.

3. IOMMU (H1)

AHCI is DMA-capable, so on a machine with an IOMMU it should be confined to its arena like xhci (§6.4). The T630 has a working IOMMU. The driver was brought up in passthrough first (get it correct), on the "earned confinement" rule the USB drivers follow (docs/iommu.md).

It is still in passthrough, and that is now a decision rather than a stage. The T630's BIOS hands the SATA controller over carrying a stale firmware DMA pointer, and confining the device makes the controller's first access fault - the same quirk that keeps ehci in passthrough. The kernel states it at the policy site: kernel/src/task/mod.rs, the confine flag on HwClass::Pci (read by iommu_confine), "Not ehci + block-driver, which keep a stale firmware DMA pointer that confinement would fault, so they stay in passthrough". xhci and audio-driver are the only confined in-tree drivers (both confined on the T630; CLAUDE.md 6.4, note 2026-10-08); an installed driver package given a PCI device is spawned asking for confinement too (services/supervisor/src/installed.rs). So block-driver is trust-critical on every machine, IOMMU or not, exactly as §6.4 says an unconfined DMA driver must be.

4. Build steps (incremental, against QEMU ich9-ahci)

Developed behind the block-driver/ahci cargo feature so the ATA PIO tests stay green during the migration; becomes the default (retiring ATA PIO + the IDE probe) once read/write/fs/reboot are verified on it. Test: osdev test blockdev.

  • Step A - detect + HBA init. ✅ done. Map ABAR, enable AHCI mode (GHC.AE), read CAP/VS/PI, enumerate implemented ports, report which carry a SATA disk (DET=3, sig 0x00000101). Verified: 6 ports, disks on ports 0/1.
  • Step B - port init + IDENTIFY. ✅ done (osdev test blockdev, AHCI.B). Stop the port (clear ST/FRE, wait CR/FR), plant the command list + received-FIS base in the arena, restart (FRE then ST), then issue IDENTIFY DEVICE via a command header + H2D Register FIS + single-PRDT command table, wait on PxCI, and parse the result. Verified: model QEMU HARDDISK, 131072 sectors (64 MiB). The full DMA command path (command list / FIS / PRDT / command-issue / completion) now works.
  • Step C - read. ✅ done (osdev test blockdev, AHCI.C). READ DMA EXT (0x25) via issue() into the PRDT data buffer; block IPC ReadBlock restored. Verified: fs mounts the AHCI disk over IPC (fs: mounted).
  • Step D - write + integrate. ✅ done (AHCI.D). WRITE DMA EXT (0x35) + FLUSH EXT (0xEA); the block-IPC serve loop runs in the AHCI backend; fs spawns and the full file round-trip works over AHCI (fs: file round-trip OK). The whole filesystem stack now runs on AHCI. Harness: boot on legacy IDE, the persist disk ALONE on ich9-ahci (→ port 0), mirroring the T630 (SSD is the only SATA disk).
  • Step E - promote. ✅ done. Confinement NOT taken. AHCI is now the only backend (the ahci cargo feature is gone; ATA PIO + the hw_pio capability + the IDE probe are retired - the T630's SSD is AHCI-only). The confinement half of this step was not completed and is not pending: block-driver is spawned hwclass::pci(0x01_06_01, 5, false) (the block-driver row of the supervisor spawn table) and stays in IOMMU passthrough for the firmware stale-pointer reason in §3. Its DMA is arena-resident in practice, but nothing enforces that, so block-driver stays trust-critical on every machine (§6.4). Verified: osdev test blockdev 3/3 + identity 23/23; unsafe audit back to 413/27 (the hw_pio arch wrappers + capability/hw_pio.rs + SDK pio.rs removed).
  • Step F - port recovery (COMRESET). ✅ done (f069c85, hardware-found by the million-round soak). A soak that kills block-driver mid-command can leave the port stuck BSY (the kernel clears PCI bus-master on kill before the in-flight command drains), and the soft port-restart of Step B cannot clear it - only re-running the SATA OOB sequence does. port_comreset (ahci.rs) does what a cold boot does: idle the port keeping FIS-receive armed (so the device's post-reset initial D2H FIS is captured and clears PxTFD.BSY, AHCI 10.1.2), pulse PxSCTL.DET 1 -> hold -> 0, wait for the PHY (PxSSTS.DET == 3), clear PxSERR/PxIS, restart the engine. init_port runs it on every (re)start; recover_port (the Phase H per-I/O retry path) escalates to it when a soft recovery leaves the port wedged, so a running driver self-heals a wedged port with no restart. This is what lets fs's mount-on-truth get a truthful answer: block-driver's reply reflects a settled controller, never a wedged-BSY false 0 (Commandment VIII).
  • Step G - CI-stuck escalation. ✅ done (2026-07-09, hardware-found on the T630 by a rapid double kill all-services). Step F escalated to COMRESET only on a stuck PxTFD.BSY. But a command against a nonsense LBA (an fs mount that derived a backup-superblock LBA from a garbage capacity - see docs/persistence.md §6.16) can wedge the controller with the command-issue bit PxCI stuck set and BSY clear. A soft recovery (clearing PxSERR/PxIS) cannot clear an owned PxCI - only a port COMRESET does - so the BSY-only gate never fired and the driver retried into the same stuck state forever. recover_port now escalates on PxTFD.BSY != 0 || PxCI != 0, covering both wedge shapes. The fs-side half of the fix (rejecting the mis-shaped capacity reply, and a bounded failure-truth exit from the mount loop) is docs/persistence.md §6.16; together they complete Commandment VIII in the storage stack. The live CI-stuck condition is T630-only - QEMU's emulated AHCI never raises it.

5. Register cheat-sheet

HBA (from ABAR): CAP 0x00, GHC 0x04 (bit31 AE), IS 0x08, PI 0x0C, VS 0x10. Port n (base 0x100 + n·0x80): PxCLB 0x00, PxFB 0x08, PxIS 0x10, PxIE 0x14, PxCMD 0x18 (bit0 ST, bit4 FRE, bit14 FR, bit15 CR), PxTFD 0x20, PxSIG 0x24, PxSSTS 0x28 (DET in 3:0), PxSCTL 0x2C (DET in 3:0, the COMRESET control), PxSERR 0x30, PxCI 0x38 (command-issue bitmask).

IOMMU-backed DMA confinement (H1)

Status: SHIPPED and hardware-confirmed. Phase 0 + Phase 1 (a-f) are merged; Phase 2 was signed off and is now CLAUDE.md §6.4 (this document's own §5 header records the 2026-06-12 adoption). Real-hardware IVRS presence on the T630 (AMD GX-420GI) is confirmed, not pending - see §4. The branch named here no longer exists; the work is on main.

Two scope limits this document did not state, both load-bearing:

  1. AMD-Vi is x86-only. Every iommu:: entry point on arm, aarch64 and riscv64 is a stub and confine_device returns false there. The Pi 2, the Pi 4 and the VisionFive 2 have no usable SMMU, so every DMA-capable driver on those three ports is unconfined and kernel-equivalent by §6.4's own rule. Three of the four shipping ports are in the "without an IOMMU" case.
  2. xhci is the only confined driver, even on x86. ehci and block-driver keep a stale firmware DMA pointer that confinement would fault, so both run in passthrough by design (the confine flag on HwClass::Pci, in kernel/src/task/mod.rs), and nic-driver is spawned confine=false. "Confinement is applied per driver" (§6.4) is doing more work in that sentence than it looks: today it selects exactly one. (2026-10-03: two - audio-driver is confined too, verified in QEMU only; on the T630 it does not use DMA yet, so there xhci is still the one. CLAUDE.md §6.4.) (2026-10-09: CLAUDE.md §6.4's note of 2026-10-08 records that the T630 confines the audio controller too - its log shows xhci and 00:01.1 each confined with an arena - so on that board there are two confined devices, each with its own domain ID.) (2026-10-10: since K2 that audio device is 00:09.2, the analog controller, and it plays through its confined domain, heard on the speaker and the headphones.)

This is the narrative behind H1, the flagship trusted-base reduction. The spec (CLAUDE.md) is the authority; this document explains the why and the how.


1. The problem: a DMA-capable driver is kernel-equivalent

GodspeedOS runs its device drivers in userspace (§12). A driver holds an hw_mmio capability for its controller's registers and a physically-contiguous DMA arena for the queue structures the controller reads and writes (command rings, event rings, device contexts). The driver builds those structures in the arena and hands the controller physical addresses to find them.

Here is the catch. Without an IOMMU, the controller's DMA engine is told a raw physical address and writes there - anywhere in RAM. A driver that is buggy or compromised can program its controller to DMA over the kernel's page tables, the capability table, another service's memory, or the kernel image itself. Nothing in the capability model stops it, because the capability model governs CPU accesses through page tables; it does not govern a device's DMA.

So a userspace driver with a DMA-capable device has, in practice, the same power as the kernel. That is precisely why xhci and ehci have been treated as trust-critical: not because of what their code can do through syscalls, but because of what their device can do through DMA. The capability microkernel's central promise - "no ambient authority" - has a hole exactly the size of a DMA engine.

2. The mechanism: an IOMMU translation domain per driver

Corrected 2026-10-08: until today every confined device got the SAME domain ID, 1. The page table was per device, as below, but the ID that tags the IOMMU's cached translations was shared, on the reasoning that each device only reaches its own arena. AMD-Vi caches translations and page directory entries by domain ID, so two devices with different tables under one ID can be translated through each other's cached entries. It was harmless while xhci was the only confined device, and became live when the audio controller was confined beside it (2026-10-03). Each device's ID is now its BDF plus one (domain_of), so the logs below that say domain 1 are from before the fix. Found under an xhci that stopped completing commands mid-chaos on the T630 (docs/wifi-usb.md 51).

An IOMMU (AMD calls it AMD-Vi) sits between devices and memory and translates every device DMA through a per-device page table, exactly as the MMU translates every CPU access through the process page table. If we give each DMA-capable driver an IOMMU domain whose I/O page table maps only its granted arena, then the device can reach its arena and nothing else. A DMA outside the arena has no translation and faults - it does not silently corrupt memory.

With that in place, a compromised driver is confined to the same memory the capability model already granted it. The device's authority becomes explicit and bounded, just like everything else in the system - and the driver can be dropped from the trusted base.

   without IOMMU                         with IOMMU (H1)
   ┌────────┐  phys addr                 ┌────────┐  IOVA
   │ driver │ ───────────► [ all RAM ]   │ driver │ ──► [ IOMMU ] ──► arena only
   └────────┘   (device DMA)             └────────┘      │  fault on
        can scribble the kernel               anything else ┘  everything else

3. How it is built

The implementation lives in kernel/src/arch/x86_64/iommu.rs (the unsafe hardware boundary, §18.1). Every raw access carries a // SAFETY: argument; the file is fully accounted in audits/unsafe-audit.md. The rest of the kernel touches it only through five safe entry points: detect, bringup, confine_device, release_device and drain_event_log.

Phase 0 - detection (detect)

Before building anything, prove the hardware can do it. Limine hands us the ACPI RSDP pointer; we walk RSDP → RSDT/XSDT → IVRS (the AMD-Vi description table). If there is no IVRS, this machine has no AMD-Vi IOMMU: we say so loudly and the drivers stay in the TCB (no behaviour change). If there is one, we record its MMIO base. This is the gate for everything below.

Phase 1a - MMIO bring-up (bringup)

Map the IOMMU's MMIO register block uncached (PCD|PWT, the same way the APIC is mapped) and read the Extended Feature Register and control state. This proves the kernel can talk to the IOMMU and reads the capabilities the later phases depend on (page-table levels, current enable state).

Phase 1b - structures (setup_structures)

Allocate and program the IOMMU's three core structures:

  • Device table - one 256-bit Device Table Entry (DTE) per 16-bit PCI BDF (full 2 MiB table). Every entry defaults to passthrough (V=1, TV=0, IR=1, IW=1), so when translation is later switched on, the disk and every other device keep DMAing untranslated. Only a device whose spawn asks for confinement gets switched to a confined domain (xhci, and since 2026-10-03 audio-driver).
  • Command buffer - the ring through which we issue cache-invalidation commands to the IOMMU.
  • Event log - the ring on which the IOMMU posts translation faults.

Phase 1c - enable (enable_passthrough)

Turn on the command buffer, the event log, and then the master IommuEn bit. Every device DMA is now checked against the device table - but since every entry is passthrough, nothing changes yet. This proves the translation engine runs without breaking the running system (verified: zero fault events, disk fine).

Phase 1d - confinement (confine_device)

When a driver's DMA arena is granted at spawn (task/mod.rs), confine it:

  1. Build a private 4-level AMD-Vi I/O page table that identity-maps (IOVA == PA) only the arena's pages, with read+write permission. Identity mapping means the driver keeps handing the controller the same physical addresses it always did - they are now IOVAs that translate back to themselves.
  2. Switch the device's DTE from passthrough to that domain (V|TV|mode=4|root).
  3. Invalidate the cached DTE and the domain's page cache through the command buffer, so the new entry takes effect.

The arena design from §12 is what makes this clean: because a driver's device-visible memory is exactly one contiguous arena, the confining I/O page table is tiny and the identity map is trivial.

Phase 1e - proof (confinement_selftest)

A read-only walk of the freshly built I/O page table confirms every arena boundary page translates identity and the first page past the arena is unmapped. Combined with "the driver still works while confined" (its rings DMA correctly through the domain), this is a complete confinement proof: the mapped region is reachable, nothing outside it is.

Phase 1f - reclaim on death (release_device)

For a confined driver to be restartable, its IOMMU resources must be reclaimed when it dies, or a restart would leak the I/O page table and re-confine on top of a stale domain. On driver death (scheduler.rs kill path) we revert the DTE to passthrough, invalidate it, and free the I/O page-table frames (only the table frames - the arena leaf pages return via ordinary task reclaim). A restart then re-confines cleanly with a fresh arena.

4. What is verified, and where

All of Phase 0 and Phase 1 are verified under QEMU with an emulated AMD-Vi (-device amd-iommu) and a qemu-xhci controller behind it. The reusable launcher is scripts/qemu_iommu.sh. Observed end-to-end:

iommu: AMD-Vi IVRS found ... IOMMU MMIO base 0xfed80000
iommu: H1 Phase 1a OK -> MMIO reachable; capabilities read
iommu: H1 Phase 1b OK -> device table + rings programmed
iommu: enable -> control=0x1005 (IommuEn=1) ... zero fault events
iommu: selftest PASS - arena .../...  translate identity, ... (outside) unmapped
iommu: confined BDF 00:04.0 -> domain 1 arena ...; DTE invalidated
   (xhci driver then initialises and runs normally - confined DMA works)
control: RESTART xhci ...
iommu: released BDF 00:04.0 -> DTE back to passthrough, I/O page table freed
iommu: confined BDF 00:04.0 -> domain 1 arena <fresh>; selftest PASS

The negative path (no -device amd-iommu) prints "no IVRS ... drivers stay in TCB" and the system boots normally - H1 is a no-op where there is no IOMMU.

Confirmed on real hardware (T630, AMD GX-420GI): the APU exposes AMD-Vi (IVRS, IOMMU MMIO 0xfeb80000, 6-level), and a USB keyboard types into the shell while its xHCI controller is confined to a 1 MiB arena with zero faults - the flagship demonstrated end-to-end on silicon - and ultimately both USB keyboards working at once with the IOMMU enabled: the front confined, the back in passthrough. Getting there surfaced four real bugs the emulator never could (each a case of QEMU being lenient or the firmware quietly covering for the driver):

  1. DTE permission bits in the wrong word. IR/IW (bits 61/62) belong in the DTE's first 64-bit word; they were written into the second, setting reserved bits. QEMU ignored it; real AMD-Vi raised ILLEGAL_DEV_TAB_ENTRY.
  2. USB controllers still owned by firmware. The drivers never performed the BIOS→OS handoff (EHCI's is in PCI config space, xHCI's in MMIO), so the firmware kept running the controllers' DMA out of firmware memory - invisible without an IOMMU, a fault storm with one. The kernel now hands off the controller it confines (pci::xhci_bios_handoff).
  3. xHCI scratchpad never allocated. This controller reports MaxScratchpadBufs=256 - it DMAs into 1 MiB of scratchpad it finds via DCBAA[0]. The driver left DCBAA[0]=0 and leaned on firmware's scratchpad; once handed off it had none and devices dropped after binding. The arena grew to hold the 256 buffers and the driver builds the Scratchpad Buffer Array.
  4. Wrong passthrough DTE encoding. Passthrough was written as V=1, TV=0. On real AMD-Vi that is not a transparent state - it broke all DMA for the one device actually exercising passthrough after enable (the firmware-co-owned EHCI controller), stalling the back keyboard. The canonical identity encoding
    • V=1, TV=1, mode=0, root=0, IR=1, IW=1, exactly Linux amd_iommu's PAGE_MODE_NONE - is genuinely transparent. With it, EHCI runs untouched while the IOMMU is on. QEMU accepted the broken encoding; only hardware rejected it.

4a. Per-driver confinement: confine what can be confined, pass the rest through

Confinement is applied per driver. The xHCI driver is confined: handed off

  • scratchpad-complete, all its controller DMA inside the granted arena (a confined keyboard types on hardware). The EHCI driver is left in passthrough: its controller legitimately DMAs to firmware/hub regions outside any arena we could grant (the 0xffffffc0 accesses), and it reaches a low-speed keyboard only through a high-speed hub's transaction translator via split transactions - so a tight arena confinement isn't applicable. But passthrough is now genuinely transparent (bug 4), so EHCI works with the IOMMU enabled. The result on hardware: front xHCI keyboard confined to least privilege, back EHCI keyboard working in transparent passthrough, simultaneously, zero faults. A device is confined when confinement fits its DMA shape and passed through (transparently) otherwise; the trust posture is explicit and logged at spawn.

5. Phase 2 - DMA-capable drivers brought into least-privilege (ADOPTED 2026-06-12)

With confinement (1d/1e) and restartability (1f) in place, the trust argument for xhci/ehci changed: an IOMMU-confined driver can no longer DMA outside its arena, so its compromise no longer endangers the kernel or other services. That removed the reason they were trust-critical and brought them into the ordinary least-privilege model - the genuine trusted-base shrink H1 was aimed at.

Adopted as the constitutional amendment CLAUDE.md §6.4 (with the §3.1 DMA-gap note and §22 Test 12), signed off 2026-06-12. The headline points the amendment fixed:

  • §6 / §12.1: state that DMA-capable userspace drivers are IOMMU-confined and therefore not trusted on machines where an IOMMU is present. Where no IOMMU is present, they remain trusted (loud, explicit - detect already reports which case holds at boot). This conditional is itself a worked example of "loud failure over silent fallback" (§3.12): the trust posture is a printed fact, not an assumption.
  • §22: add an identity test for DMA confinement - confine a device to a domain that does not map some address, have the device touch it, and assert an IO_PAGE_FAULT event appears in the event log (the live negative control, complementing the structural self-test of Phase 1e). This needs the test harness to launch QEMU with -device amd-iommu; that harness change is part of the Phase 2 work.

Open questions to resolve with sign-off:

  1. Interrupt remapping - ANSWERED 2026-09-26. Confinement does not touch the interrupt path at all, and that is structural rather than lucky. The question was: a driver's MSI/MSI-X write to the 0xfeex_xxxx interrupt region is also a DMA, iommu.rs contains no interrupt-remapping table and maps no interrupt region, and yet the confined xHCI runs fault-free. Why?

    Because write_dte writes four DTE words and words 2 and 3 are always zero - in the default passthrough entry written for every BDF at init and in the confined entry alike. Every AMD-Vi interrupt-remapping field (IV, IntTabLen, Interrupt Table Root Pointer, IntCtl) lives in those two words. Confinement edits only word 0 (mode 4 plus the page-table root) and word 1 (the DomainID). So a confined device's MSI takes byte-for-byte the same path as every other device's on the machine, and the arena's page tables are never consulted for it: the IOMMU classifies a write to the interrupt region as an interrupt request and handles it on the interrupt path, not the translation path. Nothing faults because nothing is being translated. The xHCI being the confined one is irrelevant.

    What this relies on, stated rather than assumed: that the IOMMU FORWARDS interrupt requests unremapped while those fields are zero. That is not a confinement-specific assumption - it is the machine's normal state with translation enabled, for every device on the bus, and if it did not hold nothing would take an interrupt at all.

    What it does NOT buy, which is the part worth recording (§26.7). Confinement bounds a driver's DMA to memory. It does not bound its interrupt reach, because the DMA page tables are not in that path. Whether a compromised confined driver could retarget its own interrupt therefore depends on where its message lives, and the kernel's MSI programming at spawn tries both (legacy MSI, then MSI-X) - and on the machine where confinement actually operates, it is the safe one:

    • Legacy MSI (capability 0x05) keeps the address and data in PCI configuration space, which no service can write: PCI_CFG is a read authority and the kernel programs the message itself. Out of the driver's reach. This is the T630 case, and it is measured, not assumed. Every T630 boot log pairs the two lines on the same BDF, one immediately after the other:

      pci: MSI enabled on 00:10.0 vector=0x28 (64-bit addr)
      iommu: confined BDF 00:10.0 -> domain 1 arena 0x1cf9000..0x1e1d000 (292 pages); DTE invalidated
      

      So the only driver confined on the T630 holds no path to its own MSI message. (audio-driver, confined in QEMU since 2026-10-03, is the same case: QEMU's HD Audio controller uses plain MSI. 2026-10-09: the T630 confines its audio controller too (CLAUDE.md §6.4, note of 2026-10-08); which MSI form that controller takes on the T630 is not recorded here.)

    • MSI-X (capability 0x11) keeps the message table in MMIO inside a BAR (bir=0 on every controller observed here, so BAR0 - the window a driver is granted), and the driver holds that BAR. A compromised driver could point its own interrupt elsewhere. Observed on qemu-xhci, which the code names as the MSI-X case; not on any confined device.

    So the honest boundary is: confinement makes a compromised driver unable to reach memory outside its arena, and would not make it unable to misdirect its own interrupt on an MSI-X controller - but no confined driver is on one today. The exposure is therefore latent rather than live: it arrives the first time a machine confines an MSI-X-only controller, and the thing to check at that moment is whether the vector table falls inside the granted MMIO window. Closing it properly needs an interrupt-remapping table, which is real work and not a constant, so it is recorded here rather than implied away. It does not weaken the §6.4 claim, which is about DMA, but it is the limit of that claim and belongs beside it.

  2. The no-IOMMU machine. The conditional trust posture above means the TCB is machine-dependent. That is honest but novel for this project; it deserves a deliberate decision rather than a default.

  3. block-driver / fs. CORRECTED 2026-09-26: both LEFT the TCB on 2026-06-17 (§6.1's Phase D amendment) once fs gained crash-consistent recovery, and §6.3's goal is reached at its floor - the non-restartable set is {kernel} alone. This said they "remain trusted for v1" and that the §6.3 v2 plan "still stands". block-driver is nonetheless trust-critical on any machine that cannot confine its DMA arena, which is every machine today (§6.4's 2026-09-12 census).

Phase 2 was signed off and is now §6.4 (see the header). This closing paragraph used to read "Until sign-off, the drivers remain in their current trust posture and H1 is a pure hardening mechanism that confines them without yet re-classifying them" - which negated the header of its own document.

Power and Idle Efficiency

Status: SHIPPED in v0.7.0 (Phases 1a, 1b, 2a). Non-normative - it records strategy and measurement, not law. Reducing CPU/power draw without hurting latency or robustness.

Result (hardware, 2026-07-19). Every busy-spinning service is gone. On the T630 the idle reading went from two cores pegged at 100% to C0 82 / C1 0 / C2 82 / C3 84 quanta/s, and on the Wyse xhci fell from 100% to ~0%. Validation: identity 24/24; chaos max-carnage 882 deaths on the T630 and 66 on the Wyse, all recovered, zero panics / page faults / liveness wedges; selfcheck 349 pass / 0 fail on both machines, before and after the storm. Crucially the slow-idle path re-engages after the storm (C1 back to 0), so the tick slowdown is not a one-shot boot-time state.

What is deliberately NOT done: the BSP still ticks at the normal rate (it owns MONOTONIC_TICKS, scan_timed_wakes, and polled UART RX) - see §14.4, Phase 2b. observe's live repaint is transmit-bound at 115200 baud, measured at ~87 us of THRE spin per byte and confirmed linear (halving the frame rate halved its CPU, 28% -> 14%); the fix is frame-diffing plus interrupt-driven UART TX, and it belongs with Phase 2b. On firmware-locked Goldmont+ parts (idle: cores may halt = false) the slowdown is correctly inert - those cores spin regardless, so slowing their tick would cost wake latency and save nothing.

1. The problem

GodspeedOS is already lean on RAM (a few MiB used of gigabytes) and most services sit at 0% CPU - they block in recv and wake only when a message arrives. The lone exceptions, visible in observe, are the USB host-controller drivers xhci and ehci, which peg their cores at 100% CPU continuously.

On a desktop this is merely wasteful. On a laptop it is a battery and thermal problem: two of four cores can never sleep, they run hot forever, and idle_can_halt (the mechanism that lets an idle core hlt and cool) is defeated for those cores because they are never idle.

This is not something USB inherently requires. Every other service already does the efficient thing. The USB drivers are the outliers, and the reason is history, not necessity: they settled into a busy-spin watching for input instead of blocking.

2. The tradeoff, named precisely

There are three ways a driver can watch for input, and they are not equal:

ApproachCPU idleLatencyNotes
Busy-poll (current)100%~0loop { check_event_ring(); }, never blocks. Core never sleeps.
Sleep-poll (rejected)lower= sleep intervalloop { check(); sleep(N); }. Shorten N to fix lag and CPU climbs back. No free lunch.
Interrupt-driven (goal)~0%interrupt latency + bIntervalloop { recv(irq); process(); }. Low CPU and low latency.

The prior "cool-c1" attempt picked sleep-poll and was rightly rejected: on the T630 the keyboard felt laggy, because sleep-poll literally trades CPU for keystroke latency. The lesson is permanent: the answer to "polling burns CPU" is not "poll slower" - it is "stop polling and block on the interrupt." See the xhci-cool-c1 REJECTED record.

3. The key realization: the controller already polls, in hardware

A USB keyboard sits on an interrupt endpoint. The xHCI/EHCI controller polls that endpoint in hardware every bInterval (~8-10 ms for a boot keyboard). When there is no keypress - 99.9% of the time - the controller finds nothing and stays quiet. When a key is pressed it completes the transfer and raises a controller interrupt (we already see both fire: <a class="gs-abbr" href="../glossary.html#gl-msi"><abbr title="MSI - Message Signalled Interrupts: a device raises an interrupt by writing a message rather than asserting a pin, so it can be targeted at a chosen core.">MSI</abbr></a> enabled ... vector=0x28 on xhci, USBSTS.USBINT set on ehci, kernel deliver() vector=0x29).

So the software driver spinning on the event ring is redundant with polling the hardware is already doing for us, better. The fix is to let the controller do the periodic polling it was built for, and wake our driver only when a report actually lands. The polling does not disappear; it moves to where it is cheap.

This is exactly how Linux and macOS work, and it is worth being clear that it is not exotic:

  • Linux submits a URB on the interrupt-IN endpoint and returns; the host-controller driver gets an MSI on completion, runs its ISR, fires the URB completion callback (usbhid), which processes the report and re-submits. Between keystrokes the CPU does nothing for USB.
  • macOS does the same via IOKit (IOUSBHostFamily/IOHIDFamily): an async interrupt-endpoint read, completed by an interrupt, a callback that re-arms. Event-driven, no software poll.

Interrupt-driven USB is the industry-standard driver model. GodspeedOS's busy-spin is simply the part not done yet.

4. The fix: block on the interrupt (matches every other service)

The constitution already describes this model (§12): "Kernel routes interrupts. ... Driver Service ... recv() returns interrupt event." The change is to make the USB driver main loop block on its interrupt endpoint - the same recv-blocking shape every other service already uses - rather than spin on the controller's event ring.

When blocked, the driver's core draws near-zero power and can hlt (idle_can_halt). A completion interrupt wakes it instantly on a real keypress. Expected result: the two pegged cores drop from 100% to ~0% at rest, spiking only while input is actually flowing.

5. The sharp edges (and what probably bit the last attempt)

Interrupt-driven is the right model, but "the keyboard felt delayed" is a real risk to design out. The likely culprits are not the model itself:

  1. Cross-core wake latency. The USB IRQ lands on core 0 (kernel deliver() vector=0x29 on core 0) while the driver runs on another core (ehci on core 3, xhci on core 2), so every keystroke pays a cross-core IPI + reschedule to wake the driver (§8.8). Bounded, but per-keystroke. Mitigation: pin the USB driver to the same core the IRQ routes to, making the wake local - no IPI. This alone may explain past lag.
  2. Interrupt moderation. xHCI has an interrupt-moderation register (IMOD). If set to coalesce, interrupts are batched and input feels delayed. It wants to be low/off for HID.
  3. Missed interrupt = dead keyboard. The failure mode of pure interrupt-driven: if one completion interrupt is ever lost (a re-queue race, a controller quirk), a driver blocked forever leaves the keyboard dead. This is the real reason a busy-poll feels "safe", and it must be designed around, not ignored.

6. The shape we aim for: interrupt-primary + a slow watchdog

The GodspeedOS "bounded and loud" answer to the missed-interrupt risk is interrupt-primary with a slow watchdog fallback:

  • The main loop blocks on the interrupt endpoint (asleep, ~0% CPU, core can hlt).
  • A completion interrupt wakes it instantly on a real keypress; it processes the report(s) and re-arms.
  • Hot-plug rides the port-status-change interrupt, not a port poll, so even enumeration stops needing a spin.
  • A slow watchdog wake (order 250 ms - 1 s) is the only residual poll. It costs almost nothing, and if an interrupt was ever missed it re-checks the ring and self-heals instead of dead-keyboarding. 99.99% asleep, instant on input, robust against a lost IRQ.

This is strictly better than busy-spin on both axes (CPU and latency) and more robust, because a busy-spin has no self-heal story either - it just happens to re-read the ring constantly.

7. The next lever: tickless idle

The USB fix is the 90% win. The next lever, and the one with the most GodspeedOS-specific tension, is tickless idle. Today every core takes a ~100 Hz scheduler tick (the 10 ms quantum), so even a fully idle, blocked core wakes 100 times a second for a tick that finds nothing to do. Linux (NO_HZ/dynticks) and macOS stop the tick on an idle core and let it sleep until a real event.

The tension is real and is why this is a later, careful step, not a free flip:

  • The liveness watchdog detects a silently-wedged core by the tick (a core that stops ticking is how we notice it stalled). Tickless idle removes that signal for idle cores, so the watchdog must learn to distinguish "idle on purpose" from "wedged".
  • Preemption is tick-driven; a core with runnable work still needs its tick. Tickless applies only to a core whose run queue is empty and that is parked.

Reconciling "let idle cores sleep through the tick" with "still notice a core that stopped ticking because it wedged" is the design knot. §14 is the worked design that solves it - and the key move is that GodspeedOS should slow the idle tick rather than stop it, which sidesteps the watchdog tension entirely and keeps a lost-wake safety net.

8. Further out: efficiency-core placement (arch-gated)

Apple's battery edge is mostly not the driver model (Linux matches it); it is system-wide wakeup minimization (timer coalescing, tickless), owning the silicon to sleep deeper than a general-purpose OS safely can, and heterogeneous cores - routing light work (a keystroke handler) onto tiny efficiency cores while the performance cores stay asleep.

GodspeedOS is unusually well-shaped for the last one: it already separates identity from location and places services statically (§9.2). On a big.LITTLE / Apple-Silicon-style ARM target

  • exactly where the aarch64 port is headed (docs/aarch64.md) - the placement policy could route light services (USB, events) onto efficiency cores by class, not just by core number. The architecture is already shaped for it; the hardware is not here yet.

The honest ceiling: GodspeedOS runs on commodity hardware with firmware it cannot trust (the Goldmont+ APIC power-gate quirk that forced the sti-only idle spin is exactly this), so it will always be more conservative about the deepest sleep states than Apple, who owns the whole stack. Linux is in the same boat, for the same reason. We can match the driver model now, borrow the wakeup-minimization ideas next, and treat deep-package-sleep as hardware-gated.

9. How we measure (not "it feels fine")

The last attempt was judged by feel and failed. This one is judged by numbers:

  • observe before/after: xhci/ehci should drop from 100% to ~0% at rest, spiking only while typing.
  • Typing latency on real hardware. QEMU's timing lies (proven repeatedly this project), so the T630 and Wyse 5070 are the judges. A held key's auto-repeat cadence and a fast-typing burst are the stress tests.
  • Measure on the Wyse specifically, whose Goldmont+ APIC has the power-gate quirk we already had to design idle_can_halt around; USB interrupt delivery through C-states is exactly the kind of thing that behaves differently there.

10. Constitution alignment

  • §12 (Drivers and Interrupts): the target model is the one the spec already describes - drivers receive interrupt events, they do not poll hardware.
  • §26.6 (Bounded behaviour): a busy-spin is unbounded CPU by construction; interrupt-primary + a bounded watchdog is bounded and explicit.
  • §20 / §26.12 (Correctness before performance): here correctness and efficiency align - the interrupt-driven design is both more correct (it is the intended model) and more efficient. No invariant is traded for the win.

11. Critical prior art: this was built, tested, and reverted

Before writing anything, know this: block-on-interrupt USB was fully implemented, wired end to end, hardware-tested on the T630/Wyse, and then deliberately reverted to busy-poll (merged branch feat/usb-interrupt-driven, merge 49021aa). The kernel infrastructure survives and is live; only the drivers' reliance on it was removed. The revert arc:

  • e767956 ehci: block on the interrupt instead of busy-polling
  • 9d1c43c / b0e40e2 usb: per-core / BSP shared-tick recv_timeout wake (fighting input lag)
  • e68a1aa ehci: revert to busy-poll - "EHCI interrupt is dead"
  • efff4d1 xhci: busy-poll only while a key is held (auto-repeat), block otherwise
  • fafcd0e usb: scale both back to busy-poll - "return to the flawless state"

So this is not "add an interrupt path". It is "make a reverted approach actually work", and the two controllers are different problems:

  • xHCI (MSI, edge-triggered) - tractable. Reverted for tuning (input lag, sluggish auto-repeat, hot-plug wedges), not a hardware wall. The MSI-X interrupt is still enabled and drained today (services/xhci/src/main.rs), it just does not gate the loop. The genuinely new lever: the drivers stayed pinned to cores 2/3 while the USB IRQ lands on core 0, so every wake paid a cross-core IPI - co-locating the driver with its IRQ core was never tried, and it aims straight at the lag the revert cited. Auto-repeat needs care because a held key emits no new USB reports (repeats are synthesized from a timer), so the loop must still wake on a deadline while a key is down - the efff4d1 "busy-poll only while held" hybrid is one answer.
  • EHCI (level INTx) - the hard blocker. The driver documents that its level line stops asserting once the async schedule goes cold, so a blocked recv never wakes (deliver() fired zero times once it blocked, across many T630 flashes). That is a hardware-model conflict, not tuning. Worth re-checking whether the keyboard's interrupt transfers belong on EHCI's periodic schedule (frame-driven, always live) rather than the async schedule before concluding it is impossible - but until that is re-verified on hardware, EHCI stays busy-poll.

(2026-10-25: superseded as a description of the drivers today. xhci blocks on its interrupt (Phase 1a, §12), ehci stopped spinning in Phase 1b, and since 2026-10-22 both wait in Irq::wait (docs/driver-library.md, step 4). ehci still falls back to a short poll deadline when it sees a keyboard report arrive with no interrupt (hid_needs_poll, services/ehci/src/main.rs), because the keyboard is still on the async schedule.)

12. Phasing (revised for the prior art)

  1. Phase 1a - xHCI interrupt-driven, with IRQ-core co-location. DONE + HW-VERIFIED (Wyse 5070, 2026-07-19: observe shows xhci BlockRecv at ~0% CPU, was 100%; its core idle; keyboard fully responsive; zero panics). The new angle the last attempt missed: route the xHCI MSI to, and pin the driver on, the same core, so the wake is local (no IPI). Block on recv_timeout when idle; keep a short deadline / brief busy-poll only while a key is held (auto-repeat); a slow watchdog wake keeps hot-plug detection alive. Judge by observe (100% -> ~0% at rest) and hardware typing feel - QEMU timing lies, so the T630/Wyse are the sole judges. The Wyse (xHCI only, no EHCI) gets the full win here.

  2. Phase 1b - EHCI: DONE, but NOT the way this section predicted (2026-07-19). The verdict below stands on its own terms and is left intact because the reasoning is still correct: making EHCI genuinely interrupt-driven does need a from-scratch periodic-schedule engine, and that remains unbuilt. What the verdict missed is that the power goal never required it. Needing to POLL is not the same as needing to SPIN. Replacing yield_cpu() with ctx.sleep() at the three idle sites dropped ehci from 216,810 to 84 quanta/s (~2,600x) with continuous key-repeat intact - a handful of lines, no new engine. The earlier "cool-c1" sleep-poll attempt failed only because sleep's floor was an uncalibrated ~1 s quantum on this machine; once the APIC/TSC PIT calibration landed, the cheap fix simply worked. Lesson worth keeping: when an expensive mechanism is deferred, re-check whether the goal still needs it. The periodic engine is still the answer for EHCI latency; it was never the answer for EHCI power.

    The original (still-accurate) analysis of why interrupt-driven EHCI is hard: DEFERRED (needs a from-scratch periodic-schedule engine; see §13). The investigation found the keyboard sits on EHCI's ASYNC schedule and the PERIODIC schedule is entirely absent, so a blocked driver cannot wake - hardware-confirmed on the T630. Interrupt-driven EHCI requires building periodic split-interrupt scheduling from scratch (§13): a medium-to-large lift that benefits ONLY the T630 (legacy USB 2.0; modern laptops are xHCI-only, so it does not serve the laptop-battery goal). EHCI stays busy-poll (the known-good state). Do not re-break the flawless state for a CPU number on a non-laptop.

  3. Phase 2 - tickless-ish idle. Stop ticking a parked, empty-run-queue core, reconciled with the liveness watchdog and preemption (§7). The next system-level lever.

  4. Future (arch-gated) - efficiency-core placement. On a big.LITTLE ARM target, route light services onto E-cores via the placement policy (§8).

Discipline for this branch: the busy-poll state is the known-good baseline. Every change is hardware-judged against it, behind a feature flag or an easy revert, and we do not trade typing feel or hot-plug robustness for a CPU percentage (§26.12 - correctness before performance). The failure mode to respect is a dead or laggy keyboard, which is exactly what reverted the last attempt.

13. EHCI investigation: interrupt-driven needs the periodic schedule (verdict)

A deep read of services/ehci/src/main.rs settled whether interrupt-driven EHCI is viable. Verdict: not without building a from-scratch periodic-schedule engine.

What the code does today. The keyboard's interrupt-IN queue head is linked into the async schedule (ASYNCLISTADDR + USBCMD.ASE), with the head-of-reclamation-list bit set and the QH's S-mask / C-mask fields (dword 2 bits 0-15) zero - the structural signature of an async QH. The periodic schedule is entirely absent: PERIODICLISTBASE is never programmed (it stays 0, matching the boot log), there is no frame-list array in the DMA arena, and there is no USBCMD.PSE path. The driver keeps the endpoint alive by re-arming a fresh ACTIVE qTD every time one completes (arm_int).

Why blocking fails (confirmed, not just claimed). An async interrupt endpoint only advances while the driver re-arms it. Block the driver and it stops re-arming, the QH goes idle, no qTD retires, no USBSTS.USBINT, no deliver(). The driver's own note ("INTx never reached the kernel once the driver blocked, proven across many T630 flashes") is exactly consistent with this. A periodic interrupt endpoint is different: the controller walks the periodic frame list every microframe autonomously, so a completion raises USBINT regardless of driver activity - which is what lets a blocked driver wake.

What interrupt-driven EHCI would require (from scratch).

  1. Allocate a periodic frame list (1024 dwords, 4 KiB-aligned) in the DMA arena.
  2. Program PERIODICLISTBASE, enable USBCMD.PSE, wait USBSTS.PSS.
  3. Build the interrupt QH with a non-zero S-mask (start-split microframe) and, because the T630 keyboard is a low-speed device behind a high-speed hub's Transaction Translator, a C-mask (complete-split microframes) - a periodic split-interrupt transfer.
  4. Link the QH into the periodic tree at the polling interval, instead of the async reclamation ring.
  5. Keep IOC on the qTD (already done) so periodic completions raise USBINT autonomously; then the driver can genuinely block on its interrupt (vector 0x29).

This is a medium-to-large change - a second schedule engine, new DMA regions, split-interrupt S/C mask computation, periodic-tree linking - and every existing helper (control, arm_int, poll_devices) assumes the async model. The simpler "async + short watchdog + block" alternative is a hardware-proven dead-end on the T630 (INTx goes cold), so it is not an option here.

ROI and recommendation - why this is DEFERRED, not built now.

  • It benefits only the T630. EHCI is legacy USB 2.0. The Wyse 5070 is xHCI-only (its EHCI driver just idles). Modern laptops are xHCI-only too - EHCI was phased out of new silicon years ago. So interrupt-driven EHCI does not serve the stated laptop-battery goal; the xHCI work (Phase 1a, done) is the part that does, and it already covers any current laptop.
  • The T630 is a desktop thin-client, not battery-powered. The only real payoff there is fan/heat from the one hot core - worth something, but not the headline.
  • It cannot be validated without the T630. QEMU's split-TT periodic emulation may not faithfully reproduce the T630's rate-matching hub, so this must be built iteratively against real hardware, behind a feature flag (default = busy-poll, the known-good state), with the operator testing typing feel and hot-plug each step. Writing it blind risks the T630's only keyboard path.
  • The higher-ROI next lever is Phase 2 (tickless idle, §7). It reclaims idle-tick wakeups on every core and machine - including whatever core EHCI busy-polls on - so it helps the T630's power/heat picture too, without the risk of rebuilding the T630's keyboard stack. It is the better next investment for the laptop goal.

Decision left to the operator: pursue the periodic-schedule engine (feature-flagged, T630-iterative) when the fan/heat justifies it, or leave EHCI on busy-poll and move to Phase 2. This section is the ready-to-implement design for whenever it is chosen. No speculative driver code was written, to keep the T630 keyboard's known-good path intact.

14. Phase 2 design: slow the idle tick (tickless idle, done the GodspeedOS way)

This is the build-ready design for §7. The investigation of the scheduler/timer/watchdog machinery (kernel/src/task/scheduler.rs, arch/x86_64/boot.rs, arch/x86_64/interrupts.rs) settled the shape.

14.1 The mechanism today

  • Per-core, self-re-arming timer. Each core (BSP and every AP) arms its own LAPIC timer at init_local_apic. On real HW it is TSC-Deadline one-shot: the timer ISR re-arms it every tick, right after EOI. ~100 Hz (~10 ms quantum, PIT-calibrated). QEMU uses periodic mode (hardware auto-reload; the watchdog is disabled there).
  • Idle cores still tick. When a core's run queue is empty it calls wait_for_interrupt() - sti; hlt where IDLE_CAN_HALT is true, else sti-only spin. But the timer keeps firing every quantum regardless, so an idle core wakes 100x/s to run an ISR that finds nothing and re-arms itself. That is the waste.
  • The liveness watchdog (the tension). Each core stamps its own TSC into CORE_LAST_TICK_TSC[cid] in the timer ISR. Every core, every tick, checks every other core and panics the whole machine if one has made no progress for tpq * 300 (~3 s). The stamp is per-core-self; the check is cross-core. So a core that simply stops ticking does not self-exempt - it is policed by all the others and gets the machine panicked after ~3 s. Nothing today distinguishes "idle on purpose" from "wedged"; the tick is the only liveness signal.
  • The BSP-only monotonic clock (the second hard constraint). MONOTONIC_TICKS is advanced in exactly one place - scan_timed_wakes, driven only from the BSP's timer ISR (cid == 0). It is the single core-independent clock (AMD cores need not share a TSC), and every timed wake rides it: recv_timeout, the xHCI hot-plug watchdog, keyboard auto-repeat, block_rpc deadlines. The BSP also drives COM2 control (process_pending) and COM1 input (uart_rx_poll) from its tick. So if the BSP ever stops ticking, all timed wakes and console input die system-wide.

14.2 The design: slow the idle tick on halt-capable APs

The elegant move is not to stop the idle tick (which forces a watchdog exemption and loses the lost-wake safety net) but to slow it:

  • Idle AP (run queue empty, cid != 0, IDLE_CAN_HALT): re-arm the timer at a long IDLE_QUANTUM_MULT quanta (~1 s) instead of the ~10 ms quantum, then hlt. The core now wakes ~1x/s instead of ~100x/s - or immediately on real work.
  • Busy AP (>=1 runnable task): the normal ~10 ms quantum, unchanged (preemption needs it). The transition idle->busy must re-arm the quantum promptly when a task is picked, or a newly-runnable busy task would go un-preempted for up to ~1 s. A per-core "idle-tick mode" flag (set when arming long, cleared + re-armed to quantum when pick_next returns a task) is the mechanism.
  • BSP (cid == 0): always the ~10 ms quantum. It is the heartbeat - the monotonic clock, timed wakes, and COM2/COM1 polling all ride it. One always-ticking core is the cost of not re-homing that clock (Phase 2b, §14.4). This is why the win is on the APs.

Why this sidesteps the watchdog entirely (the payoff): a slow-ticking idle core still stamps CORE_LAST_TICK_TSC every ~1 s. Since IDLE_QUANTUM_MULT quanta (~1 s) is comfortably under the watchdog threshold (tpq * 300 ~ 3 s), every policing core still sees it as alive. No watchdog change is needed - the stamp and check stay exactly as they are. (Design constraint: the idle sleep, IDLE_QUANTUM_MULT quanta, MUST stay below the watchdog threshold, or the watchdog would false-panic an idle core. Couple them, e.g. the idle sleep at a third of the threshold, so they can never drift into a false panic.)

Why the lost-wake safety net survives: today the 100 Hz timer is not only preemption - it is also a 10 ms re-poll of the run queue that recovers a lost cross-core wake (a dropped WAKE_RECEIVER IPI). A slow idle tick keeps that recovery, just at ~1 s granularity: a parked core that missed its wake IPI re-checks its run queue within ~1 s and picks up the work. A fully tickless core would lose this entirely (a lost wake = parked forever, invisible). Slowing rather than stopping keeps the GodspeedOS "bounded and recoverable" property (§26.6, §26.7) for a negligible power difference between 1 Hz and 0 Hz.

Waking on real work is unchanged: a WAKE_RECEIVER IPI (cross-core send / wake_by_slot) or a device IRQ wakes a hlt-ed core immediately, regardless of the timer. The slow tick is only the fallback; real work still wakes the core instantly.

14.3 Gating and scope

  • Only where IDLE_CAN_HALT is true. The win is real only when the core actually hlts between ticks (then fewer ticks = deeper/longer sleep = less power). On Goldmont+ (the Wyse 5070), C-states are BIOS-locked and IDLE_CAN_HALT is false, so idle cores sti-spin and never sleep - slowing the timer saves nothing there (their idle spin is a separate, firmware-gated problem, and note observe hides it: an idle core reads 0% task-share while physically spinning). So Phase 2a helps the T630 and typical laptops with working C-states/ARAT, not the Wyse.
  • Only the APs (cid != 0). The BSP stays 100 Hz (§14.2).
  • Expected win: on a 4-core T630, three APs drop from ~100 Hz to ~1 Hz idle wakeups (sleeping ~1 s at a time instead of 10 ms); one core (BSP) keeps the heartbeat. This is the deep-sleep enabler a laptop needs. It does NOT rescue a core EHCI busy-polls at 100% - only interrupt-driving or gating EHCI does that (§13).

14.4 Phase 2b (future, harder): a fully-tickless BSP

To let core 0 also sleep deep, its tick-borne duties must move off the scheduler tick onto an always-on timer decoupled from it: scan_timed_wakes + MONOTONIC_TICKS (re-armed as a one-shot to the next pending deadline rather than a fixed 100 Hz), and COM2/COM1 polling (interrupt-driven UART RX instead of polled). That is a larger change and is deferred; Phase 2a (AP slow-tick) captures most of the win first.

14.5 Validation

  • QEMU proves correctness, not power. Under load: preemption still fair (two busy tasks on one AP still round-robin at 10 ms), timed wakes still fire (recv_timeout, auto-repeat, hot-plug), a cross-core wake still schedules promptly, and no false liveness panic over a multi-minute idle soak.
  • Hardware (T630, IDLE_CAN_HALT true) proves power. observe will not show it - an idle core already reads 0% task-share. Need a wakeup counter (ticks/sec per core) or a real thermal/power read. A minute of idle should show the APs at ~1 tick/s and the BSP at ~100.
  • Risk: this touches the scheduler timer core; the known-good baseline is "always 100 Hz". A bug is a missed preemption (a busy task starves) or a stalled timed wake - serious, so build it behind care and iterate on hardware, exactly like the driver work. The watchdog-coupling constraint (§14.2) is the one invariant that must not be violated.

15. The power service: leases on the Arm clock (2026-10-01)

Status (2026-10-01): built - the power SERVICE, the first piece of this document's strategy that lives in its own service rather than inside a driver or the scheduler. One responsibility so far: the Arm cores' clock. QEMU-booted; the hardware result is recorded in docs/wifi.md 57.

15.1 Why it exists

The Raspberry Pi firmware owns the Arm clock. With no operating system asking it for a rate, it runs the cores at turbo for initial_turbo seconds after boot (60 by default since the November 2024 firmware) "or until cpufreq sets a frequency", and then at their minimum for good. GodspeedOS had no way to ask, so every Pi 4 ran at its minimum clock from one minute after boot onward.

That mattered the first time something needed speed. The WiFi chip's firmware traps at start when the driver uploads it with the cores at their minimum - every load after the first minute did, every load inside it did not, and holding the cores at turbo with force_turbo=1 made every power cycle come up cold (docs/wifi.md 55). Holding them at turbo forever fixes that and wastes power the rest of the time. The machine should be fast exactly when something needs it to be.

15.2 The split: mechanism in the kernel, policy here

The kernel sets the clock to the firmware's minimum or its maximum, and reports what the clock reads back (CpuClock, syscall 55). It cannot name any other rate, and it learns nothing about why a rate is wanted. It is in the kernel at all because the only way to ask the firmware is its mailbox, and the kernel already owns that channel - the SD power, the GPIO expander and the radio's power cut all go through it. A channel with one owner cannot be shared with a service without two parties writing to it and nothing ordering them. This is the same shape as DevicePower (CLAUDE.md 12.3).

This service holds CPU_CLOCK, the authority to make that syscall, and is the only service that does: the clock is one machine-wide setting, so a second holder would silently overwrite the first. Everything else asks this service over IPC. It owns the rule - who may ask for speed, for how long, and when to go back (26.10).

The kernel's part is per board. On the Pi 4 the arch layer asks the firmware; on every other port it answers "no control", the service says so once at start, and every lease is answered ST_NO_CONTROL, which a caller treats as "proceed at whatever the clock is".

15.3 The rule: leases

A service that needs the cores fast asks for a lease of N seconds:

RequestReplyMeaning
[1, secs] (HOLD)[status, lease, hz:u32 LE]open a lease; secs 0 means 15, capped at 30
[2, lease] (RELEASE)[status]close it early

Status: 0 OK, 1 every slot is open (8), 2 this machine has no clock control, 3 no such lease (already expired, already released, or from before this service restarted), 4 a malformed request.

While any lease is open the clock is at its maximum; when none is, it is wherever the governor (15.7) has it. The hz in a HOLD reply is the rate the clock read back when that lease raised it, or 0 when the clock was already at its maximum, held by another lease or by the governor.

A lease that nobody releases expires. That is the whole answer to "what if the holder dies or hangs while holding it": the clock comes back down within 30 seconds at most, and the expiry is said in the log, so a holder that keeps letting leases run out is visible. No death notification is involved, which is why this service needs none.

15.4 Restart

A respawned instance knows of no lease, so it puts the clock at its minimum - the honest state for "nobody has asked". A holder whose lease died with the old instance runs slower until it asks again; nothing is left stuck fast with nobody to bring it down. This is also what it does at first boot, which ends the firmware's minute of turbo early: from that point the machine is fast when something says it needs to be, and not otherwise.

15.5 Who holds leases today

(The governor, 15.7, holds no lease: it is the other half of the same rule.)

wifi-driver, across its bring-up: from the moment it holds the SDIO window, through the firmware upload and the first scan, to the moment it starts serving (about ten seconds measured on the card, docs/wifi.md 57). Optional on its side: a power that is absent or refuses costs one log line and the load goes ahead at whatever the clock is.

15.6 What is not here yet, and is not promised

  • Load-based scaling is built now (15.7). This line said it was not, because nothing needed it yet (26.2); a download needed it.
  • Rates between the two ends. The kernel offers the firmware's minimum and maximum and nothing in between. Widening that is a kernel change and needs a reason.
  • Other power: device power-down, thermal. This is where they would live. Neither exists.
  • Shutdown (2026-10-14, docs/shutdown.md): this service gives the warnings and the tidy-up three seconds before the deadline, reading the one deadline the kernel holds. It holds no authority to turn the machine off; the shell does.

15.7 The governor: the clock by load, as Raspberry Pi OS sets it (2026-10-21)

Why. Every download on the Pi 4 ran with the cores at 600 MHz, 40% of their maximum: the only lease was the radio's firmware load, over by eight seconds after boot. A download is work for the cores - decrypting, copying each frame from service to service, reading the radio's FIFO a word at a time - and nothing in it knew to ask for speed, nor should it have to.

What Linux does, read from the source rather than remembered (raspberrypi/linux rpi-6.6.y, and RPi-Distro/raspi-config master):

  • drivers/cpufreq/raspberrypi-cpufreq.c asks the firmware for its minimum and maximum and registers every 100 MHz step between them (RASPBERRYPI_FREQ_INTERVAL), 600 to 1,500 MHz on a Pi 4.
  • arch/arm64/configs/bcm2711_defconfig makes ondemand the default governor (CONFIG_CPU_FREQ_DEFAULT_GOV_ONDEMAND=y).
  • drivers/cpufreq/cpufreq_ondemand.c, od_update: each sample, the load is the busiest core's share of non-idle time over the interval (cpufreq_governor.c, dbs_update). Over up_threshold the clock goes to its maximum and, if it was below it, the next sample waits sampling_down_factor intervals. At or under it, the clock goes to the step nearest min + load * (max - min) / 100, and the next sample is one interval away.
  • The kernel's defaults are an up_threshold of 95 (80 without microsecond idle accounting), a sampling_down_factor of 1, and an interval of 10 ms on this board (the driver's steps carry no transition latency, so cpufreq_policy_transition_delay_us caps it).
  • Raspberry Pi OS overrides them at boot, in debian/raspi-config.init: up_threshold 50, a sampling_rate of 100,000 us, sampling_down_factor 50, and io_is_busy 0 (time waiting on I/O counts as idle).
  • drivers/cpufreq/cpufreq-dt.c puts every core in one policy when no device-tree table says otherwise (cpumask_setall). Whether the Pi 4's device tree carries one was not checked; the driver adds its steps at run time.

What this does, and where it differs. Every 100 ms the busiest core's share of busy timer ticks (InspectKernel queries 6 and 7, the counters observe shows) is read. Over 50% puts the clock at its maximum, and the next look is 50 samples, 5 s, away; at or under 50%, the minimum, and the next look is 100 ms away. The kernel offers only the two ends (15.6), and for a load at or under half the step ondemand would pick is nearer the minimum, so on this board the rule is the same. Busy ticks count only time a task ran, so time spent blocked waiting on a device is idle, as io_is_busy 0 makes it. Not copied: ondemand's case for a core that was idle for two intervals and has just woken, which reuses its previous load so a burst starts fast. A burst here waits for the next 100 ms look.

With the leases. The clock is at its maximum while any lease is open OR the governor says so, and at its minimum only when neither does. A lease opened while the governor holds the clock fast is answered with hz 0, as one that joins an open lease is.

What it needs. INTROSPECT, to read the counters, granted in the supervisor's spawn row and pinned in COMMANDMENTS.baseline.toml (operator approved, 2026-10-21). It reads; it cannot act on anything it reads. On a board with no clock control the governor never starts.

Design Spec: Move Naming Out of the Kernel

Status: COMPLETE. Every phase - 0a through 6 - is built and merged. The supervisor wires every service from a name → cap map at boot and on restart; the registry SERVICE is retired and deleted (Phase 4); init is removed and the kernel spawns the supervisor directly (Phase 5); and the supervisor is itself restartable, respawned by the kernel unconditionally and forever (Phase 6), so the unkillable set is {kernel} alone. End state is Path C (§3.7) - it supersedes the original §3.5 "retire registry, supervisor = sole namer." The constitution amendments (§6) landed with their phases. The spec wins on any conflict; this doc trails it.

Author intent (2026-06-20): the kernel currently performs a policy job - resolving service names to endpoints - which §26.10 says belongs in a service. Pull it out so the kernel is pure mechanism, the supervisor owns naming, and there is no "the kernel already resolves names" precedent for future scope creep.

Revised goal (2026-06-21, Path C, §3.7): the deeper prize is shrinking the unkillable set to its theoretical minimum - just the kernel (§6.3). Reaching that requires a recovery anchor that cannot die, which only the kernel is. So the kernel keeps a minimal name→endpoint recovery directory (one bounded exception), the registry service retires into it, init is removed, and the supervisor becomes restartable (kernel respawns it; it recovers from the directory). This softens §26.10 (a thin naming facility stays in the kernel) to better serve §6.3 (unkillable = {kernel}). §§1-3.6 below record the original reasoning that got us here; §3.7 is the chosen end.


1. Motivation

1.1 The kernel does naming policy today

Two kernel facilities resolve names → endpoints:

  • kernel/src/ipc/names.rs - a SpinLock<[NameEntry; 128]> mapping service name → EndpointId. Populated at spawn (task/mod.rs, names::register(name, ep_id)), read to wire each service's declared send_peers SEND caps (task/mod.rs, names::lookup(peer_name)).
  • Syscall 10 AcquireSendCap(name, include_grant) (syscall/dispatch.rs) - ungated: any task may mint a SEND cap to any registered name. Exposed in the SDK as reacquire_cap / acquire_send_cap.

Resolving another service's endpoint by name, and minting a cap to it, is policy - a decision about who may talk to whom. Per §26.10 ("the kernel is mechanism, not policy") and §4.4 (kernel anti-scope), that belongs in a service, not the kernel.

1.2 The scope-creep precedent

The concrete danger (the author's argument): once the kernel resolves names, every future feature can justify itself with "the kernel already does the registry, so my thing fits too." That is how a microkernel rots. Removing naming removes the precedent.

1.3 The registry-bootstrap bug made it visible

chaos kill-storm exposed that a client could not reacquire its registry cap after the registry restarted - you cannot look the namer up in the namer (the bootstrap chicken-and-egg, see the registry-bootstrap investigation). The shipped fix (merged ea99322) is a deliberate stopgap: in registry_lookup, on a dead registry cap, fall back to the kernel name table via reacquire_cap("registry") (syscall 10) and retry. That stopgap re-embraces the very kernel path this spec deletes. This work removes the stopgap by giving clients a real, non-kernel bootstrap anchor.


2. The line: what stays vs what leaves

The single most important clarification - there are three distinct "naming" things in the kernel, and only one leaves:

Kernel facilityKindVerdict
Routing table (EndpointId → core/gen/liveness)mechanism - opaque ids, the kernel routes messagesSTAYS
Cap mint / validate / install, the spawn syscall, IPC enqueue/IPImechanismSTAYS
Task labels ("this task is fs") for death notices, observe, logsmechanism - a task's own identity, not third-party resolutionSTAYS
name → ELF + ServiceConfig for spawning embedded services (service_config)a packaging reality at the time (ELFs were include_bytes!'d into the kernel image) - a separate concernOUT OF SCOPE here, see §7 (since moved to the supervisor)
name → EndpointId resolution for IPC send-peers (ipc::names)policy - who may reach whomLEAVES
Syscall 10 AcquireSendCap (ungated mint-by-name)policy + ambient surfaceLEAVES
Spawn-time send-peer name wiring (the names::lookup loop in spawn_service_with_config)policyLEAVES

Key distinction. The kernel keeps task labels (it may report "the task you named fs died" - reporting the lifecycle of its own tasks, by the label the supervisor gave at spawn). It loses name resolution (handing a third party a cap to a service it names). The first is mechanism; the second is policy. Routing stays opaque: the kernel routes EndpointIds and never needs a name.


3. Target architecture

3.1 The bootstrap principle

A name service cannot bootstrap from itself - resolving a name needs something already reachable. The only things that never die in this system are the kernel and the TCB (init + supervisor). The supervisor is the natural anchor: it is already non-restartable, already the restart authority, and - in this design - already holds a cap to every service it spawned. So:

The supervisor is the name authority and the bootstrap anchor. Every service holds a stable cap to the supervisor (wired at its own spawn). Naming and reacquisition flow through it.

3.2 New spawn protocol (kernel = mechanism)

The spawn syscall changes from "resolve names, wire caps" to "install the caps I hand you, and give me back a cap to the new endpoint."

spawn(name, core, install: [(peer_label, cap_slot)]) -> (task_id, endpoint_cap_slot)
  • Input caps. For each (peer_label, cap_slot), the kernel validates the caller holds cap_slot with GRANT, derives a copy into the new task's cap table, and records peer_label → child_slot in the child's send-peer metadata - so the child's ctx.capability(peer_label) / find_send_slot(peer_label) resolves exactly as today. The kernel never consults a name table; it installs what it is handed.
  • Returned endpoint cap. If the new task has a recv endpoint, the kernel mints a SEND|GRANT cap to it and inserts it into the caller's (supervisor's) table, returning the slot. This is the supervisor's handle to wire that service into future dependents.
  • Still kernel-minted, still mechanism. The kernel continues to mint caps for resources it owns
    • memory, MMIO/DMA/IRQ for drivers (per contract), the endpoint it just created, delegated resource caps (RESOURCE_MINT, §7.10). What it stops doing is resolving one service's name to another service's endpoint.

3.3 The supervisor as name authority

The supervisor already spawns every service in dependency order (pong before ping, block-driver before fs, …). In the new model it collects each child's returned endpoint cap into a userspace name → cap map, and wires dependents from that map:

ep_pong = spawn("pong", 1, [])                    # leaf: no peers
ep_ping = spawn("ping", 0, [("pong", ep_pong)])   # ping gets a SEND cap to pong, by being handed it

The map lives in the supervisor (TCB userspace), built by construction. The contract's send_peers becomes a requirement the supervisor reads and fulfils (it must already hold a cap to each named peer - enforced by spawn order), preserving §13.3 ("the service declares what it needs; the OS decides whether to grant it"). A contracted peer the supervisor lacks is a loud spawn failure, not a silent skip.

3.4 Reacquisition (deletes the stopgap)

A client whose cap to fs (or any service) goes stale after a restart asks the supervisor for a fresh one, over its stable supervisor cap:

client: reacquire("fs")  ──▶  supervisor
supervisor: derive a SEND copy of its held fs cap, grant it back   (DeriveCap + SendWithCap, §7)
client: cache it, retry

This is explicit reacquisition (§14.3 / §26.5 - no silent rebind), anchored on the one thing that never dies. It works for every name including the registry's role, because the anchor (supervisor) is reached by a bootstrap cap, not by a lookup. The registry_lookup syscall-10 fallback is removed.

3.5 Fate of the registry service - DECIDED (2026-06-20): retire it

Today the userspace registry (H11) is a separate restartable name service. In this design the supervisor is the name authority (it holds the authoritative name → cap map by construction), so the registry's job collapses into the supervisor:

  • Recommended: retire registry. The supervisor serves register/lookup/reacquire directly. One name authority, no separate service, and the bootstrap chicken-and-egg disappears (there is no registry cap to reacquire - only the supervisor cap, which never goes stale). Net fewer moving parts (§26.13). The H11 "registry is restartable" achievement becomes moot, not lost - there is simply no registry to restart; the supervisor was always non-restartable TCB.
  • Alternative: keep registry as a restartable query front-end seeded by the supervisor, for high-volume dynamic discovery, so the supervisor isn't on every lookup path. Clients still reacquire the registry cap from the supervisor (bootstrap). This keeps the supervisor lean but keeps a service and a cache-coherence concern.

Decision (signed off 2026-06-20): retire registry - superseded 2026-06-21 by Path C (§3.7). The original decision retired the registry into the supervisor, leaving {kernel, supervisor} unkillable. Path C keeps the registry's recovery role as a minimal kernel directory instead, so the supervisor can also be restarted and the unkillable set shrinks to {kernel}. The registry service still retires; what changes is where its recovery state lives (kernel directory, not the supervisor). Read §3.7 for the chosen end-state; §3.1-§3.6 are the reasoning that led there.

3.6 Death notifications

Unchanged in spirit. The kernel notifies the supervisor when a restartable service dies. It may report by task label (kept) or by task_id (the supervisor holds task_id → name from spawn). Either is mechanism - the kernel reporting the lifecycle of its own tasks - not name resolution. No change required beyond what the new spawn return already gives the supervisor.

3.7 Path C - kernel keeps a minimal recovery directory (the chosen end-state, 2026-06-21)

§3.1-§3.6 make the supervisor the bootstrap anchor. That works for wiring (done, Phases 0a-3c), but it leaves the supervisor unkillable, and trying to fix that exposes a trap.

3.7.1 The trap, and why only the kernel escapes it

To make the supervisor restartable it must recover its state after a respawn - above all, its caps to the already-running services. But capabilities are not data: a cap is an unforgeable kernel token (ResourceId + Rights + Generation, §7.3), and a service only ever holds opaque handles. You cannot serialise a cap to disk and load it back - that is cap forgery. So persistence can save intent ("fs should be running") but never the caps; those must be re-minted from a live source. And the supervisor cannot re-spawn the services to re-derive them (they are alive - the singleton guard rejects it).

What live source? A separate registry could hold the caps and survive the supervisor - but the registry, if it dies, is respawned by the supervisor. Supervisor needs registry to recover; registry needs supervisor to recover. Mutual dependency ⇒ both must stay up ⇒ both unkillable. The trap.

There is exactly one escape: the recovery anchor must be the one thing that fundamentally cannot die - the kernel (a kernel fault is the machine faulting; nothing beneath it could respawn it). So the kernel holds the minimal recovery state. Everything above the kernel then becomes restartable, and the unkillable set is {kernel} - the theoretical minimum (§6.3).

3.7.2 The one exception: a minimal directory, not the registry

The kernel keeps only what recovery needs - and it already has it:

  • name → EndpointId (ipc::names, populated at spawn - kept), and
  • mint a SEND cap by name (AcquireSendCap, syscall 10 - kept, but now GATED behind a recovery capability, closing the ambient surface §1.1 flagged).

That is not the full registry - no register-permission policy, no rights-narrowing, no delegated-cap bookkeeping. The kernel already routes opaque endpoints; a flat name label per endpoint plus reacquire-by-name is a thin recovery directory, not naming policy. The registry service retires (the directory replaces it); its richer features aren't needed for recovery.

3.7.3 How the supervisor recovers (mechanism, checked)

State the supervisor needsRecovery source
caps to running servicesAcquireSendCap from the kernel directory (re-minted by the kernel)
which services should runa persisted manifest, reconciled against the kernel's live-task list - so a service that died during the supervisor's downtime is noticed and restarted
its death-notification endpointthe kernel re-points death notices to the new instance
service_control + other authority capsre-minted at spawn (the kernel grants them by name)
clients finding the new supervisorclients reacquire it by name through the directory - the directory is the bootstrap, so no special stable endpoint is needed

Capabilities are never persisted; the manifest carries intent and the kernel re-mints the caps. Clients that were mid-request when the supervisor died get EndpointDead and poll until it is back - exactly §14.3, already proven by the chaos double-storm.

3.7.4 The trade - §26.10 vs §6.3 - and why Path C is chosen

Path C softens §26.10 (a thin naming facility stays in the kernel) to better serve §6.3 (reduce the TCB over time - here, to its theoretical floor, {kernel}). It chooses fault-tolerance over the last increment of kernel-naming purity - the right priority for an OS: availability of everything above the kernel outweighs the final scrap of minimalism.

The §26.10 scope-creep worry (§1.2) was about unbounded "the kernel already does X, so my thing fits." Path C's exception is the opposite: one named, documented, frozen exception - the recovery directory - with a hard, non-extensible rationale (the recovery anchor must be unkillable, and only the kernel is). That is a defensible boundary, not a slippery slope. The kernel's naming role still shrank dramatically: from "resolve names to wire every service at spawn" (today) to "a flat recovery directory for re-minting caps after a restart." Phases 0a-3c - moving all wiring to the supervisor - stand unchanged; only the endgame's target moves.

Path C in one line: the kernel keeps a minimal, gated name→cap recovery directory; the registry service retires into it; init is removed; the supervisor is restartable (kernel respawns it, it recovers from the directory). Unkillable = {kernel} only.


4. What the kernel deletes - and keeps (revised for Path C)

Deletes:

  • The names::lookup send-peer wiring loop in spawn_service_with_config - once every service is supervisor-wired at boot and restart (Phases 3b/3c, done). The kernel no longer resolves names to wire anyone.
  • SDK reacquire_via_registry / the userspace registry lookup path, and the registry service itself (Path C, §3.7) - its recovery role moves to the kernel directory.

Note (2026-10-09): the first bullet did not happen as written. The kernel's spawn path (spawn_service_with_image in kernel/src/task/mod.rs; spawn_service_with_config no longer exists) still runs a names::lookup loop that name-wires every declared send-peer the caller did NOT provide - the Phase 0b merge, kept. The supervisor provides caps only where it passes them (spawn_wired); a spawn with no install list (spawn_by_image(.., &[], &[])) sends the declared peer list and the kernel name-wires all of it, and spawn_wired logs kernel will name-wire it for any peer missing from its map. So "the kernel no longer resolves names to wire anyone" is not true of the code: the directory is still consulted at spawn for unprovided peers.

Keeps (the Path C exception - was "delete" under the original plan):

  • kernel/src/ipc/names.rs - the flat name → EndpointId recovery directory (names::register still runs at spawn; names::lookup serves recovery, not wiring).
  • Syscall 10 AcquireSendCap - the re-mint-a-cap-by-name recovery primitive, now GATED behind a recovery capability (closing the ambient surface from §1.1). SDK keeps a thin reacquire_cap.
  • InspectKernel query 2 ("endpoint generation by name") - rides on the kept directory; no longer needs a name-free rewrite.

Also unchanged: routing, cap machinery, the spawn syscall (new shape), service_config (ELF lookup - §7), task labels, MMIO/DMA/IRQ + endpoint + delegated-resource minting.


5. Migration plan (incremental, always-bootable)

The boot/spawn path is the most load-bearing code in the system and is currently hardware-proven (selfcheck 185/0). Do not big-bang it. Each phase keeps the full suite green (identity 23/23, files 137/0, shell 67/0, script 4/0, and selfcheck green) before the next.

PhaseChangeStatus
0aNew SpawnReturningEndpoint syscall - spawn returns the new endpoint cap to the caller. Additive.✅ merged
1Supervisor builds its name → cap map (NameCapMap) from those caps (shadow).✅ merged
0bNew SpawnWithCaps syscall - kernel installs caller-supplied send-peer caps. Wiring becomes a merge (install caller caps, then name-wire any peer not provided).✅ merged
2Flip fs ← block-driver (wired from the map).✅ merged
3aFlip shell ← fs; generalize spawn_wired(name, peers).✅ merged
3bMove registry spawn init → supervisor (§11); provide registry to all services → every real service 100% supervisor-wired at boot.✅ merged
3cFlip the supervisor's restart paths to re-wire from the map (map updates in place + frees the dead cap). Boot and restart now avoid kernel name resolution.✅ merged

Endgame - re-scoped for Path C (§3.7). Each phase a mergeable, always-bootable, suite-green increment; all three are merged.

PhaseChangeStatus
4 - Retire the registry service; the kernel directory becomes the namer.Clients reacquire via the gated kernel directory (AcquireSendCap) instead of the registry service; the registry service + its userspace lookup path deleted. The registry-bootstrap stopgap became the normal path. AcquireSendCap is gated: a caller needs ACQUIRE_ANY, or the name must be one of its own declared send-peers.✅ merged
5 - Remove init; the kernel spawns the supervisor directly.The kernel's one direct spawn is supervisor (task::spawn_supervisor); the logger's spawn moved into the supervisor; init deleted. −1 TCB member.✅ merged
6 - Make the supervisor restartable; unkillable = {kernel}.Kernel respawns the supervisor on death (instead of panic) and re-points death notices to the new instance. The supervisor rebuilds its name → cap map from the kernel directory and reconciles against live tasks, adopting the survivors rather than duplicating them. Pinned by §22 Test 15.✅ merged

Roll-back is per-phase: each phase is a mergeable, green increment, so a regression reverts one phase, not the program. Phases 4 and 5 are mechanical; Phase 6 is the constitutional one (it amends §6.2: the supervisor's death is no longer a kernel panic) and the largest - it gets its own design pass.


6. Constitutional amendments required

To be drafted into CLAUDE.md at adoption (each with a commit rationale, §21):

  • §4.4 (Kernel Anti-Scope): the kernel does not resolve names to wire services - the supervisor owns wiring and cap distribution. The kernel keeps only a minimal name→endpoint recovery directory (Path C, §3.7), a single named, frozen exception for re-minting caps after a restart. (Done in spirit by §11 amendment, Phase 3b.)
  • §11 (Bootstrap): the new spawn protocol; registry spawned by the supervisor (✅ amended, 3b); later, the kernel spawns the supervisor directly (Phase 5).
  • §26.10 (Mechanism, not policy): record the worked example and the bounded Path-C exception - the recovery directory is the one place naming stays in the kernel, justified by §6.3 (the recovery anchor must be unkillable), not a precedent for further kernel growth.
  • §6.1/§6.2/§6.3 (TCB): the big one (Phase 6) - the supervisor becomes restartable: its death is no longer a kernel panic; the kernel respawns it and it recovers from the directory. The non-restartable set shrinks to {kernel} only - §6.3's goal reached at its floor. Retire registry from §6.1 and the Glossary (Phase 4). The registry's own design note was deleted with the service (9a880b12); this document is the record.
  • §13 (Contracts) note: send_peers is a requirement the supervisor fulfils, not something the kernel resolves - sharpening §13.3. (In effect since Phase 3.)
  • §22: extend Test 11 to pin client-resolution-after-restart via the directory (Phase 4); add a new identity test for supervisor-survives-its-own-restart (Phase 6) - the executable form of "unkillable = {kernel}."

7. Out of scope (and why)

  • name → ELF resolution for spawn (service_config). When this spec was written the kernel spawned every embedded service by name, because their ELFs were include_bytes!'d into the kernel image. That was a separate, deeper change and this spec deliberately stopped at IPC name resolution - correctly, since the two are independent: the spawn protocol works whether the ELF is kernel-embedded or supplied by the caller. It has since been made: the supervisor holds every service's image and hands the kernel a pointer (SpawnImage), and the kernel's catalogue is supervisor alone. See docs/service-ownership.md.
  • Multi-node / cluster naming (Appendix C.4) - unaffected; the supervisor-as-name-authority model generalizes cleanly but is far-future.

8. Risks and open questions

  1. Supervisor endpoint load. If registry is retired, every reacquisition (and any register/lookup) hits the supervisor's endpoint, which also carries kernel death notices. Replies must be request/response (no blocking send back into a client), so a slow client can't wedge the restart loop (§8.9). Volume in v1 is tiny (reacquisition is rare); the front-end alternative (§3.5) exists if it ever isn't.
  2. Spawn syscall ABI. Passing a variable-length install list through the register-based syscall ABI needs a small in-memory descriptor (like handle_spawn_pipe already does for its sink string). Bounded, fixed-max install entries (§26.6).
  3. Cap-table growth on the supervisor. It now holds an endpoint cap per service. Bounded by the service count; well within the cap table. Worth a number in Phase 1.
  4. Query 2 consumers. Confirm exactly who reads "endpoint generation by name" before removing it (Phase 5) so a test doesn't silently lose coverage.
  5. Retire vs front-end. Open (§3.5). Resolved by Path C (§3.7): the registry service retires; its recovery role moves to the minimal kernel directory.

Path C-specific risks (Phase 6)

  1. Supervisor recovery completeness. A respawned supervisor rebuilds caps from the directory and reconciles a persisted manifest against live tasks. The gap to watch: a service that dies and is itself mid-restart exactly as the supervisor dies. Reconciliation (manifest vs live-task list) should catch it on the next pass - pin this in the Phase-6 identity test.
  2. Manifest authority + location. The "should-run" manifest is supervisor-owned intent. Simplest: a fixed compiled-in list (the current boot order) - no persistence needed for v1, since the set of services is static. Persisting to fs would re-introduce a dependency (and fs is itself restartable); a compiled-in manifest sidesteps it.
  3. Gating AcquireSendCap (Phase 4). It becomes the universal recovery primitive, so it must be gated (a RECOVER/directory cap) rather than ambient - held by the supervisor and by services that legitimately reacquire their own declared peers.
  4. Kernel re-points death notices (Phase 6). On supervisor respawn the kernel must redirect the death-notification endpoint to the new instance - a small, bounded kernel mechanism (it already tracks the supervisor specially).
  5. The respawn must be un-starvable (Phase 6, hardware-found). The kernel respawns the dead supervisor from poll_supervisor_respawn, which runs at the scheduler loop's top - an IF=1 point Core 0 reaches by blocking or via the timer ISR. Under chaos max-carnage the foreground task never blocks (it paces with yield), so recovery hinged solely on the timer ISR - which a storm starves on real hardware (slow tick + long IF=0 console writes + IPI/shootdown pressure), draining the live set to 0 with no respawn. Fix: drive the pending respawn from the yield path too (yield_current mirrors the timer ISR's pending-respawn routing), so the storm's own hot path drives recovery - un-starvable. Zero cost when healthy (one relaxed load), guarded by the same PENDING/IN-PROGRESS handshake (no double-respawn), and no new unsafe (it lives inside yield_current's existing block). Confirmed on hardware: a 5000-round max-carnage soak sustains the live set instead of draining to 0.
  6. Death handling must be idempotent - the supervisor is a fixed point (Phase 6, hardware-found). A rapid second kill all-services (the whole restartable set killed twice within seconds) can deliver a death notice for a service the supervisor has already recovered - either it adopted a still-running instance on its own respawn (§3.7.3), or a duplicate/stale notice arrives after the respawn already completed. Reacting to that notice naively re-spawns a second instance (the singleton guard then rejects it, and the churn feeds the storm). Fix: the supervisor treats recovery as convergence to a target set, not a per-event reflex. Each death notice reconciles the live-task list against the compiled-in manifest (risks #6/#7) and acts only on the difference - a service already running (adopted or freshly respawned) is a no-op it logs as already recovered; a service genuinely absent is respawned, exactly once. The supervisor thus behaves as a fixed point: from any perturbation - one kill, a storm, its own death mid-storm - it reconverges to "every manifest service alive, exactly one instance each," and a redundant notice is absorbed, never amplified. This is the supervisor-side complement to Invariant 11 (identity is stable; the instance is not): the supervisor restores the identity set, indifferent to how many notices announce the same gap. Confirmed on hardware: a rapid double kill all-services on the T630 recovers the full set with no duplicate spawns and no wedge (the storage-stack half of the same run is docs/persistence.md §6.16).

9. Test strategy

No new test categories - the existing suite is the safety net, run green at every phase (§5). Plus:

  • Phases 2/3 (done): boot + cross-core IPC and the files test (real fs↔block-driver) prove wiring-by-cap matches wiring-by-name; the chaos double-storm proves restart re-wiring.
  • Phase 4: the chaos double-storm regression must still pass with the registry service gone, through the gated kernel directory. AcquireSendCap rejects callers without the recovery cap.
  • Phase 6 (the headline test): a new identity test - kill the supervisor, the kernel respawns it, it recovers its map + manifest, and the system keeps running with no reboot. The executable proof that the unkillable set is {kernel} only. audits/unsafe-audit.md essentially unchanged (the kernel additions - gated directory, respawn-supervisor, re-point death notices - are small and in permitted layers or safe fns).

10. Summary

The kernel stops resolving names to wire services - the supervisor owns all wiring, at boot and on restart (Phases 0a-3c, done: spawn returns endpoint caps; the supervisor installs caller-supplied caps and wires dependents from its name → cap map). What the kernel keeps is one bounded exception: a minimal, gated name → endpoint recovery directory (Path C, §3.7) - because the recovery anchor must be the one thing that cannot die, and only the kernel is. With that anchor the registry service retires into it (Phase 4), init is removed (Phase 5), and the supervisor itself becomes restartable (Phase 6: the kernel respawns it and it recovers from the directory). The result is the theoretical-minimum liveness base - {kernel} alone (§6.3 reached at its floor), bought by softening §26.10 (a thin naming facility stays in the kernel): a deliberate, documented, non-extensible trade of last-scrap minimalism for the availability of everything above the kernel. The change is large and load-bearing, so it ships incrementally, always-bootable, suite green at every step - never a big-bang on the boot path.

Pipes - composing built-ins and services

Status: Implemented (osdev test files); two-stage pipes hardware-verified on the T630, multi-stage QEMU-verified. Realises Appendix D.3 (capability-mediated pipes). Trails CLAUDE.md; does not amend it.

A | B | C | … feeds each stage's output into the next. Unlike a POSIX pipe (an inherited file descriptor across a fork), a GodspeedOS pipe is capability-mediated: the shell brokers a real endpoint cap (or an in-process capture) between stages. There is no shared buffer and no ambient authority - data always crosses a real boundary (§26.4 forbids a silent shell-internal data path masquerading as a pipe).

A pipe now carries text or records. The dispatcher threads a Stream that is either a byte buffer or a typed Table; from json / to json flip between them, and each stage is chosen by its command and by which it currently holds. This page describes the text world (producers, filters, sinks, services); the record world (where/select/sort, from/to) is in docs/records.md. They are one pipeline.

The stage model

A pipeline is one producer, zero or more filters, one sink:

 stage 1        middle stages         last stage
 PRODUCER  |    FILTER | FILTER  |     SINK
  • PRODUCER - emits text (or records), ignores input. Text built-ins (is_producer_builtin): read, echo, tree, input, and the system-info commands about / version / whatis / mem / cores / date / net / ping / sock / help / pwd, plus random, tcp, churn verify (churn's other verbs refuse), and the report verbs of wifi, audio and hardware (their action verbs refuse). Record built-ins (is_record_producer): status, dir, caps, drives, find, uptime, events, trace, jobs, ask, the report verbs of godspeed (its action verbs refuse), and observe now. Services: greet (text), roster (records). (There is no cat: read is the one file reader - utilities/18_read.md, the replacement for POSIX cat, whose name describes a different operation. This OS does not carry POSIX vocabulary for its own sake.)
  • FILTER - consumes input, emits output. Service: upper. Built-ins: match/count/sort/ first/last (text) and the record verbs where/select/sort/from/to (docs/records.md).
  • SINK - consumes the final buffer. Built-in: write [append|prepend] <file> (plain overwrites; the keywords add to the end / front - see The write sink below), assert (the verifying sink) and paginate. A service filter used as the last stage prints to the console; with no recognised sink, the buffer is printed.

The shell threads a bounded buffer down the chain: stage 1 fills it, each filter transforms it, the sink consumes it. Each inter-stage buffer is 16 KiB (CAP_MAX in services/shell/src/main.rs; loud on overflow, §26.6); it lives on the user stack - two coexist for a middle filter (input + output ≈ 32 KiB), within the 256 KiB user stack.

Examples:

echo hello | upper                  builtin producer → service filter → console
tree / | write /snap.txt            builtin producer → write sink
read /log | upper | write /up.txt   producer → filter → sink (3 stages)
greet | upper | write /g.txt        service producer → service filter → sink
about | write /about.txt            capture any text producer's output to a file

What can start a pipe - the producer rule

The governing idea is simple: anything that displays information can be saved. A command is a pipe source iff its job is to emit data. That splits the command set three ways:

  • Data / display commands → pipe sources. Anything whose purpose is to show you something: about, version, whatis, mem, cores, date, net, ping, sock, help, pwd, random, tcp, churn verify, the reports of wifi / audio / hardware / godspeed, status, dir, caps, drives, find, uptime, events, trace, jobs, ask, tree, read, echo, input, observe now. Each renders through an Out target that is the console when run bare and a capture buffer when piped - so about prints, and about | write /f saves, the same bytes. No new authority: a built-in already held these capabilities; the pipe just redirects its text.
  • Action / mutation commands → NOT sources. spawn, kill, restart, reboot, mkdir, copy, move, rename, delete, cd, write, edit, drives flash. These do something; their one-line "ok"/error is an acknowledgement, not data. And that outcome already has its own channel - the Result model (result, assert ok <cmd>). Piping delete /x | write log to capture the word "ok" would blur the data channel and the outcome channel, which the shell keeps deliberately apart. A non-producer in stage 1 is refused loudly ("… cannot start a pipe").
  • Live / interactive → NOT sources. The full-screen observe live view and edit own the screen and never yield a discrete stream; piping them is a loud refusal (use observe now).
  • Orchestrators (run / selfcheck) → NOT sources. They run the suite's own sub-pipelines, so capturing one through a pipe would nest a pipe_run (which holds a 16 KiB Stream on the stack) inside another - two coexisting 16 KiB buffers overflow the tight user stack (HW-proven). They refuse loudly as non-producers. To save an orchestrator's output, it writes its OWN file: selfcheck save <path> / run <script> save <path> streams the report straight to a file (direct, no pipe - a small bounded buffer, not a nesting capture), then read <path> | … brings it into the pipe world (read is a leaf producer). That is the right shape anyway: a report is a durable artifact (keep it, re-read it, grep it, edit it), and a file decouples producing it from consuming it - a pipe couples them in time and is for transient streams. (To build a big file for edit's windowing instead, append a simple producer a few times: help | write /big.txt then help | write append /big.txt ×N.)

Materialize, then pipe - the standing escape hatch

When something can't be a direct pipe source - an orchestrator, output too large for the capture buffer, anything that won't fit the transient model - don't reach for an in-memory mechanism. Stage it through a file: materialize it (the utility writes its own file), then pipe from the file.

This is the move to reach for before building machinery (a streaming sink, a buffer pool, a heap). It needs nothing new: a utility that can write a file, and read (a leaf producer) that pipes one. The file is the adapter between two things that couldn't meet directly - the cheapest, most universal one in the system: a named, bounded, durable artifact.

selfcheck save /report.txt        # step 1: produce → file (direct, no pipe)
read /report.txt | match FAIL     # step 2: file → pipe (read is a leaf producer)

It is usually the better shape, not a compromise. A pipe couples producer and consumer in time (shared transient buffer, gone once consumed). A file turns that into a checkpoint: produce once, consume later, repeatedly, however you like - re-runnable, inspectable, resumable. This is the Unix "everything is a file" instinct (and Plan 9's), and it's how systems scale past memory: sort spills to a tempfile, MapReduce writes map output to disk before the shuffle, build systems checkpoint intermediate artifacts. "Materialize between stages" is the pattern that survives when the data won't fit in RAM and when you want fault tolerance (a disk checkpoint is restartable).

Honest caveat: staging costs a disk round-trip and the artifact's space, and it is not concurrent (step 1 finishes before step 2 starts). For a bounded, durable result that is the right trade every time. Only a truly unbounded flow through filters justifies the real concurrent streaming pipeline - until then, materialize, then pipe. (Same shelf as "resist the heap", CLAUDE.md §26.6.1: reach for a bounded, durable, visible mechanism before an in-memory clever one.)

The write sink - overwrite by default, append/prepend explicit

write is the file sink, identical in the pipe (… | write <path>) and standalone (write <path> <text>) forms:

FormEffect
write <path>Overwrite (or create). The default - the common case is unqualified.
write append <path>Add to the end (create if missing).
write prepend <path>Add to the front (create if missing).

The destructive-vs-additive choice is a visible keyword, not a punctuation subtlety - you can read the line and know exactly what it does. Append/prepend stream through a temp file (fs_stream_combine): the original is read while the combined content is written to a staging file that then atomically replaces the target, so they work on files of any size with constant memory. prepend is honestly a full-file rewrite (there is no insert-at-front in the filesystem), so it costs the same as rewriting the file - stated, not hidden (§26.7).

Why there is no > redirection

A POSIX reflex says "where's >?". The answer is that > would be redundant here, not ergonomic. In POSIX, > exists because redirection (an fd dup) is a different primitive from a pipe (fork + pipe). In GodspeedOS both are the same mechanism - a capability to a sink - so | write is "redirection as capability minting" (Appendix D.2). Adding > would be a second syntax for one mechanism, exactly the kind of speculative convenience surface §26.2 / §26.5 tell us to resist - and it drags its baggage in (>> vs >, clobber/noclobber, 2>), plus parser special-casing for a literal > in echo text. The verb form is self-documenting and keeps the authority (the fs write-cap) visible at the word write, where it is exercised (Appendix D.4).

Type mismatches are loud (text vs records)

A pipe carries text or records, and a stage that gets the wrong kind fails loudly and aborts the pipeline (§3.12) - it never silently passes garbage through:

about | to json   → "to: input is text, not records (parse with 'from json' first)"
dir | to xml        → "to: unknown format (try: to json | to yaml)"
status | match x   → "match: this is a record stream - use 'where'/'select'/'sort', or 'to json'"

The new text producers (about/mem/…) emit a byte stream, so feeding one into a record verb (to json, where, …) trips this guard automatically - no special-casing needed.

How a built-in stage works

A producer built-in renders through an Out target that is either the console or a capture buffer; in a pipe it captures. The write sink writes the buffer to a file. No fs surface is added - built-ins already had these capabilities; the pipe just redirects their text.

How a service stage works (the round-trip)

A service stage is wired with no new syscall:

  1. The shell asks the supervisor to spawn the service with "shell" as its peer (spawn_via_supervisor(svc, 0xFFFF, peers) in drain_stage, with &["shell"] as the peers drain_service passes). The delegated SEND cap to the shell's own endpoint is installed first (gs::ipc::peer_at(ctx, 0)) - that is the service's "downstream". The service's contracted peers (e.g. fs) follow, so a filter that must reach them to receive input still can.
  2. If the stage has input (a filter/sink, not stage 1), the shell resolves the service's endpoint by name via the kernel directory (the kernel records the name at spawn) and sends the input buffer as one message, then an EOT.
  3. The service processes and sends its output back to the shell (peer_at(ctx, 0)), ending with an EOT.
  4. The shell drains its endpoint until EOT, then reaps the service.

Exchanging whole buffers - one message each way - keeps the bounded IPC queues deadlock-free (§8.9): neither side ever has more than a couple of messages queued. The shell's single endpoint is reused sequentially down the chain (one service alive at a time). A message is 4 KiB, so a stage crossing a service boundary is capped at 4 KiB and refuses a larger buffer loudly (it is not chunked - see Streaming below).

The EOT end-of-stream marker

A service ends a stream with a one-byte EOT (0x04) message, so the shell's recv drain knows when to stop. A zero-length message is not used: a byte says what it means, and until 2026-10-08 (e3fcf7ed) the kernel did not deliver an empty body on x86, AArch64 or RISC-V. A filter (upper) forwards EOT downstream; the shell stops draining on it.

Bounds and failure (loud, never silent - §26.6 / §3.12)

  • Each inter-stage buffer is 16 KiB; overflow is reported, never silent: the capture keeps what fits and the shell prints pipe: producer output exceeded the pipe buffer (truncated).
  • A pipeline is capped at MAX_STAGES (8); more is refused.
  • Stage 1 must be a producer. A non-producer service in stage 1 would send nothing, and the shell would wait out its FILTER_WAIT_SECS deadline for output that never comes, so producer services are an explicit whitelist (is_pipe_producer_service, currently greet); anything else is refused loudly.
  • A producer built-in mid-pipe or as a sink (it ignores input), or a service that never registers when used as a filter, is reported, not silently mishandled.
  • A buffer larger than a service stage can take (4 KiB, one message) is refused loudly with the actual size and the reason - never silently clipped. (The write sink is not limited this way: it streams the captured buffer to a multi-block file via WriteNew/WriteAt, so it can save up to the full 16 KiB capture.)

Why store-and-forward, and the chain of real limits

This is store-and-forward, not a true stream: each stage runs to completion, its whole output is materialised into the 16 KiB buffer, and then the next stage runs. Stages run sequentially, one at a time - never concurrently. That is a deliberate v1 choice: it sidesteps the hardest part of real pipes - §8.9, where the kernel will not detect or break a deadlock, and a concurrent producer/consumer needs backpressure. Store-and-forward has exactly one service alive at a time and one bounded message each way, so it is provably deadlock-free.

The cost is that it is bounded in total data, not just in buffer size. The 16 KiB buffer is not the binding limit; the ceilings below are, and they are all the same "no streaming / no multi-block" limitation:

LimitValueSet by
IPC message4 KiBMAX_PAYLOAD (§8.5) - a stage through a service is one message
Capture buffer16 KiBthe inter-stage buffer; a builtin-only pipeline is bounded by this
Concurrencynonestages run sequentially; data is materialised, not flowing

(The \| write sink is no longer a ~3.5 KiB ceiling: it streams the buffer to a multi-block file via WriteNew/WriteAt. The remaining hard cap is the 4 KiB service-stage message.)

So a builtin-only capture can fill 16 KiB and the write sink takes all of it; what cannot take more than 4 KiB is a service stage. Lifting that, and the 16 KiB total, is the streaming work, not a constant.

Future: true streaming (design intent - not built)

A streaming pipeline would match the POSIX mental model - a | b | c running concurrently, bytes flowing through bounded queues, total data unbounded. It is a deliberate future project, not a tweak, and it is where §8.9's deadlock discipline stops being theoretical. The pieces:

  1. Concurrent stages. Each stage is a live task for the pipeline's duration (not spawned and reaped one at a time). The shell wires stage i's output cap to stage i+1's endpoint, so data flows producer→…→sink without the shell materialising any stage in full.
  2. Backpressure. With bounded per-endpoint queues (depth 16, §8.5), a fast producer must block on a full queue until the consumer drains it - send already blocks, so the queue is the backpressure, but every stage must then use the try_send/structured discipline §8.9 requires so a stall can't become a deadlock.
  3. Multi-block files - done. \| write already streams to a multi-block file (WriteNew/WriteAt); a large piped capture reaches the file. What remains is streaming it chunk-by-chunk rather than materialising the whole buffer first.
  4. A streaming filter contract. A filter service reads a chunk, emits a chunk, repeats - upper is already shaped this way (chunk-in → chunk-out); the change is the shell not draining it in full but forwarding each chunk onward.

The store-and-forward machinery here is the foundation: the producer/filter/sink roles, the EOT marker, and the whole-buffer round-trip generalise to chunked streaming without changing the shape - only who drains whom, and when.

Filter built-ins

A filter built-in consumes the previous stage's buffer and emits to the next - it runs in-process, so it is not subject to the 4 KiB service-boundary cap and can filter a full 16 KiB buffer. Built so far: match (grep - utilities/27_match.md), count (wc - utilities/28_count.md), sort (utilities/29_sort.md), and first/last (head/tail - utilities/30_first-last.md): read /log | match error | sort | last 20. Piping into command arguments is the remaining Appendix-D work; new filters drop into the same middle FILTER slot.

Structured records - typed pipes (PowerShell/nushell-style)

Status: Built + QEMU-verified (osdev test shell, osdev test files): the Table model, eleven shell-side record producers (wifi list the eleventh; wifi hardware, hardware's tables, ask and the reports of godspeed have joined since) plus the roster service, where / select / sort, to json / to yaml / the grid, from json, the binary wire codec, and the unified byte-or-record pipeline. What is NOT built is listed under What's built vs next. Non-normative - does not amend CLAUDE.md.

Why

POSIX pipes carry text. So dir flattens its structured data - name, size, type, date - to a formatted string, and then grep/awk/cut/sed exist to re-parse that string and claw the structure back out by counting columns. The whole awk/sed/cut zoo is compensation for a lossy serialize-to-text step.

PowerShell's insight was to not throw the structure away - pipes carry objects, and you filter on real fields (Where-Object { $_.Length -gt 1MB }). GodspeedOS does the same idea, but shaped by invariant 2 (no shared mutable memory): a live object cannot cross a service boundary by reference, so the structured value must be a thing that can also be serialized. The model here is therefore a typed value, with text/JSON as renderings of it - never the model itself. (Closest prior art: nushell - Rust, structured pipelines, table-by-default, to json.)

The three representations

  1. In-memory model - a Rust value, the Table (typed columns + rows). The "language between utilities", and it is reached through the standard library (gs::record) so any service can build, filter, and render records - not just the shell. Between same-address-space stages it is passed by value, never serialized.
  2. Wire codec - a compact, bounded binary encoding, used when a record crosses a service boundary as records. Built (Table::encode/decode in the SDK): magic + dims + column names + tagged cells, all bounded by the table's own limits, loud on a truncated/oversized buffer. It is emphatically not JSON - it is the Table itself on the wire. A record service (examples/roster) emits this; the shell decodes it straight into a Table with no from json. (JSON is still available for human/interop output via to json, and from json for parsing external JSON text.)
  3. Edge renderers - to json, to yaml, the default aligned grid (all built, in the SDK). These live at the edge: terminal output, or export for interop. A record never is JSON; JSON is one way to print it.

The model - a bounded Table

The canonical value is a table: static column names + rows of typed Value (Str interned in a byte arena, Int unsigned, Signed, Empty). Signed arrived on 2026-10-02 for wifi list's dbm, a reading that is always below zero: the magnitude would be a different number and a string sorts by bytes. It is its own variant rather than a change to Int, so every count and size kept its type; sort, where and the reducers compare the two kinds numerically, JSON writes it as a number, and the wire codec carries it as cell tag 3 (Empty 0, Int 1, Str 2). from json now reads a negative integer as Signed - it used to arrive as null. Most introspection output is naturally tabular (status, dir, find, caps are all uniform rows), so a table covers the realistic cases and is simpler than arbitrary records. It is bounded (§26.6): REC_MAX_COLS, REC_MAX_ROWS, a fixed REC_ARENA - all on the stack, no heap, loud on overflow. Heterogeneous (differently shaped) records are a future generalization.

The grammar (compact, not English)

Structured pipes don't want match (substring on a line) - they want field operations. The grammar is deliberately terse and code-like, not an English sentence:

  • Predicates are one token, operator attached: where mem>0, where state=BlockRecv, where name!=shell, where core>=1. The parser finds the operator inside the token (longest match - !=/>=/<= before =/>/<); before it is the column, after is the value. Ops: = != > < >= <= ~(contains). Numeric when both sides are numbers, else textual. No quotes needed unless the value has a space (then the usual minimal quoting: where "name=block driver"). Built.
  • Column ops are verb + columns: select name core (keep those columns, in order), sort mem / sort reverse name (order rows by a column - "by" dropped, same reason the spaces are; reverse leads, like match except, so the column lands at the end and nothing dangles). Built.
  • Conversions are short directional words - to <fmt> / from <fmt>. The direction is named because you need both: to renders the model → text, from parses text → model. A bare | json couldn't express "parse incoming json" without the word pointing two ways (the implicit magic §26.5 forbids). to json, to yaml and from json are built.

The text filters (match/count/sort/first/last) stay - for genuinely-text streams like a file's contents. A pipeline is routed to the record path when its first stage is a record producer (is_record_producer - status, dir, caps, drives, find, uptime, events, trace, jobs, ask, godspeed's reports, observe now - and wifi list / wifi hardware and the table forms of hardware, routed by name beside it in pipe_run), else the byte path. They coexist; the default rendering (no to) is the table grid. A text filter applied to a record stream (e.g. dir | match foo) is a loud, guided error - use where/select/sort <col>, or to json to drop back to text first.

A live, screen-owning loop cannot be a pipe stage. observe (bare) is the continuous full-screen view - it owns the console and never yields a discrete stream, so piping it would block the shell on a recv that never comes (the stage-1 hazard docs/pipes.md guards). Only its one-shot snapshot, observe now, is pipeable; bare observe | … is a loud refusal, not a hang. This is the general rule for any future live-loop utility: the snapshot form is the record producer, the live form is not.

status                                   the default table
status | where state=BlockRecv           only blocked tasks
status | where mem>0 | select name mem   filter, then keep two columns
status | sort reverse mem | to json       ordered desc, as JSON
status | where name=shell | to yaml      one task, as YAML

A service producing records - the roster example

examples/roster is the executable proof that records are not shell-only: it builds a Table with the SDK, encodes it with the binary wire codec, and emits the bytes through the shell-delegated pipe cap (zero ambient authority, like greet). The shell knows roster is a record service, so it decodes the stream straight into a Table - no from json:

seat is an illustrative Int column (a desk number, not a CPU core - roster is a fixed demo dataset, the same 4 rows on any machine; real core counts live in cores/status):

gsh> roster | sort reverse seat
name     role     seat
John     worker   4
Luke     courier  3
Mark     worker   2
Matthew  core     1
gsh> roster | where role=core | to json
[
  {"name": "Matthew", "role": "core", "seat": 1}
]
gsh> roster | select name seat
name     seat
Matthew  1
Mark     2
Luke     3
John     4

This is the whole point of the SDK record API: a third-party service emits typed data as records, and the verbs (where/select/sort/to) operate on it directly - no text, no re-parsing. (osdev test files pins it.)

from - the bridge from text into the typed world

read file.json gives text (bytes); where/select need records. So from json parses text → the table model: read svc.json | from json | where core=1. read deliberately does not auto-parse by extension - that would be hidden behavior (§26.5). from is the explicit bridge, and the symmetric partner of to:

read a.json | from json | to yaml | write a.yaml   convert json → yaml
read svc.json | from json | where mem>0            external data, filtered

This crosses the byte ↔ record boundary, and that unification is built: one pipeline threads a Stream that is either Bytes (a text buffer) or a Table (records), and each stage is dispatched by its command and by which it currently holds - so sort is a line-sort on Bytes and a column-sort on a Table, and from/to flip between the two. A mismatch (e.g. where on bytes, match on a table) is a loud error pointing at the fix. from json parses a flat [ {…}, … ] array (string/number/true|false/null, no nesting; the first object defines the columns). ini could plug into the same to/from pair later, but it suits a single record or key-value view, not a many-row table - json/yaml are the table-native pair.

What's built vs next

  • Built: the Table model (owned column names + arena); to_grid (default, full string cells - no clipping), to_json, to_yaml; the compact where, select, sort [reverse] <col>; the shell-side record producers - status (task roster), dir (name/type/size/sealed), caps (resource/rights), drives (index/label/status/size_mib/free_mib), find (name/type/path), uptime, events, trace, jobs, wifi list (network/band/signal/dbm/security/note), and observe now (the roster + a ticks cumulative-cpu-time column - the native "top", observe now | sort reverse ticks); from json (text → records); and the unified byte↔record pipeline (Stream = Bytes | Table, dispatched by command + data type, from/to bridging). All in-process (no wire codec), QEMU-verified incl. a json → records → yaml → file round-trip and dir | where type=file | sort reverse size.
  • Also built: the SDK record API - the Table model, where/select/sort, to_json/to_yaml/to_grid (over a RecordSink), from_json, and the binary wire codec (encode/decode) now live in gs::record, so any service can produce records, render them, or put them on the wire as records (sdk/rust/CLAUDE.md). examples/roster is a record service: it encodes a Table, the shell decodes it - roster | where role=core, no from json.
  • Next: a JSON string-escaper (values are plain ASCII today); from yaml; record streams larger than one IPC message (chunked encode + the streaming-pipe work); eventually heterogeneous records and richer interop.

Discipline (so it doesn't rot into PowerShell-magic)

Keep the typing and serialization explicit (§26.5): no implicit property coercion, no automatic ToString(). Keep everything bounded (§26.6): fixed cols/rows/arena, loud on overflow. This is a subsystem, pulled into existence one producer at a time (§26.2), not a speculative framework. The payoff is deleting a whole category of cryptic tools: keep the data typed and most of grep/awk/sed/cut never needs to be born - replaced by where/select/sort, which read on sight.

Design Note: Console Service - separating logs from the interactive console

Status: BUILT and shipped. services/console/ is the terminal; 1,172 lines of terminal emulation left the kernel for it, and what ring 0 kept is the boot/panic blit in kernel/src/bootcon (CLAUDE.md 11.4, amended 2026-08-17). Hardware-verified on the Pi 2: chaos 50 rounds, 0 kernel panics, selfcheck 350/0.

(This line said "DESIGN (draft for discussion). Not yet implemented." long after it shipped. The design below is unedited - it is why the console looks the way it does - but the header claimed the opposite of the truth, which is worse than being thin.) Date: 2026-06-05 Pins: §26.10 (kernel = mechanism, not policy), Appendix B.3 (shell = capability-broker holding a console cap), Appendix C.1 / live observe.


1. Problem

There is one console stream, and everything dumps to it. The kernel's kprintln, every service's ctx.log/ctx.print, the xhci driver's progress, and the shell's prompt all write the same append-only stream that is mirrored to both the serial port and the framebuffer (TV). So the interactive prompt fights with asynchronous log output: gsh> and xhci: keyboard ready race for the bottom of the screen, observe now output interleaves with whatever else is logging, and there is no way for a full-screen view to own the display.

This blocks two things:

  • A clean, stable interactive prompt on the TV.
  • Live observe - a full-screen view needs to clear+home and repaint in place, read q to quit, and own the screen so log lines don't smear its frame. Impossible while logs share the surface.

The interim fixes already in main (the boot-flush yield, the observe now park-wait, the inline prompt) are workarounds for not having this separation.


2. Current state (what exists today)

(Note 2026-10-09: "today" is 2026-06-05. The output diagram below shows the in-kernel fbcon that §9 removed, so fb::put_byte is not the path any more and the TV is drawn by the console service. §9.2 has the split as built.)

Output - all one path:

kprintln! / ctx.log (syscall 5) / ctx.print (syscall 22)
    → kernel log::write_fmt
        → serial_write_byte  (COM1)           ─┐ mirrored, one stream
        → fb::put_byte       (framebuffer/TV)  ─┘ to both surfaces

Input - kernel-owned ring:

USB keyboard → xhci driver → gs::driver::input::push (syscall 20)
    → console_push_byte → console input ring (+ echo to the console) + wake
shell → ConsoleRead (syscall 17) → reads the ring

events holds the bounded IPC trace ring (474 lines, utilities/47_events.md)

  • it was a stub that logged "ready" and parked when this was written. What has NOT changed is the half that matters here: all logging still short-circuits through the kernel ring buffer to serial and the framebuffer, and no log line goes to the events service. That is deliberate, not an omission - logging must not depend on a service that can be killed (CLAUDE.md 11.4).

3. The split

The fix is to stop having one stream. There are two distinct kinds of output, and they want different destinations:

KindExamplesWants to go to
Log (diagnostic)kernel kprintln, xhci: progress, spawn[...], cap-testthe log stream (serial + a queryable buffer)
Console (interactive)the shell prompt, observe output, command resultsthe interactive console (the TV surface)

And the interactive console is owned by a console service the shell brokers (Appendix B.3) - it holds the keyboard + display and gives the shell a clean surface, separate from the log firehose.

Division of labour (per §26.10 - kernel is mechanism, console service is policy):

  • Kernel keeps the mechanism: rendering a glyph to the framebuffer, the serial UART, the keyboard input ring. It does not decide layout.
  • Console service owns the policy: the terminal model - where the prompt sits, what scrolls, the cursor, foreground ownership for a full-screen app. It drives the display through a kernel console-output capability and reads the keyboard through a console-input capability; the shell holds a cap to it.

The two physical surfaces fall out naturally:

  • Serial = the log/debug stream. Unchanged for debugging (TeraTerm shows the verbose logs). Kernel + service logs go here.
  • Framebuffer/TV = the interactive console, owned by the console service. The kernel stops mirroring log output to the framebuffer; the console service owns what the TV shows.

4. Proposed staging

A full console service is a genuine subsystem (Appendix D calls it far-future). Stage it so each step is useful on its own.

Stage 1 - separate the streams (clean TV, no new service yet)

  • Split output into log vs console at the API:
    • ctx.log / kprintln → log stream → serial (+ the kernel ring buffer, later drained by the events). No longer mirrored to the framebuffer.
    • a console output path → the framebuffer (the interactive surface).
  • The shell's prompt/results and observe's frames use the console path; all the xhci:/spawn[...]/kernel diagnostics use the log path.
  • Result: the TV shows a clean interactive session; serial keeps the logs. The boot chatter stops smearing the prompt on the TV without silencing it on serial. This alone fixes the felt problem and needs no new service - just a routing split in the kernel console layer.

Stage 2 - the console service (userspace) + terminal model

  • A userspace console service owns the interactive surface: a scrolling output region plus a fixed input line redrawn after any output, the cursor, and a foreground-app API (take the screen, clear+home, stream keys, release).
  • The shell brokers it (holds a cap); ctx.log-style console output from the shell goes through the console service.
  • Unlocks live observe: it asks the console service for the foreground, repaints each tick, reads q, and releases - with no log lines smearing it.

Stage 3 - events becomes real (optional, parallel)

  • Route ctx.log to the events service (today a stub) so logs have a real home (a queryable buffer, osdev logs <svc>, later a file via fs). Independent of Stages 1-2; makes the log stream first-class.

5. Key decisions - RESOLVED (2026-06-05)

All settled per the recommendations below: (1) land Stage 1 first as its own mergeable win, then Stage 2; (2) console output reaches the framebuffer via a kernel render path (the kernel keeps glyph rendering = mechanism); (3) cursor control via a minimal ANSI subset (one escape stream works on the TV and a serial terminal); (4) the console service owns the keyboard (Stage 2); (5) log-vs-console routing is by API (ctx.log = log → serial; new ctx.console_* = console → serial + framebuffer).

Stage 1 mechanism (this branch, first)

  • serial_write_byte / serial_write_bytes_lockfree → COM1 only (drop the framebuffer mirror). This makes all existing logs - kprintln and every service's ctx.log - serial-only automatically; the TV goes quiet during boot.
  • New console_write_* (kernel) → COM1 and the framebuffer. The keyboard echo uses this so typed input still shows on the TV.
  • New ConsoleWrite syscall (23) + ctx.console_write/console_writeln - the interactive path. Gated by LOG_WRITE for now (Stage 2 introduces a dedicated console cap held only by the console service).
  • The shell (prompt + command output) and observe (its frame) switch their user-facing output to ctx.console_*. Everything else keeps ctx.log → serial.
  • Result: TV shows a clean interactive session (shell, echo, observe); serial keeps the full logs and the session, so debugging/capture is unaffected.

5a. Stage 2 direction - REVISED (2026-06-05)

Stage 1 changed the premise. The console service's original headline job - keeping the input line from being smeared by async log output - is already solved: Stage 1 stopped mirroring logs to the framebuffer, so the TV only ever shows console output. The only remaining job a console service was for is foreground input arbitration for a full-screen live view (live observe reading q).

A confirmed kernel constraint then settled the design: there is one console ring and one CONSOLE_READ_WAITER (arch/x86_64/mod.rs) - exactly one task reads the keyboard at a time, and there is no select(). A separate always-on console service that owns the keyboard would block in ConsoleRead and could not also field out-of-band "take/release the screen" messages without either busy-polling (throwing away the idle-halt work) or reworking the hardware-verified USB keystroke path into IPC messages. The kernel's single-waiter slot is the foreground-input ownership primitive already.

Decision: build the live-view seam now, shell-brokered, not as a separate service. The reusable foundation is the utility-facing contract - "become the console reader, paint via console_write + ANSI, poll q, release on exit" - which is identical whether the shell or a future console service brokers it. The shell is already the Appendix B.3 capability-broker and fits the kernel's waiter model. A dedicated console service is deferred until a real multi-consumer need pulls it into existence (a real events consuming the log stream, multiple terminals, multiple foreground apps) - at which point it takes over brokering with zero changes to the utilities (§26.2: features pulled into existence; nothing built speculatively, nothing wasted).

This supersedes decision (4) below ("console service owns input"): input stays shell-brokered for now. Decisions (1) Stage 1 first, (2) kernel render path, (3) ANSI subset, and (5) routing by API stand.

Stage 2 as built:

  • 2a (mechanism): minimal ANSI subset in the fbcon - clear, cursor position, erase line, hide/show cursor - plus InspectKernel query 9 for screen geometry.
  • 2c (live observe): a non-blocking TryConsoleRead (24) and a ConsoleEcho (25) echo toggle, both gated by CONSOLE_READ; observe gains a MODE_LIVE_FG that hides the cursor, suppresses echo, repaints in place (home + ESC[K per line, no full-clear flicker) every ~0.5 s, and polls q; the shell spawns observe-live, stops reading the keyboard while it runs, and resumes when it parks (the foreground handoff). The first client of the seam validates it.
  • (There is no "2b separate console service" - folded away by the decision above.)

5b. Original options (for reference)

  1. Scope for this branch. Stage 1 only (separate streams → clean TV, small, high-impact), or push through Stage 2 (the console service + live observe) in the same branch? Recommendation: land Stage 1 first as its own mergeable win, then Stage 2.

  2. How does "console" output reach the framebuffer? The kernel owns the framebuffer (arch layer), so a userspace service can't write it directly. Options:

    • (a) Kernel render API / console-output cap - the console path is a syscall the kernel renders to the framebuffer (kernel keeps glyph rendering = mechanism; the service controls layout). Recommended - matches §26.10.
    • (b) Map the framebuffer to the console service (like the xhci BAR) - the service renders glyphs itself. More control, but duplicates the font renderer and is serial-blind.
  3. Cursor control: ANSI escapes vs positioned-write syscalls. For the console service to manage a terminal (clear, home, move cursor), either the kernel fbcon interprets a minimal ANSI subset (and a serial terminal understands the same escapes for free), or the kernel exposes positioned-write primitives (explicit, but serial-blind). Lean: ANSI subset - one escape stream works on both the TV and a serial terminal.

  4. Keyboard ownership. Does the console service own keyboard input (the shell asks it for lines), or does the shell keep reading ConsoleRead directly and use the console service only for output? Lean: console service owns input too, so it can do line editing and route keys to a foreground app (observe's q).

  5. Log routing granularity. Stage 1 needs to mark output as log-vs-console. By API (ctx.log = log, a new ctx.console_* = console - simple, explicit) or by a level/tag? Lean: by API.


6. Out of scope (far-future, not this work)

Multiple virtual terminals, a real VT100/xterm emulator, copy/paste, resize, colour themes beyond the current green-on-black. The goal here is a clean, stable interactive console with foreground-app support, not a terminal emulator.

Amendment 2026-09-18: scrollback is IN, and it is the one item on this list that was not a nicety. This section listed "scrollback paging" alongside copy/paste and colour themes, as though the three were the same kind of want. They are not. Without scrollback a framebuffer console loses output permanently: reaching the bottom of the screen scrolls the top away and there is nowhere for it to have gone. That is not a missing convenience, it is the display discarding information the operator asked for - and it is the stated reason help and trace each grew a pager of their own - and help's has since been deleted because this replaced it (§10).

The rest of the list stands.


7. First step once a direction is agreed

Stage 1: in the kernel console layer, split the framebuffer mirror off the log path and give the shell/observe a console-output path to the framebuffer; verify the TV shows a clean session while serial keeps the full logs (shell-test + a framebuffer screendump). Then Stage 2.


8. Input under a starved timer ISR - drain-on-read (2026-07-09)

Built 2026-07-09. Not a design question - a robustness fix to the input mechanism of §2, surfaced by the same T630 storm that produced docs/persistence.md §6.16 (a rapid double kill all-services). No new syscall, no format change, no amendment.

The keyboard/serial input path of §2 depends on the timer ISR to poll the UART RX line and push bytes into the console ring. Under a storm - the timer starved by long IF=0 console writes and IPI/shootdown pressure - that ISR fires late or not at all, so typed bytes pile up in the UART's 16-byte RX FIFO and never reach the ring. The console looks dead even though the hardware already holds the input.

Fix: drain on read, not only on tick. The console read syscalls (ConsoleRead / TryConsoleRead) now call uart_rx_drain_now before consulting the ring: with interrupts disabled (cli), drain the UART RX FIFO into the console ring, then restore the prior IF. A task blocked in ConsoleRead therefore pulls its own input straight off the hardware the instant it asks for it, independent of whether the timer ISR is keeping up. The tick-driven poll stays (it is what wakes a blocked reader), but correctness no longer hinges on it. This is the input-side echo of the storage stack's Commandment VIII fix (§6.16): don't wait on a proxy (the tick) for a truth (a keystroke) you can observe directly. The drain is bounded (16-byte FIFO into a fixed ring) and IF-guarded (it never re-enters and never leaves interrupts unexpectedly enabled); no new unsafe beyond the existing arch UART accessors.

Companion: lazy, bounded shell history. The shell persists its command history to /users/default/history (the §15 monotonic-counter pattern). It used to read that file back at startup - an fs round-trip fired during the shell's own re-init, which is exactly when the storage stack may be mid-recovery (a contributing masked race in the §6.16 wedge). The read is now lazy: the shell loads /users/default/history on the first up-arrow, not at spawn. A fresh prompt never blocks on fs coming up, and the shell's re-init path touches no disk. In-memory history stays bounded (a fixed ring); saving remains best-effort (§15). Two small changes, one theme: the interactive path must stay responsive precisely when the rest of the system is busy recovering.


9. Stage 4 - the console service is real, and fbcon leaves the kernel (2026-08-17)

Status: BUILT and HARDWARE-VERIFIED on feat/pi2-arm32-hardening (Pi 2, 2026-08-17: chaos 50 rounds, 0 kernel panics, 0 liveness wedges, selfcheck 350/0, 0 console writes lost). This section supersedes decision (2)(a) of §5 ("kernel render path") and decision (4) of §5a ("input stays shell-brokered" - still true; only OUTPUT moves). Driven by scripts/commandments.py, for which kernel/src/fbcon was the last standing Commandment I violation: 1,172 lines of terminal emulation that can claim none of the kernel's six responsibilities (§4.3) and no sanctioned support role.

9.1 Why (2)(a) was wrong

§5 chose "the kernel keeps glyph rendering = mechanism, the service controls layout", reasoning from §26.10. That reading does not survive contact with §4.4: a font rasteriser, an ANSI/CSI state machine, a UTF-8 decoder, a shadow grid and a scroll strategy are a display driver, and §4.4 forbids drivers in the kernel by name. The measured boundary (the commandment walk, 2026-08-14; its surviving findings are now in audits/userspace-audit.md Audit 11) also showed there is no cheap partial slice - the emulator is reached THROUGH put_byte, so removing CSI handling alone leaves the shell's cursor moves and colours going nowhere. The service must take rendering entirely.

The recorded user position settled it: "fbcon is not special, only the kernel is special; I really don't want to continue adding exceptions to the kernel." So option (b) of §5b - map the framebuffer to the service, as the xHCI BAR is mapped to xhci - is what gets built. Its stated drawback ("duplicates the font renderer and is serial-blind") is answered below.

9.2 The split

Kernel (bootcon/, 539 lines)console service (1,785 lines)
Serialowns it, unchanged - still the source of truthnever touches it
Framebuffera minimal boot/panic blitthe whole terminal
Text modelnone (no grid, no cursor, no scrollback)ANSI/CSI, UTF-8, shadow grid, cursor, scroll, reverse video, scrollback (§10)
Geometryprivate to its own blit, never publishedthe single source of truth for rows/cols

Not serial-blind. ConsoleWrite (syscall 23) still writes serial synchronously, exactly as today, so a captured build/serial_output.log is unchanged and the interactive session still appears in it. What the syscall stops doing is rendering; it enqueues the same bytes to the service's ordinary IPC endpoint (no new ring - the bounded queue every service already has). Serial is truth, the display is a mirror - which is already the stated ARM policy (mirror()).

A full queue blocks the WRITER. The writing task is parked as a blocked sender until the terminal drains (§8.5, §8.6) - the kernel itself never waits. This is a correction to the first draft of §9.2, which dropped instead: see §9.8, item 4, for why dropping could not work at any rendering speed.

Not a duplicated font renderer. The kernel's blit is deliberately cruder than the terminal's: no box glyphs, no reverse video, no shadow grid, and a "scroll" that clears and starts at the top. It is a floor, not a small terminal, and the two are not two copies of one thing.

9.3 What earns the kernel's remaining blit

Impossibility, which is the only thing that does (the commandment walk: the bar the control channel failed and the supervisor spawn clears). A panic halts every core, including the console service, so a panic cannot ask a service to report it. On a Pi wired to a TV with no serial cable, a kernel with no blit dies with a frozen screen and no reason on it - the silent failure invariant 12 exists to forbid. Boot output has the same shape: it precedes every service, including the one that would render it.

Both are the §11.4 ring-buffer argument applied to a machine with no serial port, so bootcon claims the same kernel-log-floor role and nothing wider. §11.4 is amended to say so, because it currently reads "serial console", which is x86-shaped - on a PC serial is always there.

9.4 Ownership of the framebuffer is explicit and one-way

Two writers to one framebuffer is not a race the service can defend against: its shadow grid would be silently wrong about what is on screen. So ownership is a state, not a convention:

kernel owns it  --(framebuffer GRANTED at spawn: bootcon::release)-->  service owns it
       ^                                                                       |
       +------ bootcon::reclaim_on_death (the service died) --------------------+
       +------ bootcon::reclaim_for_panic -------------------------------------+

(Corrected 2026-10-09: this diagram and the sentence under it said the service calls release once it has mapped and cleared. The kernel calls it, from the spawn path (task/mod.rs), when it grants the framebuffer - §9.8 item 3 records why the grant became the handover - and takes the screen back on the service's death as well as on a panic.) The panic path reclaims unconditionally and clears, because by then the service is halted and its grid describes nothing.

9.5 Memory attributes - the constraint that shapes the mapping

The service maps the same physical pages the kernel mapped. ARM leaves mismatched memory attributes for one physical page UNPREDICTABLE, so both mappings must agree. They agree on Normal non-cacheable:

  • The service cannot do cache maintenance - unsafe is forbidden in services (§18.2) and DCCMVAC is PL1-only on ARMv7 anyway. So a cacheable mapping is not available to it at any price.
  • Therefore the kernel's mapping becomes non-cacheable too (section_fb), and fb_commit (a clean-to-PoC on ARM, an sfence on x86) collapses on ARM to a store barrier - a DSB, ordering only, nothing to clean - while KEEPING its name and its rectangle argument, because an arch that maps its framebuffer cacheable still needs both. (No arch does today. That parenthetical used to name the Pi 4; see 9.5.1 - it now maps Normal non-cacheable like the others.) FB_READBACK_CHEAP disappears with it: reading back a non-cacheable framebuffer is never cheap, so the terminal always repaints from its shadow grid and scroll_by_copy is deleted.

Non-cacheable here means Normal non-cacheable, not Device. The distinction matters and the neutral PageFlags::PCD currently encodes the wrong one on ARM: Device semantics (no gathering, no reordering, no speculation) exist to protect stores with side effects, and a framebuffer store has none

  • it is memory the display happens to scan. Non-gathering would make every 32-bit pixel store its own bus transaction, roughly 1.4M of them per full-screen repaint on a Pi 2. So the ARM page-table encoder gains a Normal-non-cacheable case and mmu::section_fb matches it; PCD keeps meaning Device for the driver MMIO grants that genuinely need it.

Cost: the kernel's boot text is slower to paint. It is boot text, and the terminal's own scroll was already repaint-from-shadow on x86 for the same reason.

9.5.1 AArch64 had the SAME mistake as ARM, and it cost 582 ms a repaint (2026-08-29)

The paragraph above predicted this exactly - "non-gathering would make every 32-bit pixel store its own bus transaction" - and then it happened on the OTHER ARM port. AArch64's map collapsed PCD || PWT to Device, so the Pi 4's framebuffer grant was Device-nGnRnE. One 1920x1080 repaint measured 582 ms (~14 MB/s), slow enough that selfcheck never reached its end on that board - which for days looked like a filesystem or scheduling fault rather than a display one.

Fixed by letting the neutral layer say what the region IS, instead of inferring it from cache bits: PageFlags::WRITE_COMBINE means "a framebuffer, not device registers", and each arch picks its own type

  • AArch64 Normal non-cacheable via MAIR slot 2, arm32 the encoding it already had, and an arch with nothing better ignores the bit (so x86 is unchanged). After: no paint took warning on any machine, and selfcheck completes on the Pi 4.

A second bug hid behind the same proxy. The kill-path reclaim skips device MMIO so it is never freed into the RAM pool, and it identified "device MMIO" as PCD && PWT. The x86 framebuffer is mapped PCD-only deliberately (strong-UC cost 596 ms per scroll on the Wyse; UC- lets the firmware's write-combining MTRR apply), so that guard silently stopped covering it - and every console death freed the framebuffer into the allocator. Measured after chaos max-carnage: 315392 double-frees, all inside 0x90000000..0x91f97000 (exactly 3840x2160x4), and free_frames 6666 above total_frames. Worse than the wrong number: the display's pages became allocatable.

The guard is now PCD alone, AND the framebuffer range is reserved in the allocator (memory::allocator::reserve_no_free) - because a check that reads a proxy can be disarmed by someone optimising something else, who has no reason to know a safety check depends on how they map a page. The resource itself is where the refusal belongs.

The same mistake existed on x86, in the other direction (2026-08-25)

The paragraphs above reason carefully about the ARM encoding and say nothing about x86, where the framebuffer grant was mapped PCD | PWT. On x86 that is PAT entry 3 - strong uncacheable, the one memory type an MTRR can never upgrade - so every 4-byte pixel store went to the bus alone. Measured on a Wyse 5070 at 3840x2160: 596 ms to repaint one scroll, about 19 MB at roughly 32 MB/s. The display was unusable and it read as "the console is slow" rather than as a mapping bug.

PCD alone selects entry 2, UC-, which yields write-combining wherever the range's MTRR says WC - and firmware routinely marks a framebuffer WC for exactly this reason. One flag: 596 ms -> 29-41 ms, about 650 MB/s. Strictly no worse where the MTRR says UC, because the effective type is then unchanged.

The two ports need OPPOSITE bits for the same intent, which is why this is worth stating in one place:

wants gatheringencoding
x86PCD alone (UC-, upgradable to WC by MTRR)PCD | PWT is strong UC and is NOT
arm32PCD | PWT (Normal non-cacheable)PCD alone is Device and forbids gathering

So the x86 fix is #[cfg(target_arch = "x86_64")]-gated. Applying it everywhere would have slowed the Pi for precisely the reason it sped up the PC, and worse, mismatched mmu::section_fb's view of the same physical pages - which on ARM is unpredictable rather than merely slow. Borrow the silicon's requirement, not the other port's answer (§26.14).

9.6 Surfaces added and removed

Addeda framebuffer grant in the spawn path; one service_config row (since step C, a row in the supervisor's spawn table, services/supervisor/src/main.rs; the grant is by the FRAMEBUFFER device kind, CLAUDE.md 12.3). No new syscall - see the correction below
RemovedInspectKernel query 9 (dims_packed) - the shell asked the KERNEL for terminal geometry, which is a service's question; FB_READBACK_CHEAP from the arch contract, halving the framebuffer surface an arch owes to fb_commit alone; ~840 lines of ring-0 code

The introspection pin shrinks by one, and the syscall pin does NOT grow. That is better than the trade the audit predicted: terminal geometry left the kernel and nothing was added to pay for it.

Corrected 2026-09-26. This table said the split ADDED a ConsoleDrain syscall and a CONSOLE_RENDER authority gating it, and the sentence above said "the syscall pin grows by one". Neither exists - not in kernel/, not in services/, not in sdk/, and SyscallNumber has no such entry. What shipped is what §9.2 of this document describes: ConsoleWrite enqueues the same bytes to the service's ordinary IPC endpoint, with no new ring and no new syscall. The wrong half mattered more than it looks: someone auditing the kernel's syscall surface against Commandment I would have gone looking for an addition that was never made.

9.7 Geometry has ONE owner

ctx.console_dims() no longer reads query 9. The shell asks the console service, which is where the safe-area inset, the cell size and the font-scale rule live. The kernel's bootcon computes rows/cols for its own blit and never publishes them - a private working value is not a second source of truth, but a published one would be (Commandment III). A shell that cannot reach the console service reports the console unavailable rather than guessing a size.

9.8 What the hardware found that review did not

Five defects, none of which a checker or a build could have caught, listed because each is a different KIND of miss:

  1. A placeholder ELF. arm_build.py kept its own copy of the ARM service list, so the console shipped as a stub and failed to spawn. The second list is now DERIVED from kernel/build.rs, not reconciled against it - the third time that shape has bitten this repo.
  2. The wrong green. grant() reconstructed the framebuffer's channel shifts from blend_lut[255] rather than storing them, and the foreground (0x80, 0xFF, 0x80) has red equal to blue - so blue always resolved to red's shift. Ambiguous by construction, for any palette with a repeated component. §26.13: discipline over cleverness.
  3. A handover window. Ownership moved on the first byte DELIVERED, which on a quiet boot is seconds after the service clears the screen. Both parties drew in between. The GRANT is the handover.
  4. Drops that no amount of speed could fix. A 16-deep queue cannot absorb a thirty-write burst however fast the renderer is, so observe now lost its tail every time. The writer now blocks (§8.5/§8.6 back-pressure); the kernel still never waits.
  5. reclaim_for_panic had zero callers. The amendment in §9.3 argues this module earns ring-0 residency because a panic cannot ask a service to report it - and the panic handler did not call it, so the first real panic showed a black screen and said nothing. A guarantee asserted in the constitution and absent from the code is worse than an unimplemented feature: the document says it is covered, so nobody looks. There is no test for it (§22 explains why - it is a negative property on a machine that cannot be failed on demand in QEMU), which is exactly why it needed reading rather than assuming.

10. Scrollback (2026-09-18)

The console retains lines that scroll off the top, and PgUp/PgDn walk them.

10.1 Why it belongs to the service and not the kernel

§11.4's boot/panic floor deliberately keeps no history: reaching the bottom of the screen clears it and starts again, because a panic must not depend on a data structure that could itself be corrupt, and serial holds the full transcript either way. This is the console service, which owns the terminal model (§9.2), and history is exactly that.

The kernel is not touched by this work.

10.2 The ring

32 KiB of bytes and 512 line slots, evicting oldest-first on whichever bound binds first. Both bounds are real: a screen of blank lines must not be able to evict a screen of real ones, so there is a ceiling on count as well as on bytes. Measured on the first boot, the ring filled at 512 lines / 26,008 bytes - about 51 bytes a line, so the two ceilings are well matched rather than one being decorative.

The bound is the STACK, not the memory limit, and that is worth knowing before anyone raises it. The service may use 8 MiB, but Term is a local of service_main and the user stack is 256 KiB. The first cut (64 KiB + 1024 lines) put Term::new at 180 KiB and service_main at 118 KiB - 299 KiB together, which faults on the first store of its own prologue. See §10.7, item 3.

Addressing is monotonic: wpos counts every byte ever written and never wraps, and the storage index is wpos % SB_BYTES. That turns "has this line been overwritten?" into one comparison rather than an interval-overlap test against a wrapped region, which is where a ring like this usually goes wrong. A line may straddle the end of the buffer; readers copy it in two pieces.

Trailing blanks are stripped on the way in. The shadow grid is space-filled to its full width, so storing a line verbatim would spend 208 bytes on it regardless of content and cut what fits by an order of magnitude.

Attributes are not retained, stated here rather than left to be discovered. The shadow grid carries one reverse-video bit per cell; scrollback keeps characters only, halving the memory. A reverse-video line scrolled back to shows as plain text. Reverse video lives in full-screen apps, which own the screen and whose output is not scrollback material.

10.3 One capture point

scroll(), where row 0 is about to be overwritten by row 1. That is the exact and only moment a line leaves the screen - every other path either stays on screen or clears it deliberately. Retaining it there means scrollback cannot miss output, whatever produced it, and no producer needs to know scrollback exists.

10.4 Scrolling was a REQUEST, and the request is GONE

This service used to answer REQ_SCROLL: move your view, and report where it now is. The SDK's console_scroll(action) was how a holder of the keyboard asked, the shell drove it from a mode of its own, and output arriving snapped the view back to live.

All of it is deleted, and the reason is backlog/fixed/37.

To answer "move your view" this service had to paint_view and present BEFORE it could compute the reply - a full repaint of the framebuffer, synchronously, inside the caller's request. On a 3840x2160 panel that is the most expensive thing it does. The caller allowed one second and ran on the same core. A scroll therefore "took over two seconds to answer", and four rounds of investigation looked for a lost reply that never existed: nothing was stuck, the work did not fit the deadline.

Holding a scrolled view also cost this service state it could otherwise do without - a view offset, an in-view flag, and a branch in the output path to snap back out of it. State that only one feature needs, and that feature was the one that did not work.

10.5 What replaced it: REQ_HISTORY, and the work moved

The ring stays; the VIEW went. REQ_HISTORY hands the caller bytes out of the scrollback - a bounded memcpy, no painting - and the caller paints its own screen with ordinary output, which is a send and carries no deadline at all. The scrollback utility (utilities/54_scrollback.md) reads the whole history ONCE when it opens, so a keypress costs this service nothing whatsoever.

The lesson is about which side does the work, not about tuning a number. Widening the deadline would have hidden the same coupling: this service was both the thing being read from and the thing being drawn to, one endpoint and one 16-deep queue, so a request made mid-frame could always end up behind painting the caller itself had just asked for. Moving the work removes that by construction; a larger timeout only makes it rarer.

Untrusted content is still the reason it was a request and not an escape sequence. A file being read can contain any bytes, so a scroll expressed as a CSI sequence would let a file scroll the terminal displaying it - the argument dir already makes about filenames, one layer out. That argument is intact and now applies to REQ_HISTORY, which carries a reply cap where output does not.

10.7 What adding it uncovered

Three defects, each older and worse than the feature, all of which had survived because nothing measured them:

  1. osdev build had been shipping a stale console. Its crate list omitted console, control, time and hw-enumerator, so it relinked the supervisor around whatever copies were lying in the target directory. Sixteen copies of that list; there is one now. Same shape services/supervisor/build.rs already records for its USB list: a missing binary trips a guard and a stale one does not, because a stale file is not missing.
  2. scripts/stack_fit_check.py was blind on x86, and was called only from two hand-written board scripts - so the gate existed on two boards and nowhere else, including every QEMU suite. It now knows the x86 prologue and its probe loop (teaching it only the first was an 18x undercount, precisely on the large frames it exists to catch), has a command line, and runs from osdev build.
  3. Term::new materialised the terminal twice - 180 KiB, from a 118 KiB service_main. Its own doc comment warned about exactly this while the code did it; it was survivable only because Fb was small. blank() is a const fn returning a literal now, built directly into the caller's local, with attach(fb) doing the init through &mut self - which is what the comment said all along.

10.8 What it does NOT do yet

help's pager is GONE, and trace's is permanent.

The condition this was held against is met: scrollback was verified on a Dell Wyse 5070 on 2026-09-18 - PgUp entered history, End returned, typing returned on its own, and the serial log carries all three transitions. help prints its table and returns; a long one is recovered with PgUp, or asked for with help | paginate.

trace's pager stays for a reason that has nothing to do with scrollback: it PINS A COLUMN HEADER while you scroll. Scrolled back through a grid in a scrollback buffer the column names are off the top and you are reading unlabelled columns, and no scrollback can fix that. That is the whole difference between the two pagers.

Verified in QEMU (osdev test shell): PgUp enters history, output returns the view to live by itself, End returns to live while scrolled, Home is taken as a scroll while scrolled and still edits the line when it is not.

(Note 2026-10-09: the two verifications above, on the Wyse and in QEMU, were of the scrolled VIEW that §10.4 records as deleted. PgUp now opens the scrollback utility one page back (utilities/54_scrollback.md), which reads the ring through REQ_HISTORY; there is no scrolled state at the prompt for output or End to return from.)

drives - drive management from the shell

Status: Design doc, non-normative, partly built. Hierarchical GSFS and the single-drive data verbs (drives list, flash, label, plus check, scrub and reset, which this doc does not cover) are built over fs; the default flag, multi-drive and [N:]label/path addressing are not (utilities/15_drives.md §8). Records the drives shell utility and the multi-drive model, decided in conversation. Builds on the GSFS filesystem (docs/persistence.md) and the AHCI block driver (docs/ahci.md). Trails CLAUDE.md; does not amend it.

Command surface superseded by utilities/15_drives.md (+ the file commands utilities/16-24), which are authoritative for the user-facing verbs. Three POSIX reflexes shown in this doc's older examples were dropped when the utility surface was settled: mount (GodspeedOS has no global tree to graft into - a drive is reachable on demand by [index:]label/path whenever it's plugged in; 15_drives.md §3.1), and drives use / drives use default (the current location is a single drive+dir pointer moved by cd, not a second "current drive" verb; 15_drives.md §3.2). The boot layer is reserved as the drives godspeed install|update|default namespace (designed in docs/prime.md, not built). This doc is retained for the architecture/rationale - addressing (§4.1/§4.2), the default-flag (§3), the multi-drive model (§4), build scope (§7) - not the verb list.

1. What it is

drives is the shell utility for managing attached disks: list them, format (flash) a raw one as GSFS, choose which one is active, and pick a default that auto-mounts on boot. It's the user-facing front end to fs + block-driver.

Because GodSpeed boots from a removable image (USB) and disks are pluggable, the system needs an explicit, user-initiated way to say "use this disk" - which is also what keeps it honest: the constitution forbids silent reformatting (§3.12), and a human running drives flash is the opposite of silent.

2. Flashing does not need a reboot

A common misconception worth stating: after drives flash, the drive is mounted immediately and usable right away - read/write at once, no reboot. A reboot proves persistence (the bytes survive a power-cycle) but is never part of the workflow. Flash → use. The only reason to reboot is to demonstrate durability, or because you normally power-cycle the machine.

3. The default drive (persistent)

With pluggable drives, the system needs to know which disk to mount on boot.

  • drives use default <n> marks a drive as the default. The flag is stored in that drive's own GSFS superblock (a DEFAULT bit in the flags field), so the drive is self-describing: on boot, fs scans the drives, finds the one flagged default, and auto-mounts it. Move the drive to another machine/port and it is still recognized. Setting a new default clears the flag on the others.
  • Survives reboot because it lives on the disk, not in the (removable) OS image.

4. Multiple drives

Today GodSpeed uses one disk: block-driver stops at the first SATA port with a disk (on the USB boards, the one USB stick) - multi-drive lifts that. Each drive is an independent GSFS tree (its own root directory). A drive moves through states:

  raw  ──flash──▶  flashed  ◀──── present whenever plugged in ────▶  (absent when unplugged)
   (no GSFS)        (GSFS;        addressable on demand by [index:]label/path
                     usable)

Superseded - no mounted state (utilities/15_drives.md §3.1). The earlier model here had raw → flashed → mounted → current; the mounted step was a POSIX reflex. GodspeedOS has no global tree to graft into, so there is nothing to "mount" - a flashed drive is reachable on demand by [index:]label/path whenever it is physically present, and disappears when unplugged. There is no mount table and no bounded mount-slots.

  • current - the drive+directory that unqualified commands operate on, moved by cd (utilities/17_cd.md). One location pointer (drive + dir), not a separate "current drive" - see utilities/15_drives.md §3.2 for why drives use was dropped.

4.1 Addressing - [N:]label/path/to/file

GSFS has real directories (§ persistence.md), so a file is named by a path within a drive, and a drive is named by its label (optionally prefixed by index):

  <address> ::= [ N: ] label / dir / … / file        # on another drive
              |             /path/to/file             # on the current drive (leading /)
              |              path/to/file             # relative to the current dir

The drive part is the GodSpeed-native bit - identity over location (invariant 11): the label is the drive's identity (stored in the GSFS superblock, stable across replug); the index is its location (the port - changes when you replug). You normally use the label alone; you prefix the index N: only to disambiguate (see §4.2). Examples:

  dir archive/projects/2026          # 'archive' is unique → no index needed
  read 0:data/notes.txt             # 'data' on drive 0
  read 1:data/notes.txt             # 'data' on drive 1 (a different drive, same label)
  read /etc/boot.cfg                # leading / → current drive, absolute path
  read notes.txt                    # relative to the current directory

Switching the current drive (drives use) changes what bare/relative paths mean; the [N:]label/… form reaches any mounted drive without switching.

4.2 Duplicate labels are fine - index disambiguates

Labels need not be unique. Two drives can both be data - flashed separately, or one arriving pre-labelled from another GodSpeed instance. They are distinguished by the index prefix, which is unique by physics (one drive per port):

  • Unique label → address by label alone: archive/….
  • Clashing label → prefix the index: 0:data/… vs 1:data/…. Both forms still show a readable name; the number only disambiguates.

This makes cross-instance drives just work: plug in a foreign data disk and it mounts as 1:data - no refusing, no forced relabel, no silent renaming (§26.5), no UUID. Identity (label) names it; location (index) disambiguates when identity repeats.

  • drives flags a duplicated label so you can see it (and relabel with drives label N <new> if you want a unique name), but it is never required:
    gsh> drives
      #  LABEL      STATUS     …
      0  data       mounted    …
      1  data  (2)  mounted    …   ← duplicate label; address as 0:data / 1:data
    
  • drives use data with two data drives asks you to qualify: drives use 1 (or drives use 1:data).

5. Command set

Authoritative command surface: utilities/15_drives.md (drive lifecycle) + utilities/16-24 (file commands). This table reflects the settled vocabulary.

CommandEffectPersists?
driveslist every drive: index, label, status, size, free-
drives flash <drive> [label]format <drive> as a GSFS data drive (asks [y/N] - it ERASES); optional label; usable at oncedata: yes
drives label <drive> <new>rename a drive's label; rewrites the superblock (duplicates allowed - index disambiguates)data: yes
drives godspeed install <drive>install bootable GodspeedOS onto the drive (Prime; not built)yes
drives godspeed update <drive>A/B kernel update of an installed drive (not built)yes
drives godspeed default <drive>which installed GodspeedOS the machine boots (not built)yes
drives godspeedlist installed GodspeedOS drives / help (not built)-
cd <path> · mkdir <path>change current location / create a directory (utilities/17,20)-
dir [path] · read <path> · write <path> … · copy/move/rename/deletenavigate / read / write / manage files (utilities/16-24)-

<drive> = index | label | index:label (§4.1/§4.2). No mount/use - the current location is a cd pointer (§4 note, utilities/15_drives.md §3.2).

6. How it looks (gsh> mockups)

Flash and use a raw drive - immediately, no reboot:

gsh> drives
  #  LABEL      STATUS     SIZE      CONTENTS                   USE
  0  -          raw        16 MiB    - not formatted -

gsh> drives flash 0 data
  This ERASES drive 0 (QEMU HARDDISK, 16 MiB). Continue? [y/N] y
  drives: formatting drive 0 as GSFS, label 'data' … done
  drives: drive 0 mounted - ready to use now (no reboot needed)

gsh> mkdir projects
  fs: created /projects
gsh> write projects/notes.txt "works immediately"
  fs: wrote /projects/notes.txt (18 bytes)

gsh> drives use default 0
  drives: 'data' is now the default - auto-mounts on every boot

Multiple drives, paths, duplicate labels, mount vs use:

gsh> drives
  #  LABEL      STATUS     SIZE      CONTENTS                   USE
  0  data       mounted    16 MiB    GSFS · 5 files · 32k free    * default · current
  1  data  (2)  flashed    32 MiB    GSFS · 1 file  · 65k free    (not mounted)
  2  archive    raw        8 MiB     - not formatted -

gsh> drives mount 1:data
  drives: 1:data mounted (read/list only; current is still 0:data)

gsh> dir 1:data/backups
  NAME           SIZE
  2026-06.tar    40 KiB

gsh> read 0:data/projects/notes.txt
  works immediately

gsh> drives use 1:data
  drives: current drive → 1:data (default 0:data restores on reboot)

Replug-safety (identity over location):

gsh> drives use archive          # by label
   …unplug drive, move to another SATA port, replug…
gsh> drives                       # the index changed; the label did not
  #  LABEL      STATUS     SIZE   …
  0  archive    flashed    8 MiB  …     ← was #2, now #0; still "archive"

7. What it touches (build scope)

A real multi-part feature, layered:

  • block-driver: enumerate all SATA disks (not just the first); the block IPC gains a drive index (ReadBlock(drive, lba) / WriteBlock(drive, lba, …)); a capacity request (sector count from IDENTIFY) so a flash sizes the filesystem to the real disk.
  • GSFS becomes hierarchical (directories) - see persistence.md. The on-disk format gains inodes (file/dir) + directory blocks (name → inode) + path walking; the superblock gains a flags field (DEFAULT bit) and a label field. (Phase 1 shipped a flat GSFS; directories are the adopted evolution.)
  • fs: become raw-tolerant (serve the drives API even with no filesystem); hold several mounted drives at once (bounded slots) with a current drive + current directory; auto-mount the default on boot; resolve [N:]label/path addressing; drives API = list / flash / label / mount / use / use default. (As built, the API is list / flash / label / reset / check / scrub; mount and use were dropped, note at the top.)
  • shell: drives (+ subcommands) and the file commands (dir / read / write / cd / mkdir) with [N:]label/path addressing.

8. Suggested order

  1. Hierarchical GSFS - evolve the format to inodes + directories + path walking (the foundation the rest needs). persistence.md.
  2. Single-drive drives - flash / use / use default + boot auto-mount of the default (one disk; proves the format/mount/default loop).
  3. File commands - dir / read / write / cd / mkdir on the current drive.
  4. Labels - name a drive at flash/label; address + select by label.
  5. Multi-drive - enumerate all disks; per-drive block IPC; mount vs use; [N:]label/path cross-drive addressing; bounded mounted-drive slots; duplicate labels disambiguated by index.

9. Open questions

  • Bound on simultaneously-mounted drives (4?), label length (settled: 31 bytes, LABEL_MAX in services/fs/src/main.rs), path depth / name length, max files per directory.
  • Label clashes: resolved - duplicates allowed, disambiguated by index (§4.2). No forced relabel on import, no UUID; drives label is available but optional.
  • Confirmation UX for flash (a [y/N] prompt vs a --force/yes token), given the shell is line-based.
  • Whether current (drive + directory) resets to the default on every boot (proposed: yes) or is also remembered (proposed: no - only the default persists).
  • Hot-plug: re-enumerating drives when a disk is inserted/removed at runtime (later; the USB stack already does hot-plug, so there is a pattern to follow).

Design Note: capability-gate kill/restart (service_control)

Status: IMPLEMENTED + verified (2026-06-05). Branch: feat/service-control-cap (off main). Date: 2026-06-05 Pins: §3.1 (no ambient authority), §14.4 (service_control, held by the supervisor), §7 (capability model), §22 (identity + property/stress/fuzz/adv/perf/ chaos suites must stay green).


0. Implementation summary (as built)

  • SERVICE_CONTROL_RESOURCE = ResourceId(6), registered in capability::init.
  • handle_kill (syscall 8) gates on current_task_holds_resource(SERVICE_CONTROL, WRITE) as its first action - killing is now a privileged, capability-checked action, not ambient.
  • Minted at spawn for shell, supervisor, and every probe service. Probes are identified by ELF identity: all probe configs now share one PROBE_ELF const, and the mint matches core::ptr::eq(elf_bytes, PROBE_ELF) - miss-proof, no name enumeration. (Strategy (a) below.)
  • caps <service> maps id 6 → service_control.

Note (2026-10-09): the gate is unchanged, the mint is not. handle_kill still checks holdings of SERVICE_CONTROL_RESOURCE (kernel/src/syscall/dispatch.rs). But nothing is minted by name or by ELF identity any more: the kernel no longer holds PROBE_ELF (the probe image moved to the supervisor, docs/probe-params-design.md), and SERVICE_CONTROL is a privilege bit (privbits::SERVICE_CONTROL, kernel/src/task/mod.rs) carried in the SPAWN REQUEST and refused unless the spawner holds it itself (CLAUDE.md 13.6). The probes' bits come from probes::privileges_of. The COM2 control channel below is the services/control service now, not a kernel control.rs.

Verification (kill path exercised, all green)

  • identity 22/22 - Test 4A kills probe-victim; 6A/6B/10A/10B restart.
  • property 10/10 - P2/P5/P7/P8/P9 kill victims.
  • adversarial 11/11 - A5 (TOCTOU kill), A9, A11.
  • fuzz: F7 (50-iteration kill storm) times out under TCG - its serial log shows kill_task: ... freed N frames succeeding throughout, so the gate is correct; the timeout is the known TCG ceiling (chaos is 0/7 on main too, i.e. pre-existing/environmental, not this change). No new unsafe (318).

Denial coverage

No dedicated negative test was added. Every probe gets the cap via ELF identity, so a probe-based "kill denied without cap" test would require excluding one probe name from the mint - a test-only wart on a clean gate. The denial mechanism (current_task_holds_resource returning CapNotHeld when the cap is absent) is the identical code path already proven by A11 (introspection_denied_without_cap). If an explicit service_control denial test is wanted later, it costs that mint exclusion.


1. The gap

kill (syscall 8, handle_kill in kernel/src/syscall/dispatch.rs) performs no capability check. It reads a service name, applies the §6.2 trusted-root guard (init/supervisor/registry rejected), and kills. Any service that can issue syscall 8 can kill any non-trusted-root service.

That is ambient authority - a §3.1 violation ("every privileged action requires an explicit capability"). And §14.4 explicitly says kill/restart require a service_control capability "held only by the supervisor." So the code is wrong against the current constitution; this is not a v2 feature.

Contrast: spawn (syscall 7) is gated - handle_spawn validates a SPAWN_RESOURCE cap. restart = kill + spawn, so today the spawn half is gated but the kill half is not.

This is also why caps supervisor shows only log_write + spawn: the kill authority isn't a held cap, so it can't be displayed. Closing the gap makes it visible.


2. The fix

A new stable kernel resource SERVICE_CONTROL_RESOURCE = ResourceId(6) (gen 0 forever, like the other stable resources). Gate handle_kill on it.

  • capability/mod.rs: declare + register_resource(SERVICE_CONTROL_RESOURCE).
  • handle_kill: it consumes both arg registers for the name (no slot to pass), so gate by holdings, exactly like the introspection syscalls - scheduler::current_task_holds_resource(SERVICE_CONTROL_RESOURCE, Rights::WRITE). Stable resource ⇒ holds_resource is valid (skips the gen check). Return CapNotHeld if absent.
  • Mint name-gated at spawn (task/mod.rs), like INTROSPECT/console_read.
  • restart then requires both service_control (kill) and spawn (spawn) - both held by the same privileged callers.
  • Shell cmd_caps: map id 6 → service_control.
  • Docs: syscall/CLAUDE.md syscall table (Kill requires service_control), capability/ docs, and the §7.4/§13 resource list.

Not affected: the COM2 control channel (control.rs) and the page-fault path call kill_by_name/kill_task_by_slot directly (kernel-internal), bypassing handle_kill - they keep their inherent TCB authority. Only the syscall is gated.


3. Blast radius - why this needs the full suite

The new cap must be minted for every service that legitimately calls kill, or that test silently breaks (kill denied → its logic fails, no compile error). The killers are not just shell + supervisor: the probe binary kills "victim" services throughout the test fleet. Victim targets found in services/probe/src/main.rs:

probe-victim                      (Test 4A)
brutal-id-13-recv                 (identity brutal)
prop-p2/p5/p7/p8/p9-victim        (property P2,P5,P7,P8,P9)
prop-bp2/bp5/bp7/bp8/bp9-victim   (property brutal)
stress-s2/s4/s5/s10-victim        (stress S2,S4,S5,S10)
stress-bs2/bs4/bs5/bs10-victim    (stress brutal)
fuzz-f7-victim / fuzz-bf7-victim  (fuzz F7)
adv-a5-victim / adv-ba5-victim    (adversarial A5)
perf-b5-victim / perf-bp5-victim  (perf B5)
chaos-c7-victim / chaos-bc7-victim(chaos C7)

The killers are the corresponding driver-probe services (e.g. the service running P9 that kills prop-p9-victim), all spawned from the one probe ELF; the service name selects probe_mode.

Mint strategy (two options)

  • (a) Gate on the probe ELF. Every killer is a probe-ELF service; mint service_control for any service spawned from that ELF, plus shell + supervisor. Cleanest - can't miss a family - but needs the spawn path to know "this is the probe ELF."
  • (b) Broad name-prefix gate. Mint for shell, supervisor, and names starting with probe-/prop-/stress-/fuzz-/adv-/perf-/chaos- (and the brutal bp-/bs-/bf-/ba-/bc- forms). Mirrors the existing INTROSPECT gate style but is easy to under-cover.

Prefer (a) if feasible; it's miss-proof.

Verification bar (mandatory before merge)

Because this rewrites how a core syscall is authorized, all suites must be green afterward, not just shell-test: osdev test identity (20), property (P1-P10), fuzz (F1-F8), stress (S1-S10), perf/perf-brutal, adv/adv-brutal, chaos/chaos-brutal. A missed mint shows up as a kill-denied failure in exactly one of these. Run on a rested box (TCG throttles under back-to-back suites) or in CI.


4. Out of scope

Making spawn/the supervisor API a single unified service_control (the spec keeps SPAWN and service_control distinct). Restartable block-driver/fs (v2). Re-routing the shell's kill through the supervisor instead of a direct syscall (Appendix B.3 ideal; the shell holds the cap directly in v1).

Design Note: Gating Introspection Behind a Capability (§3.1)

Status: BUILT. InspectKernel and TaskStat are gated by the INTROSPECT capability (INTROSPECT_RESOURCE, kernel/src/capability/mod.rs), and the adversarial suite pins the denial (A11, introspection_denied_without_cap).

(This said "approved, not yet implemented" after it had been implemented AND pinned by a test.) Date: 2026-06-03 Pins: §3.1 (no ambient authority), §3.3 (authority is explicit), invariant 1, §26.9 (authority stays visible and scoped).


1. Problem

The two kernel introspection syscalls were ambient - any task could call them holding no capability:

  • InspectKernel (syscall 13) - alloc bytes, live-endpoint count, frame counts, per-core ticks, core count, endpoint generation by name, TSC.
  • TaskStat (syscall 16) - full per-task snapshot for any scheduler slot: name, core, state, memory used/limit, queue depth, restart generation.

handle_task_stat said so explicitly in source: "No capability required - read-only kernel state." This was a deliberate convenience for the §22 property/perf harness, which calls these from many probe instances.

But it is a standing exception to the constitution. Invariant 1 (§3.1) is "no ambient authority - every privileged action requires an explicit capability," and kernel/src/syscall/CLAUDE.md states the rule with "there are no exceptions." Enumerating every task's name, memory, and restart count, or probing another named service's lifecycle generation, is information-disclosure authority - a confidentiality boundary the capability model is supposed to mediate. Today it is not mediated. This note closes that gap.

It surfaced while deciding the architecture of the observe utility (utilities/1_observe.md §7): observe is a least-authority service, but "least authority" is hollow if the authority it needs (read system metrics) is something every service already has for free.


2. Decision summary

#DecisionChoice
1What to gateDisclosure of another task's or system-wide state requires the cap; reading only your own state or a hardware clock stays ambient.
2MechanismA holds-resource check - the kernel verifies the calling task holds INTROSPECT_RESOURCE (READ) - not a passed cap-slot.
3DenialReturn a distinct denied code (CapNotHeld); SDK wrappers are unchanged (legitimate holders never see it).

3. Scope - what is gated, what stays ambient

Rule: disclosing another task's or system-wide state requires INTROSPECT; reading only your own state, or a hardware clock, does not.

Gated (require INTROSPECT_RESOURCE with Rights::READ)

Syscall / queryDiscloses
TaskStat (16)any task's full snapshot
InspectKernel 1live endpoint count (system)
InspectKernel 2another named endpoint's generation
InspectKernel 4free physical frames (system)
InspectKernel 5total physical frames (system)
InspectKernel 6a core's active ticks (system)
InspectKernel 7a core's total ticks (system)
InspectKernel 8ready core count (system)
InspectKernel 24the endpoint a given task owns
InspectKernel 25the endpoint a given task is blocked in Call awaiting
InspectKernel 26the count of fault diagnostics emitted without the serial lock
InspectKernel 27the boot record (the first 32 KiB ever logged; CLAUDE.md 11.4)

Ambient (no capability)

Syscall / queryWhy it stays open
InspectKernel 0the caller's own allocated bytes - its own state
InspectKernel 3the cycle counter - a hardware clock, not anyone's state
InspectKernel 13whether the CALLER owns the console foreground - caller-specific, like 0
InspectKernel 10, 11, 12, 16, 17board-neutral timing: input-ready, the RTC now/boot reads, TSC-per-quantum, deglitched monotonic seconds
InspectKernel 14, 15, 18, 19, 20, 21, 23board facts and transport: NIC identity and BAR, driver-presence bits, a hardware random word, the EMMC base clock, one byte off the COM2 operator channel, the board's own MAC
InspectKernel 28the pending power-off or reboot - system-wide, but ungated deliberately so a pending shutdown can never be hidden (docs/shutdown.md 6)

Queries 9 and 22 were deleted (console geometry moved to the console service, clock provenance to time) and are no longer in the ungated list, so like any unknown query they answer CapNotHeld to a task without the cap and -1 to a holder.

The gate is a single matches! on the ungated set in handle_inspect_kernel, so a query added without thought lands on the gated side - the right default. The line is chosen so the migration cost is identical to a narrower one (the same services need the cap regardless), making the complete version free: gate the whole cross-task and system surface, keep self-state, the clock and task-neutral board facts open.


4. Mechanism - holds-resource gate, not a cap-slot

Why not the existing slot pattern

Every gated syscall today (Log, Spawn, ConsoleRead, ConsolePush) passes a cap_slot argument and the handler does current_task_lookup_cap(slot, right) + resource_id check. That does not fit here: TaskStat (slot / buf_ptr / buf_len) and InspectKernel query 2 (query_id / name_ptr / name_len) already consume all three ABI argument registers. There is no spare slot to pass a cap-slot without adding a fourth ABI argument (which would touch the asm syscall entry).

The gate

Add a stable kernel resource:

#![allow(unused)]
fn main() {
// kernel/src/capability/mod.rs
/// Introspection authority - read another task's or system-wide kernel state
/// via InspectKernel (13, system queries) and TaskStat (16). Self-state queries
/// (own alloc bytes, TSC) remain ungated. Gating prevents an arbitrary service
/// from enumerating every task's name/memory/restart count (§3.1).
pub const INTROSPECT_RESOURCE: ResourceId = ResourceId(5);
// ... register_resource(INTROSPECT_RESOURCE) in init()
}

Each gated handler, before doing anything, checks that the calling task holds INTROSPECT_RESOURCE with READ:

#![allow(unused)]
fn main() {
if !scheduler::current_task_holds_resource(INTROSPECT_RESOURCE, Rights::READ) {
    return cap_err_to_i64(CapError::CapNotHeld);
}
}

This needs one new scheduler/CapTable helper - current_task_holds_resource(rid, right) -> bool - that scans the calling task's cap table for a live cap on rid carrying right. (The cap table already supports per-slot iteration via for_each_active_cap.)

Deviation, documented

This is a holds-resource check rather than the documented CapTable::get(slot, right) slot check. The intent of the §3.1 rule - validate a capability before performing the privileged action - is fully satisfied; only the calling convention differs, because read syscalls that consume all argument registers have no slot to pass. kernel/src/syscall/CLAUDE.md will be updated to record this as the sanctioned form for argument-saturated read syscalls.

SDK is unchanged

Because the gate reads the task's holdings rather than a passed slot, the SDK introspection wrappers (inspect_*, task_stat) keep their exact signatures and bodies. No caller code changes except declaring the capability (§6).


5. Denial semantics

A denied call returns cap_err_to_i64(CapError::CapNotHeld) - a distinct negative, separable from the existing -1 "not found / invalid args" the inspect handlers already use.

The three legitimate callers (§6) all hold the cap, so they never observe denial; denial only bites an undeclared or adversarial service. The current SDK wrappers coerce a negative return to a default (0, or valid: false for task_stat), which means denial is, today, quiet. We accept that for now because no legitimate caller hits it.

Follow-up (logged, out of scope here): "loud over silent" (§26.7) argues for making the introspection wrappers fallible (Result/Option) so a denied call is surfaced rather than coerced to a default. Deferred - it is SDK API churn with no current consumer, and would be pulled into existence by the first service that must distinguish "denied" from "zero."


6. Migration

The grant travels in the spawn request's privilege word (privbits::INTROSPECT), and the kernel mints the cap only if the SPAWNER holds the same authority itself (privileges_caller_lacks). It began as a name match in the kernel's own service_privileges, which was a hole once probe names became caller-supplied - "call yourself observe-x and get introspection". The decision is still made by name, but in the supervisor (probes::privileges_of), by a principal that holds INTROSPECT and may pass it on; the kernel no longer infers authority from a string. The services that legitimately read cross-task/system state:

ServiceNeeds the cap because it callsNotes
shelltask_stat (status), inspect_core_count (cores)privileged broker already
observetask_stat + aggregatesthe metrics utility itself
probeinspect_endpoint_generation (query 2)test harness; query 0 + TSC stay ambient so most probe paths are unaffected

Since written, chaos, control, power, ask and ask-net hold it too (their rows in the supervisor's spawn table, services/supervisor/src/main.rs), so the table above is the original three, not the full list.

The kernel mints INTROSPECT_RESOURCE (READ) into the cap table of each task whose spawn request carries privbits::INTROSPECT, at spawn (§14.1). The grant lands on exactly the probes that need query 2 and on no others: the prop- and stress- drivers read their victims' generations, while adv-a11 - whose whole subject is a service WITHOUT the cap - is excluded by name, which is why it cannot be "every probe".


7. Constitutional & doc impact

  • Not an amendment - an alignment. This removes a standing exception to §3.1; it strengthens conformance rather than changing an invariant, so no CLAUDE.md invariant change is needed. (A one-line note may be added to the §7 capability section listing INTROSPECT_RESOURCE among the stable resources.)
  • kernel/src/syscall/CLAUDE.md: add INTROSPECT_RESOURCE to the syscall table and document the holds-resource form for argument-saturated read syscalls.
  • audits/unsafe-audit.md: unaffected - no new unsafe (the gate is safe Rust).
  • utilities/1_observe.md §7/§8: update from "introspection is currently ambient (verify)" to "introspection is gated by INTROSPECT_RESOURCE; observe's contract declares it," restoring the accuracy of observe's least-authority story.

8. Testing

  • Regression (must stay green): the full §22 suite - identity, property (P2/P4/P5/P8 use the gated/ambient queries), perf (TSC stays ambient), stress - with the three services now holding the cap. A pass proves the gate is transparent to legitimate holders.
  • New adversarial test (the property this unlocks): a service that does not declare introspect calls task_stat / a gated InspectKernel query and is denied (CapNotHeld), while a self query (own alloc bytes) and TSC still succeed. This is the first test that can assert introspection is not ambient - it was impossible to write before. Fits the §22 Adversarial (A-series) bar.

9. Implementation checklist

  1. capability/mod.rs: add INTROSPECT_RESOURCE = ResourceId(5) + register it.
  2. task/scheduler.rs (+ capability/table.rs as needed): current_task_holds_resource(rid, right) -> bool.
  3. syscall/dispatch.rs: gate handle_task_stat unconditionally, and gate handle_inspect_kernel by listing the UNGATED query ids - so anything new is gated by default (§3).
  4. task/mod.rs: carry the grant in the spawn request's privilege word (privbits::INTROSPECT), refuse any bit the spawner does not hold itself, and mint the cap at spawn for the services whose row asks for it.
  5. Docs: update syscall/CLAUDE.md, the §7 capability list, and utilities/1_observe.md §7/§8.
  6. Build (osdev image), run the identity/property/perf/adversarial suites; add the new "introspection denied without cap" adversarial test.
  7. Hardware sanity on the T630 if the suite passes in QEMU.

All seven are done, and observe was built on top of them - a least-authority service whose introspection authority is one declared capability.

Unsafe Audit (§18.4)

scripts/unsafe_check.py runs on every CI push. It counts non-comment lines containing the unsafe keyword per file and compares to the baseline table below.

A PR that increases any file's count, or adds unsafe to a new file, fails CI unless this file is updated in the same commit with a written SAFETY argument.

unsafe_check.py scans kernel/src/ (tracked against the inventory below) and services/ (where it fails on ANY unsafe line - §18.2 forbids service unsafe).

sdk/ IS NOW SCANNED (2026-09-12). It was scanned by nothing: the script defined two roots, kernel/src and services, so ~125 unsafe lines went through no tool at all while §18.4 said "CI checks the file matches source". backlog/fixed/18 recorded the gap; this closes it.

FileLinesStatus
sdk/rust/src/syscall.rs8permitted (§18.1 - the syscall ABI)
sdk/rust/src/mmio.rs9permitted (§18.1 - device registers)
sdk/rust/src/dma.rs10permitted (§18.1 - DMA memory)
sdk/rust/src/adversarial.rs10permitted (§18.1 - the red-team module)
sdk/rust/src/memory.rs3permitted (§18.1 - a task's own memory, 2026-10-17 amendment)
sdk/rust/src/service_context.rs80grandfathered floor (§18.5; +1 exit, 2026-10-17 amendment; +2 power_off_call and power_off_pending, 2026-10-14 amendment - the query 28 site is power_pending since 2026-10-15, and the reboot wrappers ride power_off_call, so reboot on the deadline added none; +1 device_power and +1 cpu_clock, both by 2026-10-01 amendments; -1 2026-10-04, the dead cache_send_slot removed; +1 boot_record_query, 2026-10-08 amendment; -4 2026-10-10, the four retired spawn-by-name syscalls, backlog/fixed/80 K14; -3 2026-10-11, the dead NET_DEVICE wrappers, backlog/21)
sdk/rust/src/ipc.rs8grandfathered floor (§18.5)

The 87 (79 + 8) are not 87 defects. 84 of them are unsafe { raw_syscall(..) } CALL SITES (counted 2026-10-12 and moved with power_off, 2026-10-14; this read "92" and "88" from an earlier floor and was not moved with it). raw_syscall is an unsafe fn because it issues the trap instruction, so every caller must open a block - and these two files are the wrapper layer whose whole purpose is to keep services unsafe-free. That worked: services/ is at zero. The isolation simply stopped one layer short of itself.

The remaining 3 are genuinely different and are the ones to look at first: two bare unsafe { blocks (the SEND_CAP_CACHE walks) and one raw-pointer deref (&*(SERVICE_CTX_ADDR as *const ServiceContextData)).

The real fix, recorded rather than done: give raw_syscall a SAFE wrapper. The kernel validates every user pointer, so passing integers to a validating callee is sound, and ~82 of these would collapse to nothing - taking the SDK to roughly the 35 lines §18.1 actually sanctions. It is an SDK redesign on every service's call path and wants hardware validation, so it is written down here instead of attempted in the same change that made it visible (§26.7).

Both floors are enforced: a new unsafe in any other sdk/ file FAILS, and either floor growing FAILS. They may decrease freely.


2026-10-17 - Exit: a program ends itself (feat/packages)

A new syscall (57), with the constitution amended at 14.5 and the floor at 18.5. The kernel's handler adds NO unsafe: it is the self-kill path handle_kill already takes, with a new cause.

FileLinesWhy
sdk/rust/src/service_context.rs79 -> 80 (+1)exit: one unsafe { raw_syscall(57, ..) } call site behind the safe ServiceContext::exit, one integer to a syscall that ends the caller and does not return. The single design consequence the cpu_clock entry names. Floor amended at CLAUDE.md 18.5, 2026-10-17.

2026-10-17 - Region: a task's own memory as a slice (feat/packages)

A new permitted SDK module (CLAUDE.md 18.1, amended the same day), for the supervisor's install loader: it reads a package's image from fs into memory it owns before starting it, and the image is larger than a stack. No kernel change and no new syscall: AllocMem (6) already maps the pages.

FileLinesWhy
sdk/rust/src/memory.rsnew, 2Region::bytes and Region::bytes_mut: one slice::from_raw_parts and one from_raw_parts_mut over exactly the pages a successful AllocMem mapped for this task, which only the constructor can produce. Zeroed by the kernel, so initialised; unmapped only at the task's death, so they outlive the value; the value is neither Clone nor Copy, and the mutable slice is borrowed through &mut self, so no two live slices alias. Not an allocator: there is no free. 2026-10-18, 3: Region::words_mut, the same pages as u32 for a device FIFO read a word at a time (the AIC8800's SDIO read buffer, services/wifi-driver): page-aligned, so aligned for u32; a whole number of pages, so len / 4 words fit exactly; every bit pattern is a valid u32; borrowed through &mut self like bytes_mut.

2026-10-14 - PowerOff: turning the machine off (feat/shutdown)

A new syscall (56) and a new resource (POWER_OFF, 19), with the constitution amended at 12.3 and the floor at 18.5 (docs/shutdown.md). The neutral handler adds NO unsafe: it checks a holding, takes the screen and the serial line as the panic path does, and calls the arch seam power_off. The three boot-only ports answer it with safe stubs.

FileLinesWhy
arch/x86_64/acpi.rsnew, 5ACPI S5. ONE slice::from_raw_parts turns a firmware table's address into a slice of its own stated length (bounded at 1 MiB), so every field and the whole \_S5 scan are bounds-checked indexing; then the port I/O the FADT names: a 16-bit read and write of PM1a/PM1b_CNT, the 8-bit SMI_CMD <- ACPI_ENABLE handshake, and one cli. No kernel memory is written.
arch/aarch64/mod.rs73 -> 74 (+1)power_off (Pi 4): the BCM2711 power-management registers hardware_reset already writes, through the kernel's Device mapping, with the boot partition set to 63 (halt, Linux bcm2835_power_off) instead of cleared. Password-gated volatile writes, poked once.
arch/arm/mod.rs52 -> 53 (+1)power_off (Pi 2): the same for the BCM2836, in the Device-mapped peripheral window.
arch/aarch64/mod.rs74 -> 75 (+1)power_off (Pi 4), after the Pi 2 card: a bounded wait on the PL011 flag register's BUSY bit before the watchdog is armed, so the last line leaves the UART before the board halts. One volatile read through the Device mapping, as at UART init.
arch/arm/mod.rs53 -> 54 (+1)power_off (Pi 2): the same wait, after the TX ring is flushed; the Pi 2's last line was cut off mid-sentence without it.
arch/riscv64/sbi.rs11 -> 12 (+1)system_shutdown: SBI SRST with the specification's shutdown type, probed first; it does not return on success and answers an error otherwise, touching no kernel memory.
sdk/rust/src/service_context.rs77 -> 79 (+2)power_off_call: one unsafe { raw_syscall(56, ..) } call site behind the three safe wrappers power_off, power_off_in and power_off_cancel, two integers to a kernel that checks the capability before acting; and power_off_pending, one unsafe { raw_syscall(13, 28, ..) } read with no pointer. The single design consequence the cpu_clock entry below names. Floor amended at CLAUDE.md 18.5, 2026-10-14.

2026-10-10 - the unused TLB shootdown deleted; P7 tests the property (backlog/fixed/80 K20)

The cross-core software TLB broadcast in smp/ipi.rs had no caller. It was deleted after a per-port proof that translation coherence holds without it (CLAUDE.md 10.5): nothing unmaps a live user mapping, the kill path frees nothing until no core runs or is still leaving the task, every switch invalidates on its own core, and AArch64's free invalidates inner-shareable for the idle core that keeps the dead root.

FileLinesWhy
smp/ipi.rs17 -> 4 (-13)shrank: broadcast_tlb_shootdown, broadcast_full_tlb_flush, request_and_wait, service_pending, handle_tlb_shootdown, publish_expected, this_core, invalidate, the per-core request slots and their ack and expected arenas. What is left is send_ipi and ipi_handler.
smp/percpu.rs8 -> 6 (-2)shrank: alloc_atomic_u64_slice, which carved only the shootdown's ack arenas.
arch/x86_64/boot.rs107 -> 103 (-4)shrank: broadcast_ipi_all_but_self (the fn and its ICR block) and the ipi_tlb_stub naked stub (attribute and fn) that IDT 0xF1 pointed at; 0xF1 now takes the default exception_halt, as any unused vector does.
arch/aarch64/mod.rs74 -> 73 (-1)shrank: broadcast_ipi_all_but_self; the SGI id it used (SGI_TLB) is retired with it.
arch/riscv64/mod.rs67 -> 64 (-3)shrank: broadcast_ipi_all_but_self (the fn, its id read and its per-hart send).
arch/arm/mod.rs, arch/loongarch64/mod.rs, arch/riscv32/mod.rs, arch/s390x/mod.rs-1 eachshrank: the empty broadcast_ipi_all_but_self stub.
sdk/rust/src/adversarial.rs8 -> 10 (+2)The P7 stale-translation test (permitted module, 18.1). Region, a run of pages that can only be built from a successful AllocMem, with stamp (one write_volatile per 32-bit word) and first_not (one read_volatile per word). Sound because the constructor is the only way to make one: the pages are mapped, writable, private to the task, page-aligned, and no Rust reference points into them. It lets the probe service stay unsafe-free while P7 stamps a writer's pages and has a checker given the same addresses read them.

2026-10-11 - the NET_DEVICE wrappers removed (backlog/21 step 2)

No port has an in-kernel network device, and nothing has been granted NET_DEVICE since 2026-09-13, so the SDK's three wrappers for syscalls 42-44 had no caller anywhere in the tree.

FileLinesWhy
sdk/rust/src/service_context.rs80 -> 77 (-3)shrank: the unsafe { raw_syscall(42 / 43 / 44, ..) } call sites went with net_frame_tx, net_frame_rx and net_info. SDK_GRANDFATHERED in scripts/unsafe_check.py is lowered to 77 to lock it in. The syscalls stay in the kernel for now; removing them is a kernel change and the entry's last step.

2026-10-10 - the spawn-by-name syscalls retired; EHCI INTx follows its driver (backlog/fixed/80 K14, K17)

The kernel catalogue holds supervisor alone, so Spawn (7), SpawnPipe (19), SpawnReturningEndpoint (38) and SpawnWithCaps (39) could only answer NotFound or AlreadyRunning. They are retired, with their SDK wrappers; SpawnImage (52) is how a service starts.

FileLinesWhy
sdk/rust/src/service_context.rs84 -> 80 (-4)shrank: the four unsafe { raw_syscall(7 / 19 / 38 / 39, ..) } call sites went with spawn_on_kernel, spawn_returning_endpoint, spawn_with_caps and spawn_pipe_on. SDK_GRANDFATHERED in scripts/unsafe_check.py is lowered to 80 to lock it in.
arch/x86_64/ioapic.rs8 -> 9 (+1)set_dest: one write of a redirection entry's HIGH dword, which holds only the destination APIC id, so an EHCI on INTx can be re-aimed at the core its driver was spawned on (K17) without touching the entry's vector, trigger, polarity or mask. Runs at spawn, when the dead instance's line is already masked.

2026-10-04 - Pi 4: ask the faulting core's own MMU (feat/wifi-driver)

backlog/fixed/72: the page walk above came back the same way in four faults - every frame allocated and owned by the faulting task alone, the leaf valid and executable, and the core had nonetheless taken an L3 translation fault. The software walk reads the tables through memory and cannot see what the core had cached, so the report now also asks the core: AT S1E0R on the faulting address at the very top of the trap report, then a local TLB flush and the same AT again. Stale cached state fails then succeeds; an entry that was briefly invalid succeeds at once.

FileLinesWhy
arch/aarch64/ptables.rs27 -> 28 (+1)at_probe: two AT S1E0R (write only PAR_EL1) and one tlbi vmalle1 (drops only this core's cached translations). Touches no memory and no table. The faulting task is about to be killed, so the flush costs every other task on this core nothing but refills from tables that have not changed.
arch/aarch64/context.rs9 -> 8 (-1)Locked in: the reduction from 5ee446d2, when switch_context stopped reading TTBR0 to compare it with the incoming base (it now installs and flushes on every switch).

2026-10-04 - Pi 4: a page walk in the user-fault report (feat/wifi-driver)

backlog/fixed/72: Pi 4 tasks take instruction aborts and permission faults on their own code - the shell on the first instruction of service_main - with the TLB flushed on every switch. The fault report now walks the faulting address through the table the core had installed and says whether each frame on the way has a second owner. Read-only throughout; nothing is written to any table or to the allocator.

FileLinesWhy
arch/aarch64/ptables.rs24 -> 27 (+3)held_elsewhere: a read-only walk of every OTHER live root's tables through the existing get accessor, which refuses any address that is not RAM, so a corrupt descriptor is skipped rather than followed. fault_report: one mrs ttbr0_el1 (side-effect-free) and one read-only walk of the faulting address through get. The live-root set they consult is plain atomics, no unsafe.
memory/allocator.rs47 -> 48 (+1)frame_is_free: one read of the free bitmap through the existing bitmap() accessor, deliberately WITHOUT the allocator lock - it runs in a fault report, where taking a lock another core may hold would wedge the machine over a dead service. phys_in_ram bounds the index below max_ram_frame, which the bitmap was sized to cover. A racing read is at most one allocation stale, which is acceptable for a diagnostic.

2026-10-03 - the Pis' audio jack, prepared as part of its grant (feat/audio)

pwm-audio drives the Pis' 3.5 mm jack: PWM fed by the SoC's DMA engine (docs/audio.md, "The Pis"). Two of the steps live in SHARED blocks - every pin's function in the GPIO page, every clock in the clock manager's - so the kernel does them when it grants the jack, and the driver is granted only the PWM and DMA pages (CLAUDE.md 12.3, the 2026-10-03 amendment). One block per port.

FileLinesWhy
arch/aarch64/mod.rs73 -> 74 (+1)pwm_probe (Pi 4): one uaccess::probe_read32 of PWM1's CTL register at its fixed address inside the Device peripheral mapping, 4-byte aligned - the rng_probe pattern. QEMU's raspi4b models no PWM, and the first boot that granted the jack there saw the driver's first write abort and the driver respawned forever. Probed once at boot; the jack is granted only where it answered.
arch/aarch64/mod.rs72 -> 73 (+1)audio_jack_prepare (Pi 4): read-modify-writes of GPFSEL4 (GPIO40/41 to ALT0, PWM1) and GPIO_PUP_PDN_CNTRL_REG2 (their pulls off) changing only those two pins' fields, then the clock manager's CM_PWMCTL/CM_PWMDIV with the 0x5A password: kill, a BOUNDED wait for BUSY, divider PLLD/6, source, enable - Circle's sequence (gpioclock.cpp), checked against the datasheet. All fixed BCM2711 registers reached through mmio(), all owned by the PWM clock or these two pins. No kernel memory touched. Runs at pwm-audio's spawn, from map_fixed_driver_mmio.
arch/arm/mod.rs52 -> 53 (+1)audio_jack_prepare (Pi 2): the same for the BCM2836 - GPFSEL4 (GPIO40/45 to ALT0, PWM0), the GPPUD/GPPUDCLK1 strobe that clears only those two pins' pulls (the sequence sd_route_to_emmc already uses), and the PWM clock at PLLD/2, with the same bounded BUSY wait. Fixed registers in the Device-mapped peripheral window.

2026-10-01 - the RNG200 is probed before it is read (feat/wifi-driver)

hw_random read the BCM2711's RNG200 unconditionally, behind InspectKernel query 19, which is UNGATED. QEMU's raspi4b models no RNG200, so there the first read is an external abort that halts the kernel - one unprivileged syscall from any service. Found when the time service began asking for an NTP nonce on every boot. The fix is the posture the PCIe root complex and GENET already have: probe once at boot with uaccess::probe_read32, record the answer, and never touch a block that did not answer.

FileLinesWhy
arch/aarch64/mod.rs71 -> 72 (+1)rng_probe: one uaccess::probe_read32 of the RNG200's CTRL register at its fixed address inside the Device peripheral mapping, 4-byte aligned. probe_read32 is the existing one-instruction fixup that turns the external abort of an absent device into None; a fault anywhere else still halts loudly. Runs once, on the boot path, inside the probe window.

2026-10-08 - the boot record: events log boot (feat/wifi-driver)

A new InspectKernel query (27), pinned in COMMANDMENTS.baseline.toml, with the constitution amended at 11.4 and the floor at 18.5. The kernel side adds NO unsafe: the record is a fixed array beside the ring in log.rs, under the ring's existing SpinLock, and the query copies a chunk out through the existing write_user_bytes.

FileLinesWhy
sdk/rust/src/service_context.rs83 -> 84 (+1)boot_record_query: one more unsafe { raw_syscall(13, 27, ..) } call site, behind the two safe wrappers boot_record_size and boot_record_read - one block for both, which is why it is +1 and not +2. The read's buffer is typed &mut [u8; BOOT_READ_CHUNK], so the length the kernel writes is the length the caller holds. Floor amended at CLAUDE.md 18.5, 2026-10-08.

2026-10-01 - CpuClock: the Arm clock, for the power service (feat/wifi-driver)

A new syscall (55) and a new resource (CPU_CLOCK, 18), with the constitution amended at 12.3 and the floor at 18.5. The kernel side adds NO unsafe: the handler checks a holding and calls the arch seam; the aarch64 rate request goes through the mailbox's existing property_call under MBOX_LOCK, exactly as the expander GPIO calls do. The six other ports answer the seam with safe stubs returning None.

FileLinesWhy
sdk/rust/src/service_context.rs83 -> 84 (+1)cpu_clock: one more unsafe { raw_syscall(55, ..) } call site, the same single design consequence as device_power below. One integer to a validating kernel; the capability is checked before the firmware is asked anything. Floor amended at CLAUDE.md 18.5, 2026-10-01.

2026-10-01 - DevicePower: the device grant made renewable (feat/wifi-driver)

A new syscall (54) and a new resource (DEVICE_POWER, 17), with the constitution amended at 12.3 and the floor at 18.5 before the gate would pass it. The kernel side adds NO unsafe: the handler checks a holding and calls the arch seam; the aarch64 pin write goes through the mailbox's existing property_call, which already translates its buffer's address and does the cache maintenance (it has served notify_xhci_reset after the MMU came up since 2026-08) and now takes a lock so a syscall and a boot caller cannot share the buffer. The six other ports answer the seam with safe stubs returning false.

FileLinesWhy
sdk/rust/src/service_context.rs82 -> 83 (+1)device_power: one more unsafe { raw_syscall(54, ..) } call site - the same design consequence the 2026-09-12 amendment names (the SDK is the wrapper layer, so a new syscall is one block here and none in any service). Two integers to a validating kernel; the capability is checked before any pin is touched. Floor amended at CLAUDE.md 18.5, 2026-10-01.

2026-09-27 - Pi 4: routing the radio's pins, and the clock its controller needs (feat/wifi-driver)

The census below established WHICH controller the radio is behind and deliberately granted nothing. This is the grant, and the two board-level facts that have to be true before a driver can use it.

map_fixed_driver_mmio now names 0xFE30_0000 to wifi-driver, gated on the census having seen that controller ANSWER - not on a constant, because QEMU's raspi4b emulates no Arasan and an ungated grant would hand the service a window whose first register read aborts, forever.

The new unsafe is the first WRITE in this file, which is why it gets its own row rather than being folded into the reads below. GPIO 34-39 in ALT3 is the Arasan's SD1 interface - the only path to the radio - and pin muxing is a BOARD fact rather than a driver's business: a driver service is granted its own controller's registers and nothing else (§12.3), so it cannot route the pins that connect it to the part it drives. arm32 makes exactly this argument at sd_route_to_emmc, one SoC generation earlier and with the older BCM2835 pull-strobe sequence instead of the BCM2711's direct pull registers.

The read-back is logged BEFORE the write, for the same reason arm32 logs it: it is the one fact that separates "the radio was muxed away from us" from "it is ours and something else is wrong". On this board the firmware is expected to have done it already, so reading back ALT3 is the predicted case and a disagreement is the interesting one.

Two mailbox calls come with it and need no unsafe at all - mailbox::property_call is a safe function - so the SD power domain and the Arasan's base clock are asked for and PRINTED rather than assumed. The clock matters more than it looks: emmc_base_clock_hz returned a flat 0 on this port, and 0 is a refusal rather than a default, so without it the driver would correctly decline to set any card clock at all.

FileLinesWhy
arch/aarch64/mod.rs70 -> 71 (+1)hw_random (2026-09-30): the BCM2711 RNG200 behind InspectKernel query 19 - one block of 32-bit volatile reads and writes of six registers at a fixed address inside the Device-nGnRnE peripheral window, through mmio() so it holds on both sides of the jump to the high half. Registers, masks and the restart sequence are Linux's iproc-rng200 quoted at the function (26.14). Enabled once behind an atomic swap; the restart runs only on a fail bit the block reported, once per read as Linux allows; the wait is a bound in reads of the count register and returns None rather than holding the core. No kernel memory is touched. It replaces a stub that returned None on every call, which left the wifi driver hashing its handshake nonce from a cycle counter (docs/wifi.md 40).
arch/aarch64/sdio.rs2 -> 3 (+1)route_pins_to_arasan - a read-modify-write of the BCM2711 GPIO block's GPFSEL3 and GPIO_PUP_PDN_CNTRL_REG2, touching only GPIO34-39's fields in each, on the single-threaded boot path, through the kernel's Device peripheral mapping (mmio(), so it holds on both sides of the jump to the high half). One block covering both registers plus the read-back that is logged before either write; it carries its own SAFETY comment. No allocation, no loop bound to anything device-supplied, and the six pins are a compile-time range.

2026-09-27 - Pi 4: which SD host controller is the WiFi radio behind (feat/wifi-driver)

docs/wifi.md section 4 rests the whole Pi 4 phase-1 estimate on one board fact: the CYW43455 sitting behind the older Arasan controller - the block services/block-driver/src/sdhci.rs already drives - with the SD card on the other one. That was recorded as needing a device-tree read. It does not: the machine can be asked.

arch/aarch64/sdio.rs asks it. Two candidate windows, two read-only SDHCI registers each, printed with which answered. It is discovery in COMMANDMENTS.baseline.toml, beside genet.rs and pcie.rs, and Commandment I's role check is what made that classification explicit rather than assumed.

It grants nothing. map_fixed_driver_mmio is untouched until the boot log says which window to name, because that table's own comment records the cost of guessing: a service handed a range whose first register read aborts dies on that read, and the supervisor respawns it forever.

One of the two offsets is an in-repo fact (arch/arm/mod.rs grants the Arasan at PERIPHERAL_BASE + 0x30_0000, and sdhci.rs documents the same); the other is this author's recollection and is labelled UNVERIFIED in the log line itself, because a probe that cannot be told apart from a claim is worth less than no probe.

FileLinesWhy
arch/aarch64/sdio.rs0 -> 2 (+2)Two uaccess::probe_read32 calls, the same abort-catching read genet::probe and pcie::init use and for the same reason: on this SoC an address that decodes to nothing is an external abort, and one taken outside the boot probe window surfaces later as an SError blaming an unrelated userspace task. Each carries its own SAFETY comment noting 4-byte alignment and that the address is inside the peripheral Device mapping the kernel built at boot. Reads only; no write, no side effect intended, and the registers chosen are SDHCI's read-only ones.

2026-09-03 - arm32: the drainer waits for the wire rather than dropping bytes out of a line

Measurement, not reasoning, produced this one. With the line ring and the single-writer rule in place the hardware reported 0 line(s) dropped, 0 bypassed, 0 producer collision(s) - the whole structure above the byte layer held - and 95 bytes discarded per drain, forty-nine times. The bytes were coming out of the MIDDLE of lines, which reads exactly like the splicing the structure was built to stop.

So the granularity of loss has to match the granularity of meaning. A line may be dropped, loudly and counted, because a missing line is visibly missing. Part of a line may not, because a line with a hole in it still looks like a line and lies to whoever reads it.

The byte ring stays where it belongs - stopping an ORDINARY writer from waiting on a 115200 line - and the drainer, which is the single agent whose job is the wire and has nothing behind it to starve, now waits instead.

FileChangeWhy
arch/arm/mod.rs52 -> 53 (+1)pl011_write_blocking: the volatile MMIO loop that polls PL011_FR.TXFF and writes PL011_DR. Identical to the loop it replaces inside pl011_write_byte - same registers, same Device-mapped window, same bounded poll that gives up rather than hanging on a wedged UART - lifted into its own function so the drainer can use it without the discard path attached. One block moved and specialised, not a new kind of access.

2026-09-03 - arm32: one claim, factored out, so a whole line stays whole (feat/supervisor-owns-images)

The line ring added earlier the same day did not reduce the splice rate at all - 0.36% of lines carried fragments of two or three messages before it, and 0.36% after. The ring was making writers hand over whole lines and then handing those lines to pl011_write_no_fb, which by design takes no claim, so the drainer put each line on the wire a BYTE at a time with nothing stopping another core writing into the gaps. Whole-line queueing on top of a byte-level free-for-all is not whole-line output.

The claim pl011_write already had inline is now a function, taken by the drainer for its whole pass and by pl011_write_no_fb for its whole message. That moves one unsafe rather than adding a new kind: the clrex this layer already requires.

FileChangeWhy
arch/arm/mod.rs51 -> 52 (+1)claim_serial: a clrex before the claim's compare-exchange. ARMv7 does not guarantee the local exclusive monitor is cleared on exception entry or return, so a core interrupted mid-ldrex/strex can leave it reserved to a foreign address and every later strex fails - permanently, on that core. pl011_write carries the identical instruction directly above, for the identical reason, on the identical silicon; this is that requirement applied to the second acquisition site rather than a new one. clrex clears the local monitor and has no memory effect.

2026-09-03 - the boot log now says which MACHINE it came from (feat/supervisor-owns-images)

GodspeedOS 0.12.0 x86_64 names an ARCH, and an arch is not a machine. Two of the four boards this system runs on report x86_64 - an AMD GX-420GI and an Intel Gemini Lake - and they have already diverged in ways that mattered: the AMD box needs IOMMU passthrough for a firmware DMA quirk, the Intel one has a device with no function 0 and a NIC behind a bridge. Reading a serial log while having to ASK which board produced it is how a fact about one machine becomes a claim about the port, which is a mistake this project has made before and paid for.

So the banner gained a cpu: line, and moved. It used to be printed by bootcon::init, which only runs on a machine that HAS a framebuffer - so a headless boot printed no identity at all, which is the wrong way round for a line whose whole job is to identify a serial log. It is now printed by neutral kernel_main right after arch::imp::init, where serial is up on every arch and the framebuffer floor, if the machine has one, is already mirroring. Unconditional, every arch, every machine.

The buffer is a fixed 64-byte stack array (§26.6.1); a longer name is truncated, never grown.

FileChangeWhy
arch/x86_64/mod.rs37 -> 40 (+3)cpu_identity: three __cpuid calls - leaf 0x80000000 to ask the highest extended leaf supported, leaves 0x80000002-4 for the 48-byte brand string, and leaf 0 for the 12-byte vendor id when the brand string is unavailable. CPUID is unprivileged, side-effect-free, and present on every x86_64 CPU; the extended leaves are read only after the leaf-0x80000000 guard says they exist, which is the documented way to ask. Writes nothing, reads no memory.
arch/aarch64/mod.rs69 -> 70 (+1)cpu_identity: one mrs midr_el1. A side-effect-free system-register read valid at EL1, carrying the implementer in bits [31:24] and the part in [15:4]. Touches no memory.
arch/arm/mod.rs50 -> 51 (+1)cpu_identity: the ARMv7 form of the same read, mrc p15, 0, <Rd>, c0, c0, 0. Same register, same guarantees.

Only the parts actually run on are NAMED (Cortex-A72 for the Pi 4, Cortex-A7 for the Pi 2); anything else prints its raw implementer and part ids rather than a guess, because a confidently wrong name in a log is worse than a number the reader can look up. The x86 side prefers the brand string over the vendor id for the same reason - it names the part, not just who made it.

2026-08-04 - Pi 4 milestone 22: the display (feat/pi4-aarch64)

The Pi has no bootloader-supplied framebuffer descriptor the way x86 does with Limine, so the ARM asks the VideoCore for one through the same property mailbox that reported the board revision and the RAM size at milestone 7 - and in the same caches-off window, for the same reason: the GPU reads the request straight out of RAM, so asking before the caches are on removes the coherency question instead of answering it.

The console itself is arch-neutral and stays unsafe-free. crate::fbcon - the ANSI parser, the UTF-8 decoder, the character grid, glyph rendering, scrolling - is shared with x86 and the 32-bit ARM port. (Superseded 2026-08-17 - see that entry: the module is crate::bootcon and is a boot/panic blit, not a terminal; FB_READBACK_CHEAP is deleted; the terminal is the console service. The description below is what was true when this entry was written.) This arch owes it exactly three things (fb_commit, FB_READBACK_CHEAP, and the framebuffer as a &'static mut [u8]) and gets a full terminal for them. Handing over a slice rather than a base address is what keeps every pixel write in the neutral console bounds-checked: the one unsafe that buys the display lives here in arch/, where the mapping's validity is actually known.

Three checks stand between the GPU's reply and that slice, because a mailbox reply is firmware-supplied input and a plausible-looking wrong answer is the failure mode: the bus-address alias is masked back to an ARM physical address (skipping that yields an address that points nowhere and a display that stays dark with no error); the geometry is range-checked before it drives a fill loop and a slice length; and the framebuffer must lie inside the kernel's direct map AND below mmu::DEVICE_BASE, since everything above that is mapped Device-nGnRnE, which forbids the unaligned accesses the glyph renderer makes. Each failure keeps the machine on serial and says so, rather than mapping something and faulting later somewhere that names neither the GPU nor the map (§26.7).

FileChangeWhy
arch/aarch64/video.rsnew, 2fb_commit's cache clean over the written rectangle (dc cvac by VA - the framebuffer is cacheable and the GPU reads RAM directly, not through the CPU's caches, so it would otherwise scan out stale pixels); and the one slice construction in start_console, over a framebuffer the GPU allocated and owns nothing else in, whose length is its own bounds-checked pitch * height, at a direct-map address checked to be inside the map.
arch/aarch64/mailbox.rs3 -> 4 (+1)property_call: copies an arbitrary tag request into this module's 16-byte-aligned static and the reply back out, length-checked against the buffer at both ends. The static rather than the caller's array because the mailbox packs the channel into the low 4 bits of the address it is handed - an arbitrarily-aligned caller buffer would send the message to the wrong channel, so the alignment guarantee stays in one place.
arch/aarch64/mod.rs53 -> 55 (+2)The write and the read of FB_INFO, the static that carries the geometry across the jump to the high half. A static rather than a local because the jump abandons the low stack along with every local on it - the same reason memmap::current_map exists. Single-threaded boot: written once before the jump, read once after.

2026-08-04 - Pi 4: the serial lock made the machine crawl, and is now try-once (feat/pi4-aarch64)

The console lock below fixed the shredding and broke the machine. Boot went from seconds to 104 seconds, characters trickled out one at a time, and chaos ended in a liveness-watchdog panic.

Two mistakes compounded. The claim spun up to two million times, and it was held across the framebuffer render - glyph drawing plus cache maintenance over a rectangle, far more expensive than the UART write it was protecting. So every core queued behind every other core's rendering, and a core that had spent ten seconds spinning was correctly declared wedged.

Now: one attempt, never a spin. A contended writer emits its bytes anyway and skips only the display mirror - the expensive part, and the part nobody reads during contention. Lines can interleave under load, which is exactly the trade the 32-bit port makes and for exactly this reason. A log line must never make a core wait.

Boot is back to 8 seconds with four cores on every run, and a carnage run survives.

No new unsafe.

2026-08-04 - Pi 4: the serial console had no lock, and four cores made that matter (feat/pi4-aarch64)

Removing PSCI got the board past the release: all three secondaries check in, the machine boots, reboots and reads files back. But it reported three cores where QEMU reported four, and printed no "core N online" lines at all - while QEMU printed every one.

The cores were running. The evidence was not surviving. Four cores wrote a byte at a time into one UART with no serialisation, so lines were shredded into each other at character granularity. Debugging SMP through that means drawing conclusions from an instrument that destroys the measurement, which is worse than having none - it looks like a result.

Three fixes, each of which was necessary and none of which was sufficient alone:

  1. A claim on the log path. Bounded and never fatal: a core that cannot get it writes anyway rather than spinning forever, because this path is reachable from panics and ISRs and a core deadlocked while reporting why is worse than a garbled line.
  2. The same claim on put_str. Locking one writer and not the other leaves exactly the hole it was meant to close - the boot core's own lines were still being shredded by an AP's kprintln.
  3. The line has to BE one write. kprintln! flushes as it formats, so a line assembled from fragments is several writes with gaps between them. The lock was never the missing piece. The report is now rendered into a fixed buffer and emitted once.

Five consecutive boots now report cores in the scheduler: 4 (mask 0xf) intact.

No new unsafe.

2026-08-04 - Pi 4 GENET milestone 3: the receive ring (feat/pi4-aarch64)

Allocates 32 receive buffers of 2 KiB, writes their PHYSICAL addresses into the descriptor area, programs the ring geometry on queue 16, and reads it back. Receive is deliberately left DISABLED.

The split is the point. Every address handed over here is one the MAC will eventually write into, on a board with no IOMMU behind it - so "the values reached the hardware" and "the hardware is running" are separated, and only the first is claimed. The readback is what distinguishes them.

Two details that would each corrupt memory rather than return a wrong value:

  • The descriptor address words are physical. The kernel's own view of that memory is the direct-map alias, and handing a virtual address to a bus master is a device writing to an address that means nothing to it.
  • DMA_RING_BUF_SIZE packs the descriptor COUNT in the upper half and the buffer LENGTH in the lower - two different units in one register, and a swap reads back plausibly.

Ring size is 32 rather than Linux's 256: 64 KiB of pinned memory, a bound visible in the source rather than inferred (§26.6.1).

FileChangeWhy
arch/aarch64/genet.rs3 -> 4 (+1)Recording each buffer's physical address in this module's own array, indexed inside its length during single-threaded boot, so the descriptors and the eventual drain path agree on one answer.

2026-08-04 - Pi 4 GENET milestone 4: transmit ring and the receive drain (feat/pi4-aarch64)

Frames now arrive (the RGMII block had never been enabled, so the MAC could negotiate with the PHY over MDIO and hear nothing from it). This adds the transmit half and the drain that recycles receive descriptors, so the whole path is exercised in a single boot rather than one hypothesis per card swap.

The five new blocks are all the same two shapes, and both are the shapes this file already had:

  • This module's own static mut arrays and cursor (TX_BUFS, TX_NEXT, RX_BUFS), indexed inside their length during single-threaded boot. Identical to the RX_BUFS entry above.
  • One copy through the direct map in transmit: the frame is written to a physical frame handed back by the frame allocator, addressed through KERNEL_VA_BASE + phys, bounded by an explicit length check against RX_BUF_LENGTH before the copy.

Worth recording because it is a portability trap rather than a soundness one: both directions need dma_sync (dc civac). AArch64 DMA is not cache coherent (SEC-28), so a transmitted frame that exists only in this core's cache is sent as stale bytes, and a received frame read without an invalidate is read as whatever was cached before. x86 needs neither and would hide both.

FileChangeWhy
arch/aarch64/genet.rs4 -> 9 (+5)Transmit buffer table and cursor, the receive buffer lookup in the drain, and one bounded copy through the direct map into a DMA frame. Same two shapes as the existing entries; each block carries its own SAFETY comment.

2026-08-05 - Pi 4: BOT reset recovery for a wedged bulk endpoint

A bulk transfer that times out leaves the endpoint HALTED and its transfer ring desynchronised, and re-issuing the command can never clear that: 32 seconds of retries produced 106 identical timeouts, every one asking a stopped endpoint to run. Recovery repairs both layers - xHCI Reset Endpoint + Set TR Dequeue Pointer (the controller half, which the Pi 2's DWC2 driver has no equivalent of), then the Bulk-Only class reset and Clear-Feature HALT on both endpoints (the device half, for the uncollected CSW the device waits on before accepting any new CBW). Keyed on a RUN of timeouts, reset by any success, so a merely-slow device never triggers it.

FileChangeWhy
arch/aarch64/xhci.rs37 -> 38 (+1)Zeroing the two bulk transfer rings before republishing their dequeue pointers - this driver's own DMA pages, 4 KiB each, while it holds the USB claim.

2026-08-05 - Pi 4: kernel-stack guard pages (block splitting)

install_kstack_guards was inert on this arch twice over: the neutral main.rs call site is not on the Pi 4 boot path, and both arch primitives were stubs. So 224 kernel stacks sat end to end with nothing between them, and an overflow corrupted the neighbouring task's stack instead of faulting - the exact failure guard pages exist to make loud.

The high half is mapped with 2 MiB BLOCKS, so a 4 KiB hole cannot simply be cleared: the containing block is SPLIT into an L3 table whose 512 page descriptors reproduce it verbatim (address, memory type, shareability, AP, AF and the execute-never bits all carried across), the L2 entry is repointed, and then the single guard entry is cleared. About thirty guards share each block, so the split happens once per block rather than per guard.

Tables come from a fixed 12-entry arena in .bss, not a heap (§26.6.1) - the pool spans at most nine 2 MiB blocks - and exhaustion is REPORTED, because a guard page that failed to install is indistinguishable from one that worked until something overflows.

Two long-descriptor details worth their comments, both of which read wrong at a glance: 0b11 means TABLE at L1/L2 and PAGE at L3, and the address field is bits [47:21] for a block but [47:12] for a page.

FileChangeWhy
arch/aarch64/mmu.rs17 -> 23 (+6)High-half L2 lookup, split-table arena hand-out, the block split and page clear, the verify read, and TLB maintenance. All page-table work, in the layer where it belongs (§18.1); the neutral caller stays a safe fn.

2026-08-05 - Pi 4: route device IRQs to userspace (Commandment I remediation, step 1)

The Pi 4 kernel contains a USB stack and an Ethernet driver, which expands kernel responsibility and breaks Commandment I. The justification on record - "the arch does not yet route device IRQs to userspace" - turned out to be an UNIMPLEMENTED BRANCH, not a hardware limit: the GIC-400 delivers SPIs perfectly well, the neutral interrupt::route is arch-agnostic and complete, and both ARM arches already carry the stubs it needs. Nobody had connected the two.

One branch in the GIC dispatcher now hands SPIs (ID 32+) to the neutral router, which is the mechanism a userspace driver blocks on. IDs above 255 are retired with a one-shot loud line rather than truncated into the wrong routing slot, because the neutral table is keyed by a u8 vector.

FileChangeWhy
arch/aarch64/exceptions.rs12 -> 13 (+1)interrupt::route::deliver(id) from the IRQ handler, called with interrupts masked - the same contract the x86 caller documents.

2026-08-05 - Pi 4 AUDIT 8 follow-up: reclaim a departed device's DMA pages

dma_page never had a counterpart, so this driver freed nothing. Every unplug/replug cycle leaked the departing device's pages - the keyboard's interrupt ring, EP0 ring and report buffer; the disk's two bulk rings, EP0 ring, data page and command page. Four to six pages per cycle, forever, from an ordinary operator action and precisely the one stand_down exists to handle.

free_dma_page returns one, and stand_down now takes the device by value before dropping it, so nothing can still name the pages when they go back to the allocator.

FileChangeWhy
arch/aarch64/xhci.rs38 -> 42 (+4)One free_frame wrapper, and its use on the disk and keyboard teardown paths - each guarded by the device being taken out of its option first, so no descriptor, ring pointer or endpoint context reaches the page.

2026-08-05 - Pi 4 AUDIT 8: RX buffers cleaned and zeroed before the device owns them

Audit 8 (four parallel auditors) found that GENET hands the controller RX buffers with no cache maintenance at all. alloc_frame neither zeroes nor cleans, and allocator_selftest writes read-back patterns into frames and frees them immediately before this driver probes - so a buffer can arrive carrying DIRTY lines. The device DMAs into that physical memory behind the cache, and the read side's dma_sync is dc civac: clean THEN invalidate. The clean writes the stale line back OVER the frame the controller just delivered. The operation intended to make the DMA visible is the one that corrupts it - intermittently, and invisibly under QEMU.

Zeroing the buffer at ring build closes a second issue in the same line: receive_one trusts the controller's length for how many bytes are MEANINGFUL, so a device reporting more than it wrote would hand a NET_DEVICE holder whatever kernel data previously occupied the frame (the SEC-21 class).

FileChangeWhy
arch/aarch64/genet.rs13 -> 14 (+1)write_bytes zeroing each RX buffer before it is handed to the controller, paired with a dma_sync clean so no dirty line can be written back over delivered data.

2026-08-05 - Pi 4 AUDIT: the barrier class, swept (SEC-25/26/27)

Auditing by DEFECT CLASS rather than by file, after the respawn fault turned out to be one missing barrier. The question asked of every site: where aarch64 writes a page-table descriptor, is it published before something can walk it, and where one is REMOVED, is the translation invalidated before the frame is reused?

Barrier coverage was already correct in mmu::enable, enable_secondary, drop_low_map, invalidate_tlb_page, switch_context, free_page_table_root and the guard-page unmap. The gap was ptables.rs - the file the neutral kernel actually maps and unmaps through - which had no barriers at all. Three findings, all one class:

  1. finalize_service_address_space (previous entry) - a new address space installed before its descriptors were visible. The supervisor-respawn fault.
  2. map_in_root - called by map_in_active_tables against THIS CORE'S LIVE TABLES, so the walker can reach a descriptor that has not become visible. Same defect, tighter window.
  3. unmap - cleared the descriptor and handed the Frame back to the allocator with no barrier and no TLB invalidate. The previous owner can then read and write a frame that belongs to another address space: a use-after-free the MMU actively enables, reported by nothing. x86 does not need it here because its neutral kill path shoots down; that is exactly why the omission reads as complete.
FileChangeWhy
arch/aarch64/ptables.rs20 -> 21 (+1)dsb ishst publishing descriptors in map_in_root, and dsb ishst + tlbi vaae1is + dsb ish + isb in unmap before the frame is released.

2026-08-05 - Pi 4 AUDIT: publish page tables before the walker can reach them (SEC-25/27)

First finding of the aarch64 kernel audit, and the cause of the supervisor-respawn fault that the earlier TLB fix reduced but did not cure.

ptables.rs builds a task's address space with ordinary stores and contains zero barriers - grep finds not one dsb in the file. On x86 that is free, because page-table walks are strongly ordered. On AArch64 the entries are plain memory writes with nothing ordering them against the TTBR0 install that follows, so the hardware walker can observe a descriptor this core has already written as absent, and raise a translation fault for a page that is mapped.

That is the fault signature exactly: ESR 0x82000007, instruction abort on the first fetch of a freshly-spawned supervisor whose 69 pages were all mapped correctly. Boot spawns survive because other work (and its incidental barriers) separates building a space from entering it; a respawn does both back to back, which is why the respawn is the one that dies and why it is intermittent.

2026-08-11 - Pi 2: a panic must halt every core there too (kernel audit A11-2)

FileChangeWhy
arch/arm/mod.rs44 -> 45 (+1)park_core_forever: cpsid if then a wfi loop. halt_all_cores was loop { spin_loop() } - masking nothing and signalling nobody - on a port running four cores on real hardware, so a panic left the other three scheduling and the panicking core taking timer IRQs. Masking IRQ+FIQ and halting is always valid in a privileged mode, and the function never returns, so nothing is left inconsistent. Touches no memory.

2026-08-09 - Pi 4: a panic must halt every core (kernel audit A10-1)

FileChangeWhy
arch/aarch64/mod.rs67 -> 68 (+1)park_core_forever: msr daifset, #0xf then a wfi loop. halt_all_cores was loop { spin_loop() }, which masked nothing and stopped nobody - the panicking core kept taking the timer IRQ and was scheduled away, and the other cores never learned. Masking DAIF and halting at EL1 is always valid, and the function never returns, so no state is left inconsistent by the mask. Touches no memory.
FileChangeWhy
arch/aarch64/mod.rs66 -> 67 (+1)dsb ishst in finalize_service_address_space, publishing a new address space's descriptors inner-shareable before it can be installed or walked. A barrier; touches no memory.

2026-08-05 - Pi 4: invalidate a dead address space's TLB before its frames are recycled

chaos kill-storm supervisor killed the machine on this port: the RESPAWNED supervisor took an instruction abort on its first fetch (ESR 0x82000007, translation fault level 3) and wedged core 0 hard enough that the other three raised the liveness panic. Seven boot spawns were fine; the first respawn died.

switch_context skips installing TTBR0 when the incoming base equals the outgoing one. That is sound only while a TTBR value identifies an address space, and it stops identifying one as soon as root frames are recycled - the allocator hands a just-freed frame straight back, so a service that dies and respawns can get the SAME root physical address with entirely different contents. The switch is elided as a no-op and the new task runs on the dead task's mappings, whose frames have already been reclaimed.

One tlbi vmalle1is in free_page_table_root, before the frames are handed back. Inner-shareable on purpose: the dying task's entries may live in a core other than the one running the kill, and a local vmalle1 would leave them there. This is the SEC-26/SEC-27 obligation in arch/CLAUDE.md - an AArch64 address-space change does not implicitly flush - met at the point where the invariant actually breaks rather than by flushing on every context switch.

FileChangeWhy
arch/aarch64/mod.rs65 -> 66 (+1)TLB maintenance (tlbi vmalle1is) at address-space teardown, so a recycled root frame cannot inherit the dead space's translations.

2026-08-05 - Pi 4 GENET milestone 5: the NET_DEVICE bridge (feat/pi4-aarch64)

Wires the working driver to the arch-neutral net_frame_tx / net_frame_rx / net_info seam, so the unchanged userspace nic-driver and net-stack can drive it. Four new blocks, same shapes again: the station-address store, and one bounded copy OUT of a DMA buffer in receive_one.

One is worth stating explicitly because it is the only place device-supplied data sizes a copy. The frame length in receive_one comes out of a descriptor the controller wrote, so it is untrusted input on a path reachable from userspace via a syscall. It is clamped to BOTH the caller's slice length and RX_BUF_LENGTH before the copy, so a controller reporting a nonsense length can overrun neither the destination nor the source buffer. GENET_READY gates every one of these entry points, so a board where the controller was absent or a ring refused to program cannot be driven into uninitialised registers from userspace at all.

FileChangeWhy
arch/aarch64/genet.rs9 -> 13 (+4)Station-address store (written once before the Release that publishes readiness), the receive buffer lookup, and one copy out of a DMA buffer whose length is device-supplied and clamped to both source and destination before use.

2026-08-04 - Pi 4 GENET milestone 2: the MAC and the MDIO bus (feat/pi4-aarch64)

Resets the UniMAC, programs the station address and frame limit, selects the external gigabit PHY mode, and reads the PHY's identity over MDIO. The data path stays OFF: this brings up the MAC, not the rings, and a receiver enabled with no ring behind it fills a FIFO nobody drains.

The PHY id is the milestone worth reaching. A real id means three separate things are true at once - the MAC is alive, the MDIO bus is clocking, and the PHY is answering - and a later DMA failure would otherwise be blamed for any of them.

Three details that would each produce a plausible-looking wrong result:

  • UMAC_CMD and friends are not in bcmgenet.h. Modern Linux moved the UniMAC registers to a shared unimac.h. Searching the obvious header finds nothing, and the obvious conclusion - that this part has no such register - is wrong.
  • MDIO READ_FAIL must be checked. A read of an absent PHY returns a perfectly plausible 0xFFFF with that bit set; a driver that checks only the data concludes the PHY answered with every capability enabled.
  • The link bit latches low. A single read of the basic status register reports a link that has been up all along as DOWN, so it is read twice.
FileChangeWhy
arch/aarch64/genet.rs1 -> 3 (+2)The register read and write helpers - volatile accesses inside the kernel's Device mapping, to a controller whose presence the milestone-1 probe already established.

2026-08-04 - Pi 4 GENET milestone 1: find the controller and identify it (feat/pi4-aarch64)

The Pi 2 has no Ethernet MAC - its port hangs off a LAN9514 behind the USB hub, which is why that port's networking rides the in-kernel DWC2 stack. The Pi 4 has a real MAC on the SoC (GENET v5 at 0xFD58_0000) with its own DMA rings and an external RGMII PHY over MDIO. None of the Pi 2's network path transfers; only the seam above it does.

This milestone reads SYS_REV_CTRL and reports the revision, and stops there deliberately. It proves the MMIO window decodes before any driver above it can blame its own logic for a dead read - the PCIe bring-up on this board spent four hardware rounds on a window that was silently not forwarding, and every one of them was spent looking at the driver. The revision also selects the register layout: v1..v5 move the DMA rings, so a driver written against v5 offsets on a v3 part reads plausible values from the wrong places.

FileChangeWhy
arch/aarch64/genet.rsnew, 1The revision read, through probe_read32 so an absent controller is reported rather than taken as an external abort that surfaces later blaming something unrelated.

2026-08-07 - GENET's interrupt routed to userspace (feat/pi4-aarch64)

The third route::deliver call in this file, one per interrupt this port hands to a userspace driver, all under the same contract: called from the IRQ handler with interrupts masked. GENET's is registered LEVEL-triggered, so deliver masks the source before handing it over - the driver clears INTRL2_0 and unmasks with the IrqUnmask syscall.

FileChangeWhy
arch/aarch64/exceptions.rs14 -> 15 (+1)route::deliver for GENET's macirq (SPI 157), translated to the neutral vector nic-driver is granted. Same contract as the MSI and generic SPI arms beside it.

2026-08-07 - GIC disable: masking a level-triggered device IRQ for a userspace driver (feat/pi4-aarch64)

A LEVEL-triggered device interrupt keeps its line asserted until the DEVICE's own status register is cleared - and on this port that register lives in MMIO only the userspace driver maps. So the kernel cannot acknowledge it; it can only stop listening until the driver says it has (the IrqUnmask syscall). Without disable, route::deliver returns with the line still high and the interrupt re-enters immediately, which the liveness watchdog turns into a panic.

GICD_ICENABLER is write-1-to-clear, so the write touches only the named interrupt - a read-modify-write would race another core enabling a different one.

FileChangeWhy
arch/aarch64/gic.rs6 -> 7 (+1)disable(id): one volatile write to GICD_ICENABLER, the counterpart of the existing enable. Same Device mapping, same bounds argument.

2026-08-07 - MSI: the USB driver waits on an interrupt instead of a timer (feat/pi4-aarch64)

The Pi 4's xHCI service could only find an event by waking on a timer and looking - the controller's MSIs were masked at PCIe bring-up ("interrupts are not routed yet - the event ring is polled"). That is a floor on input latency no tuning removes, because the wait is not waiting FOR anything.

The BCM2711 raises ONE SPI for all 32 of its MSIs, so the handler is a demultiplexer: read MSI_INTR2_STATUS, clear it, deliver. The one added unsafe is the existing route::deliver call shape, in the arm that handles that SPI - the same call, under the same documented contract (IRQ handler, interrupts masked), as the generic SPI arm two lines below it.

FileChangeWhy
arch/aarch64/exceptions.rs13 -> 14 (+1)route::deliver for the demultiplexed PCIe MSI. Identical contract to the generic SPI delivery beside it: called from the IRQ handler with interrupts masked.

2026-08-06 - the in-kernel GENET driver is DELETED: 14 unsafe lines to 1 (feat/pi4-aarch64)

The largest single reduction in this audit's history, and it is a reduction because the code did not move to a safer place in the kernel - it left the kernel entirely. arch/aarch64/genet.rs was a ~1500-line Ethernet driver (MDIO, UMAC bring-up, PHY clock delays, RX/TX descriptor rings, the address filter, the frame data path) holding 14 audited unsafe lines. It is now 113 lines that read one revision register and write none.

The driver lives in services/nic-driver/src/genet.rs, a restartable userspace service with zero unsafe. That is not a coincidence of style: a service reaches its controller through an MMIO capability and a DMA arena, both handed to it by name at spawn, and the SDK's Mmio/Dma wrappers are the only things that touch a raw address. The unsafe did not get rewritten more carefully - the need for it was removed by putting the driver on the other side of the capability boundary (§18.1, §18.2).

Worth stating plainly because it is the point of the whole exercise: those 14 lines parsed frames that arrive unbidden from anywhere on the network, in ring 0. The new driver parses the same frames in a task that chaos killed 46 times during a carnage run while the machine stayed up.

FileChangeWhy
arch/aarch64/genet.rs14 -> 1 (-13)The driver is deleted. The one line left is the milestone-1 revision read through probe_read32 - discovery, which the kernel owes (it gates the register-window grant), not driving, which it does not.

2026-08-04 - Pi 4 SMP: the PSCI attempt hung the board, and is removed (feat/pi4-aarch64)

Four cores came up on six consecutive QEMU boots and the board hung on the release line. The cause was the PSCI attempt, and the guard around it was the wrong claim.

An smc with no EL3 handler does not return an error - it traps to a vector nobody populated and the machine stops dead, before it can report anything. There is also no way to ask first: a PSCI version query is itself an smc, so probing has exactly the failure it is probing for.

The guard was "we booted at EL2, so something must own EL3". That is not the same claim. The stock Pi 4 armstub passes control at EL2 and implements no PSCI handler at all. QEMU, which hands over at EL3, was the only environment where the guard did the right thing - so the bug was invisible in the one place it was tested, and the fix that made QEMU pass is what made hardware hang.

The spin table is what this firmware implements, what the board's own device tree describes, and it needs no smc to find out. It is now the only mechanism.

No new unsafe (the PSCI smc block is gone; the count is unchanged because the spin-table write and the cache clean it needs were already there).

2026-08-04 - Pi 4 SMP: four cores, on by default (feat/pi4-aarch64)

The last piece was a single missing line, and the 32-bit port had it all along (arch/arm/mod.rs: set_core_lapic_id(core_id, core_id)).

lapic_to_core_id resolves a core by searching the id table and, finding nothing, returns 0. So a core that never registers itself does not fail - it silently answers "I am core 0" and starts using core 0's run queue, scheduler context and per-core state. The fallback is correct for exactly one core, which is why nothing showed until the other three arrived, and why the symptom was an intermittent hang rather than an error.

Six consecutive QEMU boots now reach a shell on four cores, and chaos max-carnage survives 6 rounds / 18 kills with the kernel alive. Enabled by default; the feature remains separate so a single-core image is one flag away if a hardware fault ever needs bisecting against it. (Correction, 2026-09-07: no longer true THROUGH THE BUILD SCRIPT. pi4_build.py --features became ADDITIVE - it prepends pi4,pi4-smp to whatever is passed - so the script cannot produce a build without pi4-smp; that now needs a direct cargo invocation. The --single-core option that did it was removed with the single-core feature (backlog/fixed/02), on the reasoning that one artefact should boot on any core count. The feature itself still exists and is still pinned.)

No new unsafe.

2026-08-04 - Pi 4 SMP: two real bugs found by reading the 32-bit port (feat/pi4-aarch64)

Still gated off and still not reliable (one boot in five reaches a shell), but two genuine defects were found, and the second only because the working port was compared against rather than reasoned from.

The flag the secondaries poll was never cleaned to memory. The boot core stores it through a cacheable mapping; a parked core reads it with translation and caches OFF, straight out of physical memory. Nothing forces the line out, so whether the cores ever started depended on when the cache happened to evict it. The spin-table write two lines away already did this - the same lesson applied to one of the two places it applies to. With the clean, three of three check in on every boot.

get_lapic_id returned a hardcoded 0. The neutral scheduler::current_core_id is built on it, so the moment the other cores came up, every one of them believed it was core 0 - four cores sharing one run queue, one scheduler context, one set of per-core state. With a single core it was indistinguishable from correct, which is why it survived twenty-odd milestones. The 32-bit port has always returned mpidr & 3 there.

Also learned by comparison and worth recording: the 32-bit port runs four cores with both IPI senders still empty stubs. Cross-core IPIs are a latency improvement, not a correctness requirement - every core ticks on its own timer and picks up work then. The SGI path added here is therefore a nicety, and the remaining flakiness is somewhere else.

FileChangeWhy
arch/aarch64/gic.rs5 -> 6 (+1)send_sgi: the distributor's SGI register. Writing it raises an interrupt on the targeted cores and does nothing else.
arch/aarch64/mod.rs64 -> 65 (+1)get_lapic_id: an MPIDR_EL1 read, side-effect free.

2026-08-04 - Pi 4: secondary cores, behind a feature and NOT working yet (feat/pi4-aarch64)

pi4-smp releases the other three cores. It is gated off and it is not finished: one QEMU boot reaches all four cores and a shell, the next hangs at the release. An intermittent race is worse than a missing feature, so the default image is unchanged.

Two findings are worth keeping regardless, because both are traps rather than bugs:

  • An smc is only safe if something still owns EL3. When the firmware hands this kernel control AT EL3 - which is what QEMU does - the kernel performs its own drop and leaves no handler behind, so a PSCI call traps to a vector nobody installed and the machine stops dead with nothing to report it. The entry exception level is now captured in _start (x20, alongside the DTB in x19) because it is the only way to know afterwards, and PSCI is skipped when we were the highest level.
  • A secondary arrives by one of two completely different routes, and which one is not a choice: firmware that parks its cores releases them when the spin table is written, while QEMU delivers every core to _start immediately. Parking them at _start makes them unreachable by the release; both now funnel into one entry that waits for the page tables, so one path gets exercised instead of two that each only work somewhere.
FileChangeWhy
arch/aarch64/smp_boot.rsnew, 9The secondary entry stub (its own exception-level ladder, FP enable, per-core stack selection and the wait for the boot core's tables), the PSCI smc, the spin-table write with the cache clean a core polling with its caches off requires, and the sev pair that wakes them.
arch/aarch64/mmu.rs15 -> 17 (+2)enable_secondary: installs the tables the boot core already built rather than rebuilding them, because a second core writing shared tables while the first runs under them is a race with no upside.
arch/aarch64/mod.rs62 -> 64 (+2)Recording and reading the entry exception level.
arch/aarch64/gic.rs4 -> 5 (+1)init_secondary: a core's GIC CPU interface and priority mask are BANKED per core, so each must enable its own or it never receives its own timer. The distributor is shared and deliberately untouched.

2026-08-04 - Pi 4: auditing the new USB code (feat/pi4-aarch64)

~2,500 lines of new ring-0 driver code that parses untrusted device input had been reviewed only ad-hoc. Two real defects, both of the "works on the device in hand" kind:

A silently truncated block address. The block protocol carries a u64 LBA on purpose, and READ(10)/WRITE(10) name only 32 bits, so lba as u32 WRAPS. Block 0x1_0000_0000 becomes block 0

  • for a write, the superblock overwritten by data meant for the far end of the disk, with every layer reporting success. The stick in hand is 31M sectors and cannot reach it, which is exactly why it had to be refused here rather than left for a larger device to find.

Leaked pages on every failed enumeration. Twelve DMA allocations, ~40 failure paths, and no free_frame anywhere. Hot-plug retries an arrival three times, so a device that refuses to come up leaked steadily as it was replugged - the unbounded-resource shape of §26.6, arrived at by omission rather than design. The three transient pages (input context, control ring, descriptor buffer) now come from a fixed arena allocated once and reused; enumeration is serialised under USB_CLAIM, so there is never a second user. The residual - one device-context page per failed attempt, bounded by the three-attempt cap - is recorded in the code rather than claimed fixed (§26.3).

FileChangeWhy
arch/aarch64/xhci.rs35 -> 37 (+2)The scratch arena's borrow (serialised by USB_CLAIM, so the only live one) and the zeroing of its three pages between devices - a ring left holding the previous device's TRBs would have the controller execute entries it has already run.

2026-08-04 - Pi 4: reboot actually reboots (feat/pi4-aarch64)

hardware_reset was loop { spin_loop() }. The shell printed "rebooting...", the kernel never asked the hardware, and the board had to be power-cycled by hand - which is indistinguishable from a reset that failed.

Ported from the 32-bit implementation, including the part that was learned the hard way there: the boot partition in PM_RSTS must be cleared. The firmware reads that field back after the watchdog fires; left at whatever it held, the SoC resets and then sits dark, producing the same "reboot does nothing" symptom one layer further on. The poke is re-issued rather than spun on, and it is bounded and loud, because "this never returns" is an assumption about hardware.

FileChangeWhy
arch/aarch64/mod.rs61 -> 62 (+1)The BCM2711 power-management registers, reached through the kernel's Device mapping. Volatile 32-bit writes gated by the 0x5A password - the documented reset poke, and the only thing these writes can do.

2026-08-04 - Pi 4: the four hot-plug bugs the hardware found (feat/pi4-aarch64)

Storage and hot-plug both worked at boot and then interacted badly. Four faults, one of them shared:

  1. Unplugging the stick froze the machine. Enumeration was running in the TIMER TICK, and enumerating a device costs ~100 ms of port resets, descriptor fetches and SCSI retries - all with interrupts masked. It moved to the idle loop, which is interruptible.
  2. The keyboard died after a disk transfer. One event ring serves the whole controller, so a disk transfer waiting for its own completion also RECEIVES the keyboard's, and discarded it. pending then stayed set forever and no further report was ever queued.
  3. A disk brought up on one port relabelled the keyboard as being on that port, so unplugging the stick stood the keyboard down and unplugging the keyboard did nothing.
  4. drives reported 0 MiB while the kernel had found a 15 GB stick: block-driver was never granted USB_DISK on aarch64, so the capacity syscall refused with CapNotHeld.

Exclusion is now one claim (USB_CLAIM) across all three drivers of this hardware - tick, syscall, idle - rather than a one-way flag, and holders run with interrupts enabled while everyone else stands aside. The disk answers BUSY, which the block driver already retries.

FileChangeWhy
arch/aarch64/xhci.rs34 -> 35 (+1)hotplug_poll borrowing the controller from the idle loop, under the claim that makes it the only context touching it.

2026-08-04 - Pi 4: USB mass storage (feat/pi4-aarch64)

Storage on the Pi 4 is the USB stick, never the SD card. The board has one SD slot and boots from it - firmware, kernel image, FAT partition - and GSFS puts its superblock at LBA 0, exactly where that card's partition table is. The 32-bit port established this by corrupting two boot cards to RAW before its SD backend was withdrawn; the Pi 4 has the same topology and inherits the rule rather than rediscovering it.

Bulk-Only Transport over the two bulk endpoints, with SCSI TEST UNIT READY / READ CAPACITY(10) / READ(10) / WRITE(10) / SYNCHRONIZE CACHE(10). Above the driver nothing changed: the existing arch::imp::usb_disk_* seam, usbdisk.rs and fs are all arch-neutral and were already waiting for a backend.

The one genuinely new hazard is that the disk is driven from a syscall while the keyboard is driven from the timer tick, and both consume the same event ring. An ISR that pops the disk's completion leaves the block driver waiting forever for an event that has been thrown away. DISK_IO makes the ISR yield for the duration - it costs one keyboard sample. A future SMP port needs a real lock there, and the comment says so.

FileChangeWhy
arch/aarch64/xhci.rs26 -> 34 (+8)Building the 31-byte Command Block Wrapper and reading the 13-byte Command Status Wrapper back (the CSW is placed 64 bytes clear of the CBW so a device that writes status early cannot land it on the command still being read); the READ CAPACITY reply; the block copies in and out of the DMA page, both length-checked against 512 by their callers; the input context for a two-endpoint Configure Endpoint; and the borrow of the controller in with_disk.

2026-08-04 - Pi 4: USB hot-plug (feat/pi4-aarch64)

Everything on this board is behind the internal hub, so a driver that enumerates once at boot leaves a keyboard unplugged mid-session dead until reboot - and, worse, means the keyboard only ever works if the cable happened to be in at power-on. The watcher visits the hub's ports once a second and announces every transition, the same way the x86 and Pi 2 ports do.

Two bounds it is built around. It does not act on a change it could not acknowledge: an event whose latch will not clear is re-reported on every visit, which would tear down a working device once a second forever. And an arrival gets three attempts, not unbounded retries (§26.6) - a device that will not enumerate is left alone until the port changes again. Standing a device down also releases its slot, because a slot is a bounded resource and a leak would run the controller out after thirty cable pulls.

The watcher runs inside the timer tick with interrupts masked, so its transfers use a short deadline (50 ms) while enumeration keeps the generous one (500 ms). A transfer that hangs there does not merely delay the watcher; it stops the scheduler.

FileChangeWhy
arch/aarch64/xhci.rs24 -> 26 (+2)Reading the port status and change words in the watcher, and the keyboard-state static it disarms when a device is stood down. Both are the module's own memory, read immediately after the transfer that filled it.

2026-08-04 - Pi 4: hub enumeration (feat/pi4-aarch64)

The Pi 4's USB-A sockets do not hang off the VL805's root ports. They hang off an internal VIA hub, which the root port reports as a single high-speed device - so a keyboard plugged into the machine is one tier further out than the driver could see, and the bring-up ended at "device is not a HID boot keyboard" while pointing at a hub.

Walking a hub adds three things the root-port path never needed, and leaving any of them out produces an Address Device failure whose completion code points somewhere else: the route string (which downstream port to take at each tier), the transaction translator that a high-speed hub provides so a low or full speed device can be reached across a fast link, and the fact that the device's speed comes from the hub's port status rather than from PORTSC, which sees only the hub.

Only one tier is walked. That covers this board and a keyboard plugged into it; a hub plugged into a hub says so rather than recursing into an unbounded walk of someone's docking station.

FileChangeWhy
arch/aarch64/xhci.rs18 -> 24 (+6)Reading the hub descriptor's port count, the 4-byte port-status replies (one per port, and again while polling a reset), the configuration descriptor's bConfigurationValue, and the device descriptor slice that identifies what was found. Every one is a read of the module's own descriptor buffer immediately after a control transfer filled and synced it; the two that index it do so at fixed offsets inside a 16-byte or 18-byte reply whose length was checked.

2026-08-04 - Pi 4 milestone 23: PCIe and the USB keyboard (feat/pi4-aarch64)

The Pi 2's USB host controller sits on the SoC's peripheral bus at a fixed address. The Pi 4's does not: its USB-A ports are behind a VIA VL805, an off-SoC xHCI controller on the far side of a PCIe Gen2 link. Before one USB register can be read, the root complex has to leave reset, the link has to train, an address window has to be opened onto the bus, config space reached through it, and a BAR assigned inside it. pcie.rs does that and stops - it knows nothing about USB. xhci.rs starts at the BAR and ends where crate::arch::hid begins.

A probe read that tolerates an external abort (uaccess::probe_read32) is what keeps QEMU usable. The root complex is at a fixed address on a real Pi 4 and at nothing under raspi4b, and the difference does not present as a translation fault - the mapping is perfectly valid - but as an external abort from the interconnect, which at EL1 is indistinguishable from a kernel bug and halts the machine. That is the right default and the wrong answer for a probe, whose entire question is "is anything there?". It reuses the user-copy seam's fixup for exactly one load, so a fault anywhere else still halts loudly.

The driver runs in ring 0 and this is a TCB expansion, the same one §6.4's 2026-07-23 amendment already records for ARM: neither ARM port routes device IRQs to userspace, so an in-kernel driver parses untrusted device-supplied descriptors, on a board with no IOMMU to confine its DMA. Recorded rather than papered over (§26.3). Every descriptor walk here is bounded by the length byte and refuses a zero-length entry, because a malformed descriptor from an untrusted device must end the walk, not spin it forever.

FileChangeWhy
arch/aarch64/xhci.rsnew, 18Ring and context construction, and the report buffer. Every one is a write to a page this module allocated from the frame allocator and named through the direct map, whose layout is the xHCI 1.2 structure the controller reads. The two read_volatile/write_volatile register helpers are the controller's BAR, which pcie mapped and assigned. parse_config builds a slice over the descriptor buffer so the walk itself is bounds-checked. The scratchpad pointer-array writes are bounded by an explicit refusal above 512 entries, so a controller asking for more than one page can index is turned away rather than allowed to overrun it.
arch/aarch64/pcie.rsnew, 4The root-complex register read and write helpers (volatile accesses inside the kernel's Device mapping), the presence probe, and the probe read of the first dword through the outbound window - which distinguishes "the CPU address is not decoded to PCIe" (abort) from "it reaches the root complex, which will not forward it" (poison) from "it arrived". Those want three different fixes, and guessing between them cost two hardware iterations.
arch/aarch64/uaccess.rs5 -> 7 (+2)probe_read32: the fixup pointer, and the one-instruction asm! block that arms it, does the load, and clears it on both exit paths.
arch/aarch64/mmu.rs12 -> 15 (+3)dma_sync (dc civac over a range shared with a bus master - the BCM2711's PCIe is not I/O-coherent, SEC-28), and the two fill_device_window writes that build the sparse PCIe outbound-window table.
arch/aarch64/mod.rs59 -> 61 (+2)The write and the read of PCIE_XHCI, the static carrying the discovered controller from the boot probe to the driver.
arch/aarch64/exceptions.rs11 -> 12 (+1)end_probe: dsb sy to complete outstanding accesses, then briefly unmask PSTATE.A so a pending SError is delivered INSIDE the probe window rather than at the next entry to EL0 - which is where the first bring-up's abort actually surfaced, blaming an EL0 selftest 180 ms and one subsystem away from the write that caused it.
arch/aarch64/uart_rx.rs2 -> 3 (+1)push: a keyboard delivering a byte into the ring the console reader already drains. The ring is shared with serial deliberately - the console has one input stream, and a blocked reader should not have to know which device a byte came from.

2026-08-04 - Pi 4: the page-table primitives the chaos run needed (feat/pi4-aarch64)

Four page_tables primitives were still stubs, and the difference between them mattered. Three were quiet stubs (read_page_table_base returning 0, write_page_table_base and invalidate_tlb_page doing nothing); one, map_in_active_tables, was an unimplemented!() - which is not a stub that fails at its caller, it is a kernel panic. The first thing to reach it was a chaos run's memory pressure: a service asking for heap took the whole machine down. Nothing had reached it in twenty-one milestones because no service had grown its heap.

map_in_active_tables maps into whatever TTBR0_EL1 holds, which is the running task's table. That cannot go through a PageTable, whose Drop frees the tree - wrapping the live root in one and letting it fall out of scope would free the address space of the task currently executing. ptables::map_in_root says the intended thing instead: borrow the root, do not own it.

write_page_table_base deliberately performs no TLB maintenance, and the reason is recorded at the primitive rather than left to be rediscovered (SEC-27, arch/CLAUDE.md: every arch::imp primitive owes a documented semantic, not just a signature). On AArch64 a TTBR0_EL1 write flushes nothing, so an address-space change must invalidate - and context::switch_context does, which is the only path that switches between two task spaces. This primitive exists for the shootdown path, which rewrites the value already installed; a flush here would be a second, redundant one on every context switch.

FileChangeWhy
arch/aarch64/mod.rs55 -> 59 (+4)read_page_table_base (an mrs, side-effect-free); write_page_table_base (msr ttbr0_el1 + isb, so the next access is translated by the new table); invalidate_tlb_page (dsb ishst / tlbi vaae1is / dsb ish / isb - by VA, all ASIDs, inner-shareable, because the neutral shootdown path needs it visible machine-wide); and map_in_active_tables forwarding to ptables::map_in_root.
arch/aarch64/ptables.rs19 -> 20 (+1)map_in_root: maps one page into a live root the caller does not own, via ManuallyDrop so the borrow cannot free the running task's address space. Uses the same map path already audited.

2026-08-04 - Pi 4: real services + the diagnostics that found the next bug (feat/pi4-aarch64)

ping and pong are now built and embedded for aarch64, and spawned by name through spawn_service_by_name - the same path the supervisor's spawn syscall takes.

Two real bugs, one in the port and one still open:

pong would not spawn at all: PlacementInvalid. It contracts core 1 and this port has one core, so it needs an explicit override (§14.4) - but the override checks smp::core::is_ready(0), and this port never told the neutral SMP layer that core 0 exists. Nothing else noticed, because every other placement path falls back with .max(1) and lands on core 0 regardless; the STRICT contracted-placement rule is the one path that asks properly, and it correctly refused. mark_ready(0) at boot.

Still open: pong faults at EL0 inside memcpy, storing 8 bytes at exactly 0x8000_0000 - one word past USER_STACK_TOP. The top stack page IS mapped ([0x7FFFF000, 0x80000000)), so this is a genuine overrun rather than a mapping gap.

The trap report gained SP_EL0 and the faulting task's NAME, and both earned their place immediately. Without the name I spent several rounds disassembling ping - the wrong binary - because I had assumed which service faulted. A user-mode fault reported without the task's name makes the reader guess, and a guess about which service faulted is the wrong place to start.

FileChangeWhy
arch/aarch64/exceptions.rs10 -> 11 (+1)Reading SP_EL0 in the trap report - a side-effect-free system-register read. A userspace fault is very often the stack rather than the code, and FAR alone cannot distinguish "wrote past its frame" from "SP was never set correctly".

2026-08-04 - Pi 4 milestone 19: serial input (feat/pi4-aarch64)

Nothing on this port had read input before; output has been one-way since milestone 1, which is why there is still no prompt. The PL011 receive FIFO now drains into a ring the neutral ConsoleRead path pops from, driven both by the timer tick and directly by a blocked reader (a starved ISR would otherwise leave a byte stranded in the FIFO with a reader asleep waiting for it).

The 32-bit port's hard-won lesson is inherited rather than rediscovered: the PL011 reports receive errors in the SAME read as the data, in DR bits 11:8. Masking them off - the obvious thing to write - silently promotes line noise to input. On the Pi 2 a GPIO HAT held RX low, which the PL011 reports as a continuous break; each one enqueued a spurious 0x00, the ring filled with nulls, and a full-screen editor blocked on ConsoleRead repainted 966 times while the document changed twice. Flagged bytes are discarded here from the outset.

A persistently overflowing line is switched off (RXE cleared, output untouched, latching until reboot) after a duration of unbroken overflow rather than a byte count - a count is not a measure of how long a condition has persisted, and choosing one is how you get a threshold that either fires on a fast typist or never fires at all.

Proven by feeding characters in: gsh test 123 echoed back, 12 of 12, in order, with the logger service still running alongside.

FileChangeWhy
arch/aarch64/uart_rx.rsnew, 2The bounded FIFO drain (volatile PL011 reads plus the error-clear write) and the ring pop, whose slot the producer published with a Release store.

2026-08-04 - Pi 4: the SAME cache bug, in the loader this time (feat/pi4-aarch64)

The fix below was applied to the two hand-written payload copies and not to the ELF loader - even though its own commit message said the loader would need it. Fixing the instances rather than the class left it open where it mattered most.

On hardware, the logger's text landed in frames the one-shot EL0 demo had just executed from and freed. The I-cache still held the old blob, so the "logger" ran the DEMO's code, hit the demo's exit syscall, switched to a CTX_KERNEL saved during boot, and resumed execution in the middle of boot_high - which re-ran the rest of the boot. It looped until the endpoint table filled, 95 task slots later.

Two fixes, because the bug had a cause and an amplifier:

  • Cause: finalize_service_address_space - the arch hook the neutral loader already calls for every service - now syncs the I-cache over every page of the new address space. Applying it at the hook covers every service the loader will ever produce, rather than the ones someone remembered.
  • Amplifier: CTX_KERNEL was a resurrection point. The exit syscall now refuses when the demo is not running, so the same class of mistake costs one loud refusal instead of a boot loop.

The hook reports how many pages it synced, because QEMU models no separate I-cache and cannot demonstrate the fix - the only evidence available in emulation is that the hook ran and did work. A silent hook is exactly how this shipped once already.

FileChangeWhy
arch/aarch64/ptables.rs19 -> 21 (+2)sync_all_pages walks the new address space and syncs each leaf through the kernel's direct map (cache maintenance is by physical line, so the task's own VAs need not be mapped).
arch/aarch64/mod.rs51 -> 53 (+2)finalize_service_address_space calling it, and the count it reports.
arch/aarch64/usermode.rs14 -> 16 (+2)The DEMO_ACTIVE guard around the exit switch, and setting/clearing it around the demo.

2026-08-04 - Pi 4: writing code needs cache maintenance (feat/pi4-aarch64)

Found on hardware, on the first boot of milestone 17, and structurally invisible under emulation.

The instruction and data caches are not coherent on ARM. A store lands in the D-cache while the I-cache may still hold whatever was previously at that physical address. The kernel writes code every time it loads a program, so this is not a demo concern - the ELF loader will hit it the moment it loads a service's text.

What happened: a task payload was copied into a frame the previous EL0 task had just executed from and freed. The allocator handed the same frame straight back, the I-cache still held the old blob, and the core executed the previous task's instructions until it ran off the end into an Illegal Execution State (ESR EC 0b001110, PSTATE.IL set). The give-away in the log was the scheduled task printing the one-shot demo's messages. QEMU models no separate I-cache, so it had passed there a dozen times.

sync_instruction_cache cleans the range out of the D-cache to the point of unification and invalidates the I-cache over it. Line sizes come from CTR_EL0 rather than being assumed - DminLine and IminLine differ between implementations, and a hardcoded 64 either does needless work or, if too large, skips lines.

FileChangeWhy
arch/aarch64/mmu.rs11 -> 12 (+1)sync_instruction_cache: reads CTR_EL0 for the line sizes, then dc cvau / ic ivau over the range with the dsb/isb that order them against the fetch which follows. Cache maintenance by VA alters no architectural state beyond the caches.

2026-08-03 - Pi 4 milestone 17: an EL0 task under the neutral scheduler (feat/pi4-aarch64)

Every earlier EL0 excursion was a one-shot: the boot dropped to EL0, the task ran, control came back. This is an EL0 task the scheduler enters, the timer preempts, and other tasks share a core with.

TaskContext::new_user bridges a real mismatch: switch_context ends in ret and stays at EL1, while entering EL0 needs an eret. So the context points at a trampoline that performs the eret, carrying the user entry and stack in x19/x20. x86 solves the same mismatch the same way, differing only in where the values ride. The context's sp is the task's KERNEL stack, and that is load-bearing beyond first entry: after the eret it stays in SP_EL1, so it is the stack every later trap from this task lands on.

The bug this found had been sitting there for four milestones. syscall_slot returned core::ptr::null_mut(), and prepare_ring3_switch - which runs only for tasks marked is_user - writes through it. Nothing had ever been marked user, so the stub was unreachable and invisible; the first scheduled EL0 task turned it into a null write on the context-switch path. A stub reachable down exactly one path stays silent until something takes that path, which is why the crash arrived a whole milestone after the stub was written. It is now a fixed array (not the per-core arena, for the same reason uaccess uses one: the context-switch path must not depend on an allocation having happened), with a comment saying plainly that AArch64 does not consult these fields - the hardware selects SP_EL1 architecturally - but the neutral scheduler maintains them, so they need real storage.

FileChangeWhy
arch/aarch64/sched_user.rsnew, 4Copying the position-independent EL0 payload into its frame, and building + committing the task (its kernel stack is a module-owned static; the slot is freshly reserved; the user pages are mapped with EL0 access in the table the switch installs).
arch/aarch64/mod.rs50 -> 51 (+1)syscall_slot handing out a pointer into its fixed per-core array.

2026-08-03 - Pi 4 milestone 16: real syscall dispatch (feat/pi4-aarch64)

svc now reaches the neutral syscall::dispatch::syscall_handler - the same function x86 and arm32 call, with the same numbering. Bring-up numbers live above every real one (>= 0x1000) so the two ranges cannot collide while both exist, and the whole bring-up range disappears with the demo.

The neutral subsystems (per-core arenas, scheduler slots, capability table) moved into the main boot rather than only the scheduler demo, because a real syscall reaches current_task_lookup_cap, which indexes per-core state - and reaching that before it exists is exactly how the user-copy seam went silent in milestone 15.

What the EL0 task now proves, from real userspace through the real ABI:

  • Log with no capability is REFUSED (-2, CapNotHeld). §3.1 enforced end to end on AArch64: authority comes from holding a capability, never from being the caller.
  • An unknown syscall number returns a defined error (-1), not a fault (§22 Fuzz F2).

The unknown-number test needed fixing after it appeared to pass: 0xBEEF is above the bring-up base, so the call went to the demo handler and proved nothing about the neutral path. 0x0FFF reaches the dispatcher. The log's WARN unknown svc #48879 is what gave it away.

FileChangeWhy
arch/aarch64/exceptions.rs9 -> 10 (+1)Calling the neutral syscall_handler - the ring-3 to ring-0 transition it is written for; it treats every argument as untrusted and validates pointers through the user-copy seam.
arch/aarch64/usermode.rs13 -> 14 (+1)Recording the dispatcher's verdicts in a module-owned static so run can check both.

2026-08-03 - Pi 4 milestone 15: the user-pointer copy seam (feat/pi4-aarch64)

Where the kernel touches memory a task chose the address of. Three defences, because each catches what the others cannot:

  1. A range check - pointer and length inside the user half, addition checked for overflow.
  2. ldtrb / sttrb, not ldrb / strb - the UNPRIVILEGED load and store. Executed at EL1 they apply EL0 permissions, so the hardware refuses a kernel address even if the range check were wrong. Defence in depth at no cost: same instruction count, same speed, and a bug in check (1) stops being exploitable.
  3. A fault fixup - a range-valid pointer can still be unmapped, and the abort lands at EL1 looking exactly like a kernel bug. Unguarded, that halts the machine: a denial of service any service could trigger by passing a bad pointer. Vector 4 (same-EL synchronous) became recoverable, and the copy helpers register a recovery address around only the faulting instruction - so a kernel bug faulting anywhere else still halts loudly, which is what makes this safe rather than a blanket "ignore kernel faults".

Reads are copied into a per-core staging buffer rather than borrowed: handing the kernel a pointer into user memory leaves every later read racing the task, which can change the bytes between validation and use.

The bug this cost is the one worth keeping. The per-core state was first indexed via current_core_id(), which needs tables that are not up when the first copy runs - and, far worse, the FAULT HANDLER calls into this module. Indexing an unallocated arena from a fault handler faults again, and the second fault happens while reporting the first, so the machine went completely silent with nothing printed. A fault handler must not depend on initialisation order. It now indexes a fixed array by MPIDR_EL1.Aff0, which needs no setup and cannot fail.

The selftest drives all four outcomes including the two that only fire on bad input, and counts recovered faults so "the unmapped pointer survived" is backed by evidence the fixup FIRED rather than by something upstream having quietly rejected the pointer.

FileChangeWhy
arch/aarch64/uaccess.rsnew, 5core_index (an MPIDR_EL1 read), the two asm! copy loops (unprivileged accesses plus fixup arm/disarm), the staging-buffer pointer, and the slice built over the copied bytes.
arch/aarch64/exceptions.rs7 -> 9 (+2)aarch64_sync_current_dispatch: reading ESR_EL1 and rewriting the trap frame's elr to the recovery address.
arch/aarch64/usermode.rs11 -> 13 (+2)Installing the task's TTBR0 around the seam selftest, so it runs against a REAL user page - the only way to test checks that are all about an address the kernel does not own.

2026-08-03 - Pi 4 milestone 14: ptables becomes the real page_tables::PageTable (feat/pi4-aarch64)

loader.rs calls arch::imp::page_tables::PageTable::new(), which was still the unimplemented!() stub - so no service ELF could be loaded no matter what else worked. The hardware-proven implementation is now wired in behind the neutral signature (map(VirtAddr, PhysAddr, PageFlags)), and the arch-native form is kept as map_raw for the EL0 bring-up path.

The flag translation has one decision worth naming: PCD | PWT. On x86 those disable caching for an MMIO mapping; the faithful AArch64 equivalent is not "uncached Normal" but the Device attribute, which additionally forbids the reordering, merging and speculative repetition that make a wrongly-typed MMIO mapping misbehave in ways no fault points at.

Two hooks that were empty stubs now have bodies, and one is deliberately still empty: free_page_table_root frees the table tree; reclaim_user_frames frees the leaf pages (an empty stub there would not fail loudly - it would leak every page of every task that ever died, surfacing only as the machine slowly running out of memory); and finalize_service_address_space is genuinely a no-op, because unlike the 32-bit port there is no kernel map to clone into a new space.

Reclaim is proven, not assumed: the selftest now maps extra pages, hands the space to reclaim_user_frames, and checks both the count returned and the allocator's free total - 4 pages reclaimed, all frames returned.

FileChangeWhy
arch/aarch64/mod.rs45 -> 50 (+5)free_page_table_root and reclaim_user_frames forwarding to ptables, plus the selftest's reclaim exercise (mapping extra pages and calling reclaim on the space).
arch/aarch64/ptables.rs19 (net 0)reclaim_pages walks the tree and frees each leaf, using the same get/set accessors already audited; the kernel-copy loop it replaces was the same count.

2026-08-03 - Pi 4 milestone 12: an EL0 task in its own address space (feat/pi4-aarch64)

The payoff of the TTBR1 split, and the first place the separation is enforced by hardware rather than by leaving the right bits clear. PageTable::new now allocates one frame and copies nothing: a task table holds the task and nothing else, because the kernel is in TTBR1 where no TTBR0 switch can disturb it and no EL0 access can reach it. That deleted the 20 KiB kernel-copy per address space AND the collision class it carried.

The task is frames, not linker sections: a code page and a stack page from the allocator, with a position-independent payload copied in. Position-independent because it is linked into the kernel at a high address and executed at a low one - trivially so, being register operations and svc with no memory reference.

Net unsafe is unchanged: what usermode.rs gained (building the space, copying the payload, installing TTBR0 before the eret) is offset by what ptables.rs lost with the kernel-copy loop.

Page 0 is now reserved. The allocator handed out physical frame 0 as a page-table root, which printed TTBR0=0x0 - working, but by coincidence, and colliding with the cr3 == 0 sentinel switch_context uses for "no address space". A null physical address must never be a valid allocation.

2026-08-03 - Pi 4 milestone 11b: the kernel moves to the high half (feat/pi4-aarch64)

The kernel is now LINKED high and LOADED low (VMA KERNEL_VA + 0x80000, LMA 0x80000), relocates itself into TTBR1 early in boot, and then retires the low identity map. TTBR0_EL1 is empty from that point on, which removes the collision milestone 10 documented: a task page below 4 GiB can no longer shadow the kernel, because the kernel is not there.

Three things make the transition safe, and they are all in the code rather than in anyone's head:

  • _start reaches symbols only through adrp/add, which is purely PC-relative, so the same instruction yields the right LOW address while the MMU is off and the right HIGH one afterwards.
  • Between enable and the jump, BOTH halves translate. There is no instant at which the core executes from an address that does not resolve.
  • Peripherals move first (mmio_go_high), because a device register is still named by its physical address and the UART must survive the step or the next failure reports nothing.

Two mistakes here are worth keeping. The relocation asm used a hardcoded x9 as scratch while letting the compiler allocate {base} - and it chose x9 too, so mov x9, sp destroyed the base and orr x9, x9, x9 left SP unchanged. The kernel then ran on happily from its still-mapped low stack and only died when the low map was retired, whereupon the handler faulted on its own push and recursed, walking FAR down by one 272-byte frame per iteration. Nothing pointed at the asm; reading SP back and printing it settled it in one run, and the boot now reports SP permanently for that reason. Separately, the high-half selftest initially compared an address against itself (it took the symbol's address as the "high" side, but pre-jump codegen yields a LOW address) - it passed while testing nothing.

FileChangeWhy
arch/aarch64/mmu.rs5 -> 11 (+6)virt_to_phys (linker symbols are high; anything the HARDWARE consumes must be physical); the high-half table build; jump_high (reads SP, then relocates SP and PC together); running_high (reads the PC rather than assuming); drop_low_map (msr ttbr0_el1, xzr + invalidate); and the corrected selftest.
arch/aarch64/mod.rs42 -> 45 (+3)The mmio() indirection now used by every PL011/GPIO access, and the SP read-back that reports the stack relocated.
arch/aarch64/memmap.rs7 -> 8 (+1)current_map, which re-derives the map after the jump abandons the low stack along with every local on it.

2026-08-03 - Pi 4 milestone 11a: the TTBR1 high half goes live (feat/pi4-aarch64)

Stage one of moving the kernel out of TTBR0. The high half (TTBR1_EL1) now maps physical P at KERNEL_VA_BASE + P - a direct map, the same thing Limine's HHDM is on x86 - alongside the existing low identity map. Nothing has moved yet; this proves the mapping works before anything is built on it.

KERNEL_VA_BASE is chosen so >> 30 & 511 == 0, which makes the high L1's entry i cover the same GiB as the low L1's entry i, so both maps are built by the same loop rather than by two subtly different ones.

TG1 does not share TG0's encoding. TG0 spells 4 KiB as 0b00; TG1 spells it 0b10. Copying the TG0 value across selects a 16 KiB granule, every high walk fails, and the fault points at whatever first touched a high address rather than at the register. The selftest exists to catch exactly that class at the point of the mistake.

FileChangeWhy
arch/aarch64/mmu.rs4 -> 5 (+1)selftest_high_half writes through the physical (identity) address and reads back through the high alias, then reverses the direction so it cannot pass on a stale line that happened to match. The high tables themselves are built inside the existing enable block, so they add no new unsafe.
arch/aarch64/mod.rs41 -> 42 (+1)Freeing the scratch frame the selftest borrowed from the allocator.

2026-08-03 - Pi 4 milestone 10: per-task page tables (feat/pi4-aarch64)

An address space is a private L1 plus its own copies of the four kernel L2 tables. The alternative

  • pointing each task's L1 at the shared kernel L2s, as the 32-bit port does - saves 20 KiB and buys an aliasing hazard: a table split in one address space is seen by all of them, and reclaim then has to distinguish a table the task owns from one it merely points at. Copying means nothing is aliased, so reclaim frees everything the root reaches without a single ownership test.

This milestone also surfaced a real architectural limit, and the code says so rather than working around it. A task page below 4 GiB would land inside a 2 MiB block of the kernel's identity map, and mapping it would shadow the kernel's own view of that physical range - USER_STACK_TOP (0x8000_0000) sits directly above the frame allocator's bitmap. map refuses that case loudly instead of splitting the block, because the honest fix is to stop putting the kernel in TTBR0 at all: the kernel belongs in TTBR1 (high VA), leaving TTBR0 entirely to the task. The speculative block-splitting code that would have papered over it was written and then deleted (§26.2 - features are pulled into existence, not kept in case).

The selftest exercises the TTBR0_EL1 swap that milestone 9 shipped unexercised: build a space, map a page above the kernel map, install the new TTBR0, read the value back through the new mapping, confirm kernel memory is still reachable under the task's table, switch back, and check every frame returned. Proven to fail by inverting the value comparison (mapped=false, every other counter clean), and the map failed path fired on its own during development.

FileChangeWhy
arch/aarch64/ptables.rsnew, 19Page-table walking and construction: get/set on identity-mapped table frames, zeroing a freshly allocated table (garbage would have VALID bits set at random and the walker follows them), copying the kernel L2s, and free_all reclaiming the tree. Every one is a raw access to a frame this module allocated or to the kernel's own boot L1.
arch/aarch64/mmu.rs3 -> 4 (+1)kernel_l1_root takes the address of this module's static L1 - no dereference. Per-task spaces copy from THIS, never from the live TTBR0_EL1, which belongs to whichever task is running; the 32-bit port copied the live root and gave a child its spawner's user entries, which the child's reclaim later freed out from under the still-running spawner.
arch/aarch64/mod.rs33 -> 41 (+8)The page-table selftest: writing and reading the test frame and the kernel canary through identity addresses, reading TTBR0_EL1, the two msr ttbr0_el1 + tlbi vmalle1 switches, and freeing the two frames. The switch is safe because the new space maps the kernel (copied from the boot L1), so the instruction stream, stack and UART stay translated across it.

2026-08-03 - Pi 4 milestone 9: the neutral scheduler preempts (feat/pi4-aarch64)

Three kernel tasks that deliberately never yield, round-robined by the neutral scheduler::run under the 100 Hz generic timer. The scheduling decision is the same neutral code x86 runs; what this commit adds is the arch side it stands on.

The trampoline is the load-bearing piece. The scheduler masks IRQs before its initial switch_context, so a task whose lr pointed straight at its entry begins running with DAIF.I set and - never yielding - never has it cleared. Observed exactly: task A ran correctly and forever while B and C starved, which reads as a broken scheduler when in fact it was never given a tick. x86 solves it the same way (task_entry_trampoline does sti then ret); this port carries the entry in x19 rather than on the stack, because ret here jumps to lr rather than popping.

The GIC EOI had to move before the switch, not after. The neutral tick performs a preemptive switch_context internally and may not return on this task's stack at all. The GIC CPU interface keeps a priority active until the interrupt is retired, so an EOI deferred past the switch leaves the timer interrupt permanently active and blocks every later interrupt of equal or lower priority - one tick, then silence, with the scheduler looking like the culprit.

FileChangeWhy
arch/aarch64/context.rs2 -> 9 (+7)The TTBR0_EL1 read/write/invalidate in switch_context (SEC-26: writing TTBR0 flushes nothing on AArch64, so the switch must invalidate on an address-space change - route (a) from arch/CLAUDE.md); the entry-trampoline global_asm! and its extern; and new_user, which is a loud unimplemented! rather than a context that would ret into a user address at EL1. Note the TTBR0 branch is NOT yet exercised - every task shares the kernel identity map - and that is stated in the code rather than assumed.
arch/aarch64/mod.rs28 -> 33 (+5)Real bodies for primitives the neutral scheduler depends on, previously no-op stubs: enable/disable_interrupts (msr daifclr/daifset, #2 - note DAIF is a MASK, so the polarity is inverted from x86's IF), local_irq_save (reads DAIF), wait_for_interrupt (wfi), and read_cycle_counter (CNTPCT_EL0, with an isb first so the read is not speculated earlier and a measured duration does not come out short).
arch/aarch64/sched_demo.rsnew, 5Demo-only: three 64 KiB .bss stacks this module owns, built into task contexts and committed to freshly reserved scheduler slots during single-threaded boot.

2026-08-03 - Pi 4 milestone 8: the neutral frame allocator (feat/pi4-aarch64)

The first arch-neutral kernel code to run on this board. crate::memory::init is the same code the x86 build has used since v1, reached through the BootInfo the seam defines - the demarcation claim tested on a second ISA in the place it is easiest to get wrong.

The unsafe added is a selftest, not plumbing. memory::init printing a free count shows the map parsed; it does not show that a returned frame is real, distinct, writable, or reusable. The selftest writes every frame with a value derived from its own physical address, then verifies every frame in a SECOND pass. The two passes are the point: write-then-read each frame in turn cannot detect ALIASING, because two distinct physical addresses backed by the same RAM would each read back correctly the instant after being written. A memory map claiming RAM the board does not have shows up as address wrap or aliasing on real silicon far more often than as a fault, so this is the failure the port is actually exposed to. Proven to fire by corrupting one frame: 1 bad read-back, correct index counted, every other counter clean.

FileChangeWhy
arch/aarch64/mod.rs27 -> 28 (+1)The allocator selftest's frame writes and read-backs, plus the free_frame calls that return them. The frames come from alloc_frame and the low 4 GiB is identity mapped as Normal writable memory, so a physical address is a valid pointer and nothing else holds these frames. Also in this commit, at no cost to the count: serial_write_byte now routes through put_byte (which polls TXFF) instead of writing the data register directly - it previously dropped bytes the moment the 32-entry FIFO filled, which nothing had noticed because nothing called it until neutral code began logging.

2026-08-03 - Pi 4 milestone 7: the memory map (feat/pi4-aarch64)

The board does not tell the ARM how much RAM it has unless asked. Two sources are read, both before the MMU and caches come on: the device tree the firmware passes in x0 (authoritative), and the mailbox GET ARM MEMORY tag (a fallback that cannot describe RAM above 4 GiB and, on the usual firmware configuration, under-reports a board with more than 1 GiB).

Both are untrusted firmware input, and the unsafe here is dominated by that fact rather than by the hardware access. Every device-tree offset is bounds-checked against the header's own totalsize before it is read, the structure walk is iteration-bounded, and anything that fails to parse yields None rather than a partially-believed map. That asymmetry is deliberate: a map that is wrong in the safe direction costs capacity, while one that is wrong in the unsafe direction hands the allocator RAM that does not exist and surfaces much later as corruption.

_start also gained one instruction, and it closes a real hole: the firmware hands over the device-tree pointer in x0, and the first instruction of the EL2 check clobbered x0. The pointer was being thrown away before anything could read it. It is now stashed in x19 (which survives the eret) and stored after .bss is zeroed.

FileChangeWhy
arch/aarch64/mailbox.rsnew, 3(1) The mailbox handshake: volatile reads of MBOX_STATUS/MBOX_READ and a write to MBOX_WRITE, all identity-mapped Device memory, both waits bounded so a silent GPU cannot hang the boot (invariant 12). (2)+(3) Staging the request in a module-owned 16-byte-aligned static and reading the reply back, during single-threaded boot with caches off - which is the point: the GPU reads the buffer out of RAM directly, so running before mmu::enable removes the cache-maintenance question rather than answering it.
arch/aarch64/memmap.rsnew, 7(1) Reading the linker's __kernel_end - taking a symbol's address does not dereference it. (2)-(4) The three bounds-checked device-tree accessors (u32_at, u8_at, and the header/magic read in open); each refuses any offset outside totalsize BEFORE reading, so a malformed blob produces None rather than a wild read. (5)+(6) Building the region array in a module-owned .bss static during single-threaded boot. (7) Handing that array out as a &'static [MemoryRegion] - the storage is static and the slice is read-only and never rebuilt.
arch/aarch64/mod.rs27 -> 27 (net 0)The _start change is inside the existing naked_asm! block, so the count is unchanged: mov x19, x0 before the EL2 check preserves the device-tree pointer, and mov x0, x19 hands it to Rust.

2026-08-03 - Pi 4: EL0 gets its own 2 MiB region (feat/pi4-aarch64)

Follow-up to milestone 6, after two hardware failures traced to one cause.

In the EL1&0 translation regime, a region accessible from EL0 is forced PXN - the kernel may not execute what userspace can reach (ARM's equivalent of x86 SMEP). Granting EL0 access to the block holding the kernel's .text therefore made the kernel non-executable at EL1, and the core died on its next instruction fetch with no way to report it: the exception handler could not fetch its vector either. It presented first as tlbi vmalle1 hanging and then as the MMU enable hanging - the same permission fault landing wherever the new mapping took effect. The tlbi was never at fault, and the earlier "suspect CPUECTLR_EL1.SMPEN" note was wrong.

EL0 code and stack now live in a linker-placed, 2 MiB-aligned .el0 region, and only that region is granted EL0 access. The EL0 task consequently cannot call kernel print functions at all, so it reports its verdict through a syscall argument - the first place the EL0/EL1 boundary is real rather than notional.

FileChangeWhy
arch/aarch64/mmu.rs2 -> 3 (+1)el0_region reads the linker's __el0_start/__el0_end; taking a linker symbol's address does not dereference it.
arch/aarch64/usermode.rs9 -> 11 (+2)The svc #2 verdict call from EL0, and the EL0 stack now living in .el0.data.

2026-08-03 - Pi 4 milestone 6: EL0 and the svc path (feat/pi4-aarch64)

Dropping to EL0, taking svc back to EL1, returning a value, and exiting cleanly.

The syscall number is read from ESR_EL1's imm16, not from a register, so userspace cannot claim a different call by clobbering a register on the way in. Arguments still arrive in registers and are untrusted exactly as §18 requires. The demo checks the round trip in both directions rather than only that it did not crash.

FileChangeWhy
arch/aarch64/usermode.rsnew, 9The EL0 entry (msr sp_el0 / elr_el1 / spsr_el1 then eret - all three must be set before the eret; an unset SP_EL0 faults on the first push and points at the user code rather than the entry), the demo task's svc calls, the exit path's context switch back to the saved kernel context, module-owned static setup, and one unreachable wfe park.
arch/aarch64/exceptions.rs5 -> 7 (+2)The synchronous-lower-EL dispatcher: mrs esr_el1 to read the exception class and imm16, and dereferencing the trap frame the vector assembly built on the current stack.
arch/aarch64/mmu.rs2 -> 3 -> 2 (net 0)allow_el0 was added here and then REMOVED: it patched AP on the live map and invalidated the TLB, and on hardware the tlbi never returned. EL0 access is now decided when the tables are built, before translation is on, so there is no live mutation and no maintenance. Net effect on the count is nil.

2026-08-03 - Pi 4 milestone 5: context switch (feat/pi4-aarch64)

Saving and restoring the AAPCS64 callee-saved set, proven by two kernel tasks ping-ponging.

The context switch is the first piece here that can corrupt state silently: a wrong byte offset or a dropped register does not fault, it resumes a task holding someone else's value and the damage surfaces later somewhere unrelated. So it is proven on its own, and the demo checks rather than merely runs - each task holds witnesses in callee-saved integer and FP registers across the switch and reports a mismatch. A test that cannot fail is worth nothing (A9-1).

d8-d15 are saved even though skipping them would look harmless: the kernel is built for a target with FP/SIMD, LLVM emits NEON for bulk copies without being asked (the Pi 2 hit this with memcpy), and omitting them yields corruption only when a switch lands between a NEON spill and its reload.

FileChangeWhy
arch/aarch64/context.rsnew, 2(1) global_asm! for the switch itself - a context switch cannot be expressed in Rust. (2) The switch wrapper is unsafe fn and calls the extern; its contract names what the caller owes (valid non-aliasing contexts, next either previously saved or prepared by init).
arch/aarch64/ctxdemo.rsnew, 7Demo-only. Six are switch calls plus context/stack setup on statics this module owns during single-threaded boot; one is the wfe park on an unreachable path. The statics are TaskContexts and two 16 KiB .bss stacks, sized once and visible (§26.6.1).

2026-08-03 - Pi 4 milestone 4: GIC-400 + generic timer (feat/pi4-aarch64)

A periodic tick: the standard GICv2 the Pi 4 has (unlike the Pi 2's bespoke Broadcom controller, this is spec-driven and transfers to any GICv2 board) plus the architectural generic timer. The IRQ vectors gained a RETURN path - previously every vector reported and halted, which for a tick is not a tick.

Two disciplines carried over from the 32-bit port's scars: the counter frequency is read from CNTFRQ_EL0, never assumed (a hardcoded guess made every sleep on that board wrong by orders of magnitude and hid for months), and the wait for the first ticks is bounded so a timer that never fires reports instead of hanging the boot looking busy.

FileChangeWhy
arch/aarch64/gic.rsnew, 4Four Device-mapped MMIO accessors: init (quiesce, priority mask, enable distributor + CPU interface), enable (priority, target, set-enable bit), acknowledge (GICC_IAR - a read WITH the architectural side effect of acknowledging), eoi (GICC_EOIR). Each is a single volatile access to a register the GICv2 spec defines.
arch/aarch64/timer.rsnew, 5System-register access only: CNTFRQ_EL0 and CNTPCT_EL0 reads (side-effect-free), CNTP_TVAL_EL0/CNTP_CTL_EL0 writes to arm and disable, and msr daifclr, #2 to unmask IRQs at EL1. EL2 granted EL1 access to the physical timer during the boot drop (CNTHCTL_EL2), so none of these trap.

2026-08-03 - Pi 4 milestone 3: exception vectors (feat/pi4-aarch64)

VBAR_EL1 and a 16-entry vector table. This comes before the timer or the GIC because until it exists a fault is a silent lockup: the core takes the exception and branches into whatever sits at the reset vector. Every milestone after this adds code that can fault, so this is what makes the rest debuggable (invariant 12 applied to the port itself).

The table is proven by raising a real brk #0, not by asserting the register took. A self-test that cannot fail is worth nothing - the lesson from A9-1, where an odd sentinel let a test pass without the fix it was meant to prove.

FileChangeWhy
arch/aarch64/exceptions.rsnew, 5(1) global_asm! for the 2 KiB-aligned table and the shared save-and-report tail - assembly is the only way to express a vector table. (2) msr vbar_el1 in init, which only changes where exceptions are taken to. (3) mrs vbar_el1 in installed, side-effect-free. (4) mrs esr_el1/far_el1 in the reporter, likewise. (5) dereferencing the trap frame the vector assembly just built on the current stack - valid for the call, unaliased.
arch/aarch64/mod.rs27 -> 28 (+1)The deliberate brk #0 that proves the vectors fire. It never returns: with the table installed it is taken to the synchronous handler, which reports and halts.

2026-08-03 - Pi 4 milestone 2: the MMU (feat/pi4-aarch64)

Identity-mapping the low 4 GiB and turning translation on. 4 KiB granule, 39-bit VA (so the walk starts at L1 and there is no L0 to carry), 2 MiB blocks at L2 - one L1 plus four L2 tables, exactly 20 KiB of .bss, a fixed and visible footprint (§26.6.1).

The RAM/device split is the part that matters for correctness. Device memory is mapped Device-nGnRnE and never-execute; RAM is Normal write-back, inner-shareable. Mapping MMIO as Normal does not fail cleanly - the core may reorder, merge or speculatively repeat accesses to a peripheral register, and the symptom is a device behaving erratically rather than a fault that names the mapping.

FileChangeWhy
arch/aarch64/mmu.rsnew, 2(1) One block builds the tables and installs them: plain stores to two .bss statics this function owns exclusively while the MMU is still off and no other core runs, then msr of MAIR/TCR/TTBR0, a tlbi vmalle1 (the TLB is architecturally UNKNOWN out of reset, so entering with whatever it holds is a real hazard), and finally `SCTLR_EL1.M
arch/aarch64/mod.rs25 -> 27 (+2)pl011_init (UART disable/drain/line-control/enable) and gpio_init_uart (mux GPIO14/15 to ALT0, pull-up on RX). Both are identity-mapped MMIO writes during single-threaded boot; the BUSY drain is bounded so a wedged UART cannot hang the boot (invariant 12).

2026-08-03 - Raspberry Pi 4 (aarch64) bring-up + the stale arch stubs

Milestone 1 of the Pi 4 port, plus the arch stubs it exposed as out of date.

The stubs had drifted. The neutral kernel gained arch::imp items over time (note_user_task, page_tables::finalize_service_address_space, page_tables::free_page_table_root, and from the v0.9.0 console work fb_commit + FB_READBACK_CHEAP) without every arch stub being updated, so the demarcation builds docs/multi-arch.md claims had quietly stopped compiling. The Pi 4 build surfaced it: the compiler names the surface a port still owes, which is the workflow arch/CLAUDE.md describes. All five arch stubs are current again and compile clean.

Each stub's +2 is the two unsafe fn DECLARATIONS (the keyword is what the scanner counts) - both are empty no-ops, since a stub has no address spaces to finalize or free. They carry # Safety contracts matching the x86/ARM implementations so a real port inherits the obligation rather than discovering it.

FileChangeWhy
arch/riscv64/sbi.rs10 -> 11 (+1)2026-09-21: system_reset_cold, which is how this ISA restarts a board - and the line it replaces was pub fn hardware_reset() -> ! { loop { spin_loop() } }, a stub that printed reboot: hardware reset and then hung the calling hart. Found on the VisionFive 2: reboot wedged hart 1 inside syscall 18 and the liveness watchdog panicked ten seconds later, correctly. ONE ecall, and it is the ONLY mechanism available - x86 escalates to a triple fault the ISA guarantees and both ARM ports write the SoC's own watchdog block, while RISC-V has no architectural reset instruction and the JH7110's reset controller belongs to M-mode. Sound because SRST function 0 takes two specification-defined scalars, touches no memory this kernel owns, and does not return on success; on failure it answers with an error code like any other SBI call. PROBED first, the same discipline hart_start and send_ipi already state here, because an unimplemented extension answers with a silent nothing and "firmware refused" needs different words in front of an operator than "firmware never had it". It lives in this file rather than at the call site so mod.rs grows no unsafe and every SBI consumer keeps going through a safe wrapper.
arch/aarch64/mod.rs23 -> 25 (+2)The two unsafe fn page-table stubs. The boot path was also reworked for the Pi 4 (EL2 -> EL1 drop, BCM2711 PL011 at 0xFE201000, a bounded TXFF wait) but that is net-neutral on the count: CurrentEL read and the UART poll replace the old unguarded byte writes.
arch/loongarch64/mod.rs23 -> 25 (+2)The two unsafe fn page-table stubs.
arch/riscv64/mod.rs23 -> 25 (+2)The two unsafe fn page-table stubs.
arch/riscv64/display.rs5 -> 6 (+1)2026-09-08: display stage six - the boot console adopts the framebuffer. ONE more from_raw_parts_mut, over the SAME run of frames stage four reserved and handed to the display controller: permanently removed from the allocator (alloc_dma_arena), inside the identity map, with no third owner - the controller reads it and the console writes it. It is a second slice rather than a stored one because the first was moved into the controller's programming and a &'static mut cannot be copied; both describe the same bytes and both are the one unsafe step that keeps every glyph a bounds-checked slice write. Nothing about the font, the palette or the layout is decided in arch/: the port owes bootcon an FbParams and an fb_commit and gets the other ports' text for free, which is the arch seam's whole claim and the place a display is easiest to break by writing a small renderer instead.
arch/riscv64/display.rs4 -> 5 (+1)2026-09-08: display stage four - the mode set. ONE more line, and it is not an MMIO access: core::slice::from_raw_parts_mut over the framebuffer, turning 2025 physically-contiguous pages just reserved by the frame allocator into a &'static mut [u8]. Sound because the run is freshly allocated (so owned by nobody else), RESERVED rather than merely allocated (alloc_dma_arena, so it can never be returned to the pool and handed out again as a page table under a device that scans it forever), and inside the kernel's identity map. It is the one unsafe step on purpose: every pixel written afterwards is a bounds-checked slice write, which is the same shape that keeps the boot console free of unsafe on every other port. The controller writes themselves add no lines - they go through the two accessors stage two already declared. Register facts from StarFive's vs_dc_hw.c and vs_dc.c, read as an executable datasheet (26.14); the address is bounds-checked against 4 GiB first, because the controller's address register is 32 bits wide and a truncated one would have the display scanning out somebody else's memory.
arch/riscv64/display.rs2 -> 4 (+2)2026-09-08: display stage two - the clocks and resets. Two more MMIO accessors, this time for the two clock/reset generators rather than the PMU, and their soundness is ORDERING rather than address: syscrg is in the always-on part of the SoC, but voutcrg is inside the VOUT domain, so it is touched only after the power domain reports on and the system clocks feeding it are enabled. That order is the whole content of the file. Every enable is READ BACK, because a write into an unpowered block is silence rather than an error, and every reset deassert is BOUNDED - the reference driver notes that deasserting a reset whose clock is still gated never completes, so the bound is what turns an ordering mistake into a reported failure instead of a dead machine. Register facts from clk-starfive-jh71x0.h, starfive,jh7110-crg.h and reset-starfive-jh7110.c.
arch/riscv64/display.rs0 -> 2 (new file)2026-09-08: display bring-up, first stage - the VOUT power domain. TWO lines, a volatile read and a volatile write of the JH7110 PMU at 0x1703_0000, at the address the device tree gave and inside the identity map. Sound at any time for a reason that is the whole point of starting here: the PMU is in the ALWAYS-ON block, so its registers answer whatever else on the SoC is powered down. Nothing in this file touches the display controller, the video-out clock controller or the HDMI transmitter, because all three sit INSIDE the domain being switched on and a read there while it is unpowered is a bus transaction with nothing to answer it rather than a zero. The only registers written are the power request and its three-write encourage sequence, which is the PMU's documented interface for exactly this (Linux drivers/pmdomain/starfive/jh71xx-pmu.c, read as a datasheet per 26.14).
arch/riscv64/mod.rs57 -> 59 (+2)2026-09-08: the early-boot log replay, so the television shows the whole boot rather than joining it at the first timer tick. TWO lines, both over one fixed 16 KiB array: the copy into it while there is no screen, and the slice over it when there is. The copy is exclusive because it holds the same flag the painting path holds - a byte is either kept for later or drawn now, never both and never torn between cores - and its length is clamped to the space remaining, so a boot that talks more than expected loses the tail of the replay rather than reaching past the array. The read happens before the screen is armed, so nothing else is in the array at the time. The kernel's own log ring is NOT an alternative: the arch's print_str writes the UART directly and never enters it, and draining it here would take it from the events service, which is the one thing it exists for.
arch/riscv64/context_switch.rs34 -> 35 (+1)2026-09-10: the next.sp read, for the kernel stack guard beside the root guard. Worse to get wrong than the address space: a trap taken from S-mode runs on whatever sp it finds, so a garbage kernel stack does not fault once but forever - the fault handler's own prologue faults on the same bad pointer before reaching an instruction that could report it. The read is of the same next the caller already guarantees valid, one line from the cr3 read above it.
arch/riscv64/context_switch.rs30 -> 34 (+4)2026-09-10: switch_context is SPLIT, taking the shape arch/aarch64 already has - ordinary Rust installs the address space, then a naked riscv64_switch_registers does the registers alone. The four lines are the next.cr3 read, the phys_in_ram guard's two reads, and the csrw satp + sfence.vma that used to live in the asm. The point of the split is that naked assembly cannot call anything, so it could neither CHECK the root nor SAY anything when it was impossible - and installing a reclaimed root leaves the hart executing where the kernel's text is unmapped, unable even to fetch the handler that would report it. In Rust the check is a call and the failure is a panic: bounded and loud, where the old shape was a hart that simply vanished.
arch/riscv64/mod.rs66 -> 67 (+1)2026-09-10: note_stage reads its own ra, so the wedge dump prints WHERE a stage was stamped from and not only which stage. Three times in one hunt a stage was read as a location and proved ambiguous - 14 meant both wedged-in-the-drain and halted-by-a-neighbour, 15 meant both before and after the progress stamp, and 17 sat on syscall_slot, which has eight call sites. A return address cannot be ambiguous. One mv {}, ra: no operands, no memory effect, meaningful only because the function is #[inline(never)], which is stated at the block.
memory/allocator.rs46 -> 47 (+1)2026-09-10: ram_limit_phys - the top of RAM as a NUMBER, beside the phys_in_ram that already answers the same question as a call. It exists because one caller cannot make a call: arch/riscv64's switch_context is naked assembly, and the page-table root it installs into satp has to be checked BEFORE the write or the hart executes into an address space that may have been reclaimed - a hardware fault loop with no output, since stvec points into the same dead space. One read of max_ram_frame, which is set once at init and never mutated, exactly as its neighbour does. Deriving it here rather than in the arch keeps the two from disagreeing.
arch/riscv64/mod.rs65 -> 66 (+1)2026-09-10: panic_halt_check gains a wfi halt loop. x86 leaves this seam member an empty stub because it stops its siblings with an NMI broadcast; RISC-V has no NMI, so leaving it empty here left halt_all_cores halting only the hart that called it - a panicking machine kept running services on three harts while one printed its post-mortem. Every hart takes a timer tick within a quantum, so polling a flag here reaches all of them without an IPI a wedged hart could not answer. The block is one wfi: a hint with no operands and no memory effect, reached only when a panic is already in flight and the machine is being taken down.
arch/riscv64/mod.rs58 -> 57 (-1)2026-09-08: evict_by_filling is DELETED. It existed to answer a question the serial console could not - whether the vendor's cache flush did anything - by putting a second method on the kernel's own drawing while the vendor's stayed on the service's, so the screen showed both at once. It answered: the vendor's flush was pointed at the wrong one of the cache controller's two windows. With that corrected the vendor's method is both correct and thirteen times cheaper (154 microseconds against 2089), and it has no ordering hazard - the eviction fill races dirty L1 lines draining into L2 while it is filling L2, which left a band. A diagnostic that has delivered its answer is deleted rather than kept in case.
arch/riscv64/mod.rs57 -> 58 (+1)2026-09-08: evict_by_filling - a second way to push the framebuffer out of the last-level cache, using nothing but ordinary memory. ONE block: a read loop over a run of RAM this module reserved at boot (alloc_dma_arena, so nothing else can ever be given it), inside the identity map, READ only, values discarded. read_volatile is what stops the compiler deleting a loop whose results are unused - which would delete the whole point of it. It exists because whether the vendor's flush works depends on whether the cache controller's zero-device window is cached, which is a fixed platform memory attribute this port cannot read; reading twice the cache's size of ordinary RAM has no such question in it.
arch/riscv64/mod.rs54 -> 57 (+3)2026-09-08: the last-level cache flush, which is what fb_commit actually owed. Three lines: the two config reads in ccache_init, and the one block in ccache_flush_all holding the way-mask writes, the zero-device writes and the fences. Both windows come from the cache controller's own device-tree node and are inside the identity map. The soundness argument that matters is not the addresses but the RESTORE: the flush confines every bus master to one cache way at a time, and a path that left it confined would produce a machine that still works and is sixteen times smaller, so the mask is put back on every exit including a loop entered zero times. THIS PART CANNOT FLUSH BY ADDRESS - its controller defines a flush-by-physical-address register and the vendor driver never uses it because it does not work on this SoC - so the only granularity available is the whole cache, and fb_commit's rectangle is information the hardware cannot use. The ISA offers nothing either: neither Zicbom nor Svpbmt is implemented, so there is no cache-block operation and no non-cacheable page attribute. Recorded rather than hidden (26.7).
arch/riscv64/mod.rs53 -> 54 (+1)2026-09-08: fb_commit gains a body - one fence rw, rw, which has no operands and no effect beyond ordering. The framebuffer is ordinary cacheable RAM here because there is nothing else available: this part implements neither Svpbmt (no page-based memory type to mark a page non-cacheable) nor Zicbom (no cache-block op to clean one), and its device tree lists neither. So what the seam owes on this port is ORDERING, which is the correct and complete answer where DMA is coherent - and this board's tree says it is, marking no node dma-noncoherent and several explicitly dma-coherent. If that is ever proved wrong, this is where it shows and S-mode here has no instruction to fix it; recorded rather than assumed away (26.7).
arch/riscv64/uaccess.rs7 -> 8 (+1)2026-09-08: read_cycle_counter reads cycle where the machine permits it and time where it does not, so the two CSR reads are two lines instead of one. Both are side-effect-free reads. The MAGNITUDE is what the change is for: userspace budgets are cycle counts written against a gigahertz-ish counter, and answering with a 10 MHz timebase turned block-driver's 400-million-cycle AHCI link wait into forty seconds per port. Nothing in that driver is wrong at that point - the unit is.
arch/riscv64/mod.rs50 -> 53 (+3)2026-09-08: PCI Express through ECAM, and the rdcycle probe. Configuration space is MEMORY on this ISA - there is no port I/O, so a config read is one volatile read at an address built from bus/device/function, where x86 needs a write to CF8 then a read from CFC. The window's base comes from the device tree, never a constant, and every access is bounded by the size the tree declared, so a wrong bdf returns None instead of touching memory outside the window. The third is the probe: cycle is readable from S-mode only if M-mode set mcounteren.CY, there is no bit that says so, and getting it wrong is an illegal instruction rather than a zero - so the instruction is executed DELIBERATELY with the trap handler armed for exactly one of them, the same shape as the user-mode selftest's deliberate page fault.
arch/riscv64/mod.rs64 -> 65 (+1)2026-09-09: note_syscall reads tp for the hart id, the same one-line register read as note_irq and note_stage beside it. It exists because a stage alone was not enough: the wedge dump showed core 0 at stage 1 (trap-entry) with neither timer-enter nor fault-report set, which rules out the SBI calls and the whole fault-reporting path and leaves the one route through the trap handler that carried no stamp - syscall::dispatch. Recording the NUMBER costs the same store and answers a different question: "stuck in a syscall" leaves sixty candidates, "stuck in syscall 41" names the code to read.
arch/riscv64/mod.rs65 -> 64 (-1)2026-09-09: the tp read added so the framebuffer publish could tell which core it was on is GONE, because restricting the publish to one core was the wrong lever. The race it addressed is real - ccache_flush_all reprograms every bus master's way-mask - but the fix for a race is mutual exclusion, which the try-lock inside the flush already provides. Cutting the number of CALLERS also cut the publish RATE fourfold, which on a framebuffer this SoC cannot keep coherent is visible tearing, and the operator saw it. The pacing is now a clock and the safety is a lock, which is what each is for. A reduction, so no amendment (18.5).
arch/riscv64/mod.rs64 -> 65 (+1)2026-09-09: one more tp read, so the framebuffer publish can tell which core it is on. timer_tick runs on every hart, so the publish ran on every hart - four whole-L2 flushes every two ticks for one framebuffer with one writer, and that flush reprograms the way-mask of every bus master on the SoC. Restricting it to core 0 needs the core id, and the id comes from tp. Mapped through hart_core rather than compared as a hart number, because on this board the boot hart is 1.
arch/riscv64/mod.rs61 -> 642026-09-09: the IDLE SAMPLE - two CSR reads (sie, sip) and two tp reads, taken immediately before a hart executes wfi. All four are side-effect-free reads. They exist because a hart that never wakes has no "after": everything not captured before the wfi is unavailable forever, which is why three attempts at this wedge had nothing to read. The four numbers separate the only three explanations a dead wfi has - never armed, not enabled to wake (STIE clear), or armed and enabled and undelivered.
arch/riscv64/mod.rs60 -> 61 (+1)2026-09-09: note_stage reads tp for the hart id, the same one-line register read as note_irq beside it and for the same reason - S-mode has no register that reads back which hart is executing. It stamps a phase so the liveness watchdog can say WHERE a core stopped rather than only that it did: the wedges caught so far name two different tasks on two different cores, which rules out one guilty service and leaves the kernel path they share, and the interrupt counter freezing mid-run proves the core entered a trap and never left it. One relaxed store on a path that already does several.
arch/riscv64/mod.rs59 -> 60 (+1)2026-09-09: note_irq reads tp to learn which hart it is on. One line, a register read with no side effects, on a path that already had to know: the hart id lives in tp because RISC-V S-mode has no register that reads back "which hart am I". It earns its place by replacing a STUB that returned (0, 0) - and that stub printed "0 timer interrupts" inside the first liveness-wedge panic this port ever produced, about a core whose timer nobody had ever counted. Zero is the most incriminating answer that message can carry and it was not a reading. x86 carried the same stub, and making it real is what let a repro there distinguish a timer that STOPPED from a tick that was merely SKIPPED.
arch/riscv64/sbi.rs12 -> 10 (-2)2026-09-09: remote_fence_i is DELETED, and the deletion is the fix. It broadcast fence.i to every hart so a spawn could publish the text it had just written - the right guarantee, in a place that cannot afford it. A broadcast waits for every target to acknowledge, and it was issued from inside a spawn, which runs with interrupts off: the same hazard task/scheduler.rs documents for TLB shootdowns and refuses to take. It survived three supervisor respawns and hung the machine on the fourth, three rounds into a chaos run, with the log stopping between "respawning" and "spawned OK" and no panic, because nothing had failed. The guarantee moved to a LOCAL fence.i in context_switch::user_entry_trampoline, on the hart about to run the task, one instruction before it does - same property, no cross-hart wait, no dependency on the RFENCE extension, and cheaper. A reduction, so no amendment is needed (18.5).
arch/riscv64/sbi.rs10 -> 12 (+2)2026-09-09: remote_fence_i, which publishes freshly loaded CODE to every hart. Two lines: the local fence rw, rw + fence.i, and the RFENCE ecall that makes the other harts do the same. Both are ordering operations with no memory effects - fence.i discards fetched instructions on the hart that runs it, and the firmware call runs the same instruction on the harts it names. What makes them NECESSARY rather than defensive is that RISC-V has no coherence between the data path and the instruction path and no broadcast form of the fence: the loader writes a service's text with ordinary stores, and a DIFFERENT hart then executes it. Without this, that hart runs whatever those physical lines held before, which on a recycled frame is a dead service's text - observed on the VisionFive as roster and upper trapping at a fixed sepc on a fixed hart while the identical spawn succeeded on another.
arch/riscv64/uaccess.rs8 -> 7 (-1)2026-09-09: read_cycle_counter reads time and only time, so the two CSR reads become one again. cycle counts CPU cycles and therefore STOPS in wfi, which makes a cycle-denominated deadline unable to elapse while a core is idle - the exact shape of a wait that never returns. time advances at a constant rate whatever the core is doing, which is what every duration above the kernel actually means. A reduction, so no amendment is needed (18.5).
arch/riscv64/sbi.rs6 -> 10 (+4)2026-09-08: SBI HSM and IPI - how a secondary hart is STARTED on this ISA and how one hart pokes another. call3 is the three-argument form HSM's HART_START needs; hart_start and send_ipi are safe wrappers over it that PROBE the extension first and report a firmware refusal rather than assuming success - a hart that never started is otherwise indistinguishable from one that started and hung, and those have opposite fixes. Neither call can affect the calling hart. There is no trampoline and no INIT/SIPI timing here: the whole of x86's real-mode ap_boot is replaced by one firmware call, which is also why the started hart arrives with satp zero and must repeat _start's work itself.
arch/riscv64/trap.rs6 -> 7 (+1)2026-09-08: enable_software_interrupts sets sie.SSIE, which is what admits an IPI. Separate from the timer enable because the two are wanted at different moments - the boot hart needs the timer long before any other hart exists, a secondary needs both at once - and sound for the same reason: stvec is installed before either, so an admitted interrupt always has somewhere to go.
arch/riscv64/mod.rs39 -> 50 (+11)2026-09-08: SMP. The AP entry (naked: tp for this hart's identity, sp for the stack reserved for it, jump), get_lapic_id reading tp and the two writes that set it, the satp install on a secondary, the sip.SSIP acknowledgement, the IPI drain calling the neutral handler, and start_all_aps. RISC-V has no S-mode register that reads back which hart you are - mhartid is M-mode - so the id arrives in a0 once and must be parked per-hart or it is lost; tp is where, and it is free because a kernel with no thread-local storage never uses it. Each secondary's stack is a fixed slice of a static arena sized in the source (4 x 32 KiB), so nothing is allocated during bring-up and a hart past the arena is refused rather than given a stack pointer nobody chose. The satp write is sound because the root it installs is the kernel's own, built and unchanged, mapping the code doing the write and the stack under it.
arch/riscv64/sv39.rs28 -> 29 (+1)2026-09-08: free_table_tree becomes reclaim_user, which frees a dead task's own pages and tables and leaves the kernel's alone. U IS THE DISCRIMINATOR, because on this port nothing else is: x86 reclaims by walking only the low half of its top-level table, which works because its kernel is higher-half, and here the kernel is identity-mapped from zero while a service links at 0x400000, so an index says nothing. clone_kernel_map never sets U on anything it lends, so a leaf with U is the task's and a leaf without it is on loan; a leaf at level 1 is always the kernel's, since map_page only ever makes 4 KiB leaves. Every physical address is checked with phys_in_ram before it is dereferenced OR freed - a stale entry would otherwise be walked through the identity map and fault the kernel, and freeing one would corrupt the frame bitmap with an address the allocator never issued. The root is deliberately left alone here, because a self-killing task is still executing in that space.
arch/riscv64/mod.rs38 -> 39 (+1)2026-09-08: reclaim_user_frames and free_page_table_root gain their proper halves - the walk, and the last frame. Both refuse a root outside RAM. Returning zero from the reclaim stub was NOT a neutral placeholder: the kill path PRINTS the count, so freed 0 frames read as "this task had nothing" when it meant "nothing was reclaimed", and a service faulting in a restart loop leaked its entire address space per cycle until the machine died of FrameAllocFailed several hundred restarts away from the cause. Now 84 frames a kill, and 14,651 fault-restart cycles in two minutes with no allocation failure.
arch/riscv64/mod.rs37 -> 38 (+1)2026-09-08: the UART's RECEIVE half - uart_rbr, the counterpart of uart_thr at the same address (a 16550 tells the two apart by the direction of the access, not by the offset). ONE new line, the volatile read, at the width the device tree declares exactly as the transmit side does. Reading it CONSUMES a byte from the FIFO, so it is called only with LSR_DR set, and the drain loop around it is bounded by the ring rather than by the FIFO - a UART wedged with DR stuck would otherwise spin forever inside a timer interrupt and take the machine with it. The RX RING ITSELF NEEDS NO unsafe AT ALL: it is an array of AtomicU8, which says what is true (a producer in the tick, a consumer in a syscall) and costs nothing on a machine with the A extension.
arch/riscv64/uaccess.rs0 -> 7 (new file)2026-09-07: reading and writing USER memory from the kernel. SEVEN lines: the sstatus.SUM set and clear either side of a copy, three copy_nonoverlappings, the from_raw_parts over the scratch, and the time CSR read. What makes them sound is the WALK, not the copy: every page of a user range is checked against the live page table for presence, the U bit and the needed permission BEFORE any byte moves, so a bad pointer is a false return rather than a fault - which matters because this port has no kill path yet and a fault would halt. SUM is opened by a guard that restores on drop, so no early return can leave the kernel able to touch user memory; it is set for one copy and no longer. The returned slice is over this core's scratch, never over user memory, so nothing downstream can be made to dereference a user address by a later unmap. The pre-walk is a TOCTOU the moment a second hart can unmap underneath it - stated in the module header, and the reason x86's fault-attribution design is the one this adopts when it has a task to kill into.
arch/riscv64/usermode.rs9 -> 17 (+8)2026-09-07: the user TASK selftest - user mode and a private address space together, which is the shape a service has. A second global_asm! stub, the copy_nonoverlapping and fence/fence.i that make it executable, the context priming, the switch_context in and the one OUT OF THE TRAP HANDLER, and the frame frees. The switch out is the notable one: it is called from inside claim_task_trap and never returns, abandoning the trap frame on the task's own kernel stack - sound because nothing can resume that task (its context is a static no scheduler knows about) and because the boot context it switches to carries the kernel root, so satp is restored on the way out. Sound to free the task's frames afterwards for the same reason.
arch/riscv64/sv39.rs25 -> 28 (+3)2026-09-07: clone_leaf_roots becomes clone_kernel_map, which descends one level where it must. The kernel's identity map and userspace OVERLAP on this port - the kernel is mapped from zero and a service links at 0x400000, so both are inside the FIRST gigapage - so copying a root-level leaf wholesale replaced a service's own text with a kernel mapping that has no U bit. It now copies a gigapage only into an EMPTY root slot, leaves a slot the task already owns alone, and where the task holds a pointer table descends and fills only the 2 MiB slots it has not claimed. Same soundness as the rest of the walker (volatile single-PTE accesses, page-aligned frames the kernel owns, indices masked to 9 bits); the added lines are the level-1 read and write and the descent. Permission bits come from the kernel's own leaf, so U is never set on anything it copies.
arch/riscv64/sv39.rs17 -> 25 (+8)2026-09-07: clone_leaf_roots and free_table_tree - building a task's address space and taking it back. Same soundness as the rest of the walker: volatile reads and writes of single 64-bit PTE slots in page-aligned frames the kernel owns, indices masked to 9 bits so they are in-frame by construction, addressing identity. The pairing is what makes the frees safe: the clone copies only root-level LEAVES, so anything reachable through a POINTER entry in a task root was built by that task - and free_table_tree follows only pointer entries, so it can never walk into the kernel's own tables. free_table_tree is unsafe fn because it cannot see whether the root is the live satp one; freeing the address space you are executing in is the caller's obligation to avoid.
arch/riscv64/context_switch.rs21 -> 30 (+9)2026-09-07: the per-task address-space selftest. A third task body, the write of a sentinel into a fresh frame at its physical address (identity-mapped, freshly allocated, so it is the page the mapping points at), the read of that sentinel through the task's OWN address space, the context and root priming, and the reclaim. The private read is guarded by a check that the live root is the expected one, so a switch that failed to install the table reports instead of faulting - a fault there would halt the machine describing a load at an address that means nothing to a reader, burying the actual finding.
arch/riscv64/mod.rs36 -> 37 (+1)2026-09-07: finalize_service_address_space and free_page_table_root gain bodies (net +1). The first is what makes a task switchable AT ALL: the instant switch_context writes satp the kernel is executing through the new table - the next instruction fetch, the stack under it, the trap vector a fault would need - so a root that maps only the task's own pages is a machine that stops with nothing left able to say why. Both refuse a zero root rather than inventing one.
arch/riscv64/context_switch.rs0 -> 21 (new file)2026-09-07: the neutral context-switch surface. FOUR naked functions - switch_context itself, the kernel and user first-entry trampolines, and the callee-saved probe - plus the two unsafe fn constructors, the two diverging task bodies, the calls between them, and the frame frees. switch_context is sound because it touches ONLY the two TaskContexts it is handed, at offsets a const assertion pins against the struct's size, and because the incoming context's contract (a real return address, a stack the task owns, a valid Sv39 root or zero) is the caller's to meet - which is why it is unsafe. The satp write is guarded by a comparison against the LIVE register and followed by sfence.vma, meeting the SEC-26/27 obligation arch/CLAUDE.md places on a weak-ordered port rather than deferring it. The selftest statics are touched only on the boot hart before any other hart exists.
arch/riscv64/mod.rs35 -> 36 (+1)2026-09-07: the interrupt-mask seam members gain real bodies and the context_switch stub module leaves the file (net +1). enable_interrupts / disable_interrupts set and clear sstatus.SIE; an interrupt raised while it is clear is held pending by hardware rather than lost, so masking defers rather than discards. local_irq_save uses csrrc to read and clear in ONE instruction - two instructions would leave a window in which an interrupt is taken after the caller believes it has masked. wait_for_interrupt is wfi, which is architecturally a HINT and only correct inside a re-checking loop, which is how the neutral idle path uses it. Empty bodies here were worse than stubs elsewhere: a disable_interrupts that does nothing is a seam member telling the neutral kernel a lie.
arch/riscv64/syscall.rs0 -> 1 (new file)2026-09-07: the ecall syscall entry. ONE line: the call into the neutral syscall::dispatch::syscall_handler, which is unsafe extern "C" because it is entered from a privilege transition and must trust nothing it is handed - that is its contract, not a hazard, and it validates every argument itself. The four values come straight out of the trap frame, so they are exactly what the caller placed in its own registers; this file neither dereferences them nor writes a CSR. The x86 and ARM entries call it the same way, for the same reason.
arch/riscv64/usermode.rs0 -> 9 (new file)2026-09-07: U-mode entry, and the first code on this ISA that is not the kernel. NINE lines: the global_asm! holding the user stub, enter_user (naked - saves the callee-saved set and sp, latches the kernel stack in sscratch, clears sstatus.SPP and srets), the copy_nonoverlapping of the stub into a fresh frame, a fence + fence.i so a fetch sees instructions written as data, two map_in_active_tables calls, two sfence.vma invalidations, and two free_frames of the pages afterwards. The privilege drop is sound because it is a full transfer: nothing after sret runs in the kernel until a trap arrives, and the trap arrives on the stack sscratch names rather than one U-mode chose. The frames are this function's own, mapped at addresses above every region the kernel maps (the walker refuses to overwrite a live leaf), unmapped before they are freed. The magic syscall and the expected-fault path are gated by ARMED, set for exactly one round trip, so neither is reachable from a real task - the ARM32 port learned that one from its own audit.
arch/riscv64/trap.rs6 -> 6 (0)2026-09-07: the entry stub gained the sscratch kernel-stack swap and saves sstatus alongside sepc, and init now zeroes sscratch before installing the vector. Net-neutral on the count - the same three blocks - but the soundness argument is larger and is restated here rather than left at its old scope: a trap from U-mode may not push onto the user's stack, so entry swaps in the latched kernel stack in one csrrw and zeroes the latch so a nested trap takes the kernel path instead. sscratch has no architecturally specified reset value and the firmware beneath runs on mscratch, so it is zeroed rather than assumed - QEMU hands over a zeroed register and would never have shown it.
arch/riscv64/mod.rs34 -> 35 (+1)2026-09-07: map_in_active_tables gains a body. It walks the root satp currently names, maps one page, and issues an address-scoped sfence.vma - the fence being required even for a FRESH mapping, because RISC-V permits a negative TLB entry to be cached. It refuses when translation is off rather than inventing a root, which is the only honest answer when there is no active table to add to.
arch/riscv64/sbi.rs0 -> 6 (new file)2026-09-07: calls into the firmware this kernel runs UNDER. One ecall (the SBI calling convention: extension in a7, function in a6, two-word return), one time CSR read, and the wrappers over them. ecall is memory-safe by construction - it traps to M-mode and returns - and the primitive is unsafe because WHICH firmware routine runs depends entirely on the extension and function asked for, with the legacy shutdown call a single number away from harmless ones. Extensions are probed before use rather than assumed, so a missing one is reported rather than called into.
arch/riscv64/trap.rs4 -> 6 (+2)2026-09-07: the context-saving trap entry and the two interrupt-enable bits. The stub saves all 31 writable registers plus sepc on the current stack and restores them before sret - all 31 rather than the caller-saved set, because the interrupted code is not a caller and agreed to no convention with the handler. enable_timer_interrupts sets sie.STIE and sstatus.SIE, sound because stvec is installed first: enabling before that would give the first tick nowhere to go.
arch/riscv64/trap.rs0 -> 4 (new file)2026-09-07: the S-mode trap vector. Three CSR reads with no side effects (scause, sepc, stval), the naked entry stub, and the csrw stvec that installs it. The install is guarded by a runtime alignment check rather than an assumption: stvec steals the low two bits for MODE, so a misaligned handler would be silently reinterpreted, and Rust has no stable way to align a function.
arch/riscv64/sv39.rs15 -> 17 (+2)2026-09-07: identity_map_gigapages writes root-level 1 GiB leaves. Same soundness as the 4 KiB walker - a page-aligned frame the kernel owns, an index masked to 9 bits - and the loop is bounded by both the requested end and the 512-entry table, with an explicit break on wrap.
arch/riscv64/mod.rs33 -> 34 (+1)2026-09-07: a DELIBERATE read of an unmapped address, to prove the trap vector reports rather than trusting that installing it worked. Sound in the only sense that matters here: the read is guaranteed to fault, that is the entire purpose, and the handler it enters does not return. Removed when the kernel has real work after this point.
arch/riscv64/mod.rs32 -> 33 (+1)2026-09-07: enable_paging calls write_page_table_base. The obligation that makes it sound is discharged immediately above the call: the table maps every address identically from zero to the top of RAM, which includes the instruction stream performing the write and the stack under it.
arch/riscv64/sv39.rs0 -> 15 (new file)2026-09-07: the Sv39 page-table walker. Every block is a volatile read or write of one 64-bit PTE slot in a page-aligned frame the kernel owns, plus one write_bytes zeroing a freshly allocated table. Sound because the tables are frames from alloc_frame (so owned and 4 KiB), the index is masked to 9 bits (so < 512 and in-frame by construction), and addressing is identity while satp is zero - which PHYS_IS_IDENTITY states and the kernel's own map preserves when it enables translation. arch/ is a permitted layer (§18.1); this is recorded, not exempted.
arch/riscv64/mod.rs28 -> 32 (+4)2026-09-07: the page_tables seam over the above - unmap wrapping a returned frame, csrr satp, csrw satp + sfence.vma, and address-scoped sfence.vma. The CSR read has no side effects; the write is unsafe fn with the contract that base must map the code performing it, because the very next instruction fetch goes through it.
arch/riscv64/fdt.rs0 -> 3 (new file)2026-09-07: the device-tree reader. THREE lines, all in Fdt::from_ptr, and deliberately all at the boundary: two volatile reads of the 8-byte FDT header, and one from_raw_parts turning the firmware pointer into a &[u8] of exactly the length that header declares. Nothing is trusted before the 0xd00dfeed magic matches. Every other line in the file is safe slice indexing, so a malformed or hostile tree yields None rather than a read outside the blob - the same shape as bootcon, which takes a checked slice from the arch and is bounds-checked thereafter.
arch/riscv64/mod.rs27 -> 28 (+1)2026-09-07: build_boot_info writes the fixed REGIONS array and then hands the neutral kernel a &'static [MemoryRegion] over the filled prefix. Sound because it runs once, on the boot hart, before any other hart is started and before interrupts are enabled - so there is no concurrent access - and the slice is read-only from that point. The array is FIXED, so no allocation can fail partway through bring-up; a machine describing more banks than fit simply has the extras dropped, which the boot reports.
arch/riscv64/mod.rs26 -> 27 (+1)2026-09-07: the Fdt::from_ptr call in riscv_boot_main, passing the pointer the boot protocol supplied in a1. Sound for the reason above: the constructor validates before it trusts.
arch/riscv64/mod.rs25 -> 26 (+1)2026-09-07: fdt_total_size reads the device-tree header at the pointer the RISC-V boot protocol supplies in a1. Sound because it reads exactly 8 bytes, volatile, and acts on NOTHING until the magic matches 0xd00dfeed - a pointer that is not an FDT is rejected before any field is trusted. arch/ is a permitted layer (§18.1), so this needs no amendment, only recording. Found by scripts/unsafe_check.py REFUSING the build, which is the gate doing its job.
arch/riscv32/mod.rs23 -> 25 (+2)The two unsafe fn page-table stubs.
arch/s390x/mod.rs18 -> 20 (+2)The two unsafe fn page-table stubs.

2026-08-03 - the A9-1 large-page guard selftest (test/large-page-guard)

Kernel-audit A9-1 fixed a walk that could write into a mapped data frame, but the trigger (a large page covering a map_in_active_tables target) does not occur on any machine available, so the guard was unproven. selftest_large_page_guard manufactures the condition rather than mocking it: it installs a real 2 MiB PS=1 entry over an unused canonical VA, calls the real map_in_active_tables inside it, and checks the mapped frame is byte-for-byte untouched. Verified in both directions - the test reports CORRUPTION at u64 index 1 with the guard removed, and PASS with it restored.

Feature-gated behind mmio-map-fault-test; a default build does not compile it (confirmed: the default image never prints pt-selftest, and identity is 24/24).

FileChangeWhy
arch/x86_64/page_tables.rs48 -> 51 (+3)(1) asm! reading CR3 to find the active PML4 - a register read with no memory effect. (2) One block covering the test body: filling the frame with a sentinel, building PML4/PDPT/PD, installing the large-page entry, running the walk, checking the sentinel, tearing the entry down and invlpg-ing it. The VA is confirmed unmapped by entry_for_va before anything is written, so no live mapping is touched, and the frame comes exclusively from the allocator. (3) free_phys_frame after the entry is cleared and flushed, so no page-walker can still reach it. Permitted layer (§18.1), each block SAFETY-commented.

2026-08-17 - the terminal leaves the kernel: arch/arm/bootcon.rs 1 -> 2 (console service)

kernel/src/fbcon/ (1,172 lines of terminal emulation) moved to the console SERVICE and the kernel kept a minimal boot/panic blit, kernel/src/bootcon/ (docs/console-service.md §9, CLAUDE.md §11.4 amendment). arch/arm/fbcon.rs is renamed arch/arm/bootcon.rs to match what it now backs.

Neither the kernel's floor nor the service has any unsafe, which is the same property the neutral console had and for the same reason: both are handed the framebuffer as a bounds-checked byte slice. What changed is where the one unsafe that produces that slice lives for the SERVICE - it is now Framebuffer::bytes_mut in sdk/rust/src/mmio.rs, the SDK's audited hardware layer (§18.1), rather than an arch backend. §18.2 forbids unsafe in a service outright, so there was no other option, and the argument is the same one the arch backends make: only that layer knows the kernel mapped the region and for how long.

FileChangeWhy
arch/arm/fbcon.rs -> arch/arm/bootcon.rs1 -> 2 (+1)The renamed file gains a dsb in fb_commit. It REPLACED a clean_dcache walk over the written rectangle, which is no longer possible: the framebuffer is now mapped Normal non-cacheable (mmu::section_fb), because the console service maps the same physical pages and ARM leaves mismatched memory attributes UNPREDICTABLE. Nothing to clean, but a non-cacheable store may still sit in the write buffer and the GPU scans RAM, so the drain is still owed. Permitted arch layer, SAFETY-commented.
sdk/rust/src/mmio.rs+1Framebuffer::bytes_mut - from_raw_parts_mut over the VA and length the kernel wrote into this service's context page at spawn. The mapping lives as long as the address space does; &mut self plus a non-Copy, non-Clone handle means no second live slice can exist. Audited SDK hardware layer (§18.1).
kernel/src/bootcon/, services/console/src/0Unsafe-free by construction, both of them.

2026-08-01 - one framebuffer console for every arch: unsafe 11 -> 3 (refactor/fbcon-neutral)

arch/x86_64/fb.rs (790 lines) and arch/arm/fbcon.rs (512 lines) were two independent implementations of the same terminal. They are now one neutral module, kernel/src/fbcon/, with a thin per-arch backend. fbcon/ is NOT a permitted layer (§18.1), and no amendment was sought to make it one - instead the design was changed so the neutral module needs no unsafe at all.

The mechanism: the arch hands the console the framebuffer as a &'static mut [u8] slice (FbParams::mem) rather than a base address. Every pixel write in fbcon/ is then a bounds-checked slice write; the scroll memmove is slice::copy_within; the clear is slice::fill. The single unsafe per arch is core::slice::from_raw_parts_mut at init, in the layer that actually knows the mapping is valid and permanent.

This supersedes a claim in the 2026-07-10 entry below, which said of the fast blit path: "There is no safe route ... a bounds-checked &mut [u32] would defeat the purpose (a compare per pixel is the very overhead removed)." That is false, and the assumption behind it was that the check would be per-pixel. It is not: slicing the row once (mem.get_mut(off..end)) bounds-checks the whole run, and chunks_exact_mut then iterates it without further checks - the same contiguous store pattern, with one compare per row instead of per pixel. Verified behaviour-identical: the rendered framebuffer is byte-for-byte the same PNG (md5 2670c31a...) before and after the change, on the edit full-screen path that exercises glyphs, reverse video, absolute positioning and erase.

FileChangeWhy
arch/x86_64/fb.rs5 -> 2 (-3)Reduced to the Limine binding + the WC store fence. Remaining: the sfence in fb_commit, and from_raw_parts_mut over Limine's reported [address, address + pitch*height) - higher-half, copied into every address space by PageTable::new, so 'static; taken once on the BSP before any AP starts, and no other kernel reference to the framebuffer exists, so &mut exclusivity holds.
arch/arm/fbcon.rs6 -> 1 (-5)Reduced to the pixel format, the D-cache clean in fb_commit, and the single-writer mirror gate. Remaining: from_raw_parts_mut over the mailbox-reported base, mapped by video::map before this runs, taken once on the boot core before the tick or any AP starts.
fbcon/mod.rs, fbcon/render.rs0Neutral layer - unsafe-free by construction, which is why no §18.1 amendment is needed.

Net: 11 lines -> 3, and roughly 300 lines of duplicated logic collapsed to one copy.


The Pi 2's onboard LAN9514 brought up on real hardware, then reworked from a polled RX to an interrupt-driven one (ping loss 85% -> 4%), plus a real PHY link read and the SNTP wall clock. All in the permitted arch/ layer; every block SAFETY-commented, all of it core-0 + IRQ-masked driver state (the RX ring, the reassembly buffer, the DMA burst buffer) or MMIO through the existing rd/wr helpers.

The RX path's blocks are the bulk of it: a single-producer/single-consumer ring (net_rx_ring_push, net_rx_ring_pop), the cross-burst reassembly buffer (net_rx_parse, net_rx_partial_reset), arming and re-arming the background bulk-IN (net_rx_async_arm, net_rx_async_start, net_rx_isr), and the consumer syscall (net_frame_rx), plus net_rx_ring_full - the backpressure test that stops the ISR re-arming into a full ring (receiving frames only to drop them is work done for nothing), and hotplug_poll - the hub-port watcher (present but deliberately NOT wired to a caller; see the note at the idle hook in arch/arm/mod.rs for why enumeration cannot run there yet). Producer and consumer are mutually exclusive without a lock because both run on core 0 with IRQs masked - the syscall path cannot park, and ARM IRQ entry masks IRQs - which is the argument each SAFETY comment carries.

This entry also re-baselines two files whose drift predates this work: irq.rs and mod.rs are untouched by these commits (verified: identical counts at the branch point) and had accumulated undocumented blocks from the earlier USB/SD bring-up on this branch. They are recorded here so the inventory matches source again rather than carrying a known-stale baseline forward.

FileChangeWhy
arch/arm/irq.rs11 -> 13 (+2)Pre-existing drift, re-baselined: blocks added by earlier branch work (USB IRQ routing/ack), not by the networking commits.
arch/arm/mod.rs43 -> 44 (+1)Pre-existing drift, re-baselined: one block from earlier branch work.

2026-07-24 - SD card bring-up on real hardware: mailbox power + base clock + pin routing (feat/pi2-arm32)

The Pi 2's SD card init failed on hardware while passing under emulation. Three board-level steps the Arasan EMMC needs, none of which the driver can do itself (it is granted only its controller's 4 KiB register window - §12.3), so they live in the kernel's boot path like the USB power-on before them:

  • video::set_sd_power_on - mailbox SET_POWER_STATE device 0 (SD card). The EMMC's registers answer even when the card's power domain is off, so skipping it gives exactly the observed symptom: registers read fine, no command completes. QEMU stubs the tag, so it is invisible there.
  • video::read_emmc_clock / emmc_clock_hz - mailbox GET_CLOCK_RATE clock id 1. The base clock MUST come from the platform: the Arasan's capability register reports it wrongly on this SoC (Linux marks it missing_caps), and a guessed divider runs the card's identification clock at the wrong speed, which no card answers.
  • sd_route_to_emmc (mod.rs) - route GPIO 48-53 to ALT3 (the Arasan) with the BCM2835 pull-up sequence, and log what the firmware had them set to first (ALT0 = the other sdhost controller, which would leave the Arasan electrically disconnected from the card).

All in the permitted arch/ layer, each block SAFETY-commented, all single-threaded boot-path MMIO or the caches-off mailbox buffer.

FileChangeWhy
arch/arm/video.rs11 -> 17 (+6)set_sd_power_on (MBOX fill + response read, +2), read_emmc_clock (MBOX fill + response read + EMMC_CLOCK_HZ store, +3), emmc_clock_hz (static read, +1).
arch/arm/mod.rs42 -> 43 (+1)sd_route_to_emmc - GPFSEL4/5 read-back + ALT3 write and the GPPUD/GPPUDCLK1 pull sequence for GPIO 48-53.

2026-07-24 - underline cursor on the ARM framebuffer console (feat/pi2-arm32)

The TV console had no visible cursor (x86 draws an underline; ARM did not). fbcon now paints a 2 px underline at the write position, lifts it before the position moves, and honours ESC[?25l/ESC[?25h so full-screen apps can hide it. The cursor is moved ONCE per write run rather than once per byte, so bulk output (a help listing, a scroll) does not pay for it - which is why put_bytes gained its own unsafe block instead of looping over the public put_byte: one borrow of the FBCON static for the whole run. arch/arm/fbcon.rs unsafe 4 -> 5 (+1), permitted arch layer, SAFETY-commented, same single-writer justification as the existing blocks (serialized by pl011_write's SERIAL_BUSY guard).

FileChangeWhy
arch/arm/fbcon.rs4 -> 5 (+1)put_bytes borrows the FBCON static once for a whole run (lift cursor, render every byte, repaint cursor) instead of re-borrowing per byte via put_byte.

console_boot_complete on ARM (2026-07-26)

The shell calls console_boot_complete() to dismiss the boot screen and stop mirroring kernel log output to the TV, so the prompt is not overwritten by a late service log (fs: journal recovered ... landing on the gsh> line - the reason the prompt appeared only after pressing Enter). x86 has always implemented it; ARM's was an empty stub. Implementing it needs one borrow of the FBCON static to clear the screen and home the cursor.

FileunsafeWhy
arch/arm/fbcon.rs5 -> 6 (+1)clear_and_home borrows the FBCON static once to clear the framebuffer and reset cursor/write position. Same single-owner discipline as put_bytes: core 0 holds SERIAL_BUSY when the console writes, and the function returns immediately when no framebuffer was mapped (base == 0). Permitted arch layer (§18.1), SAFETY-commented.

2026-07-24 - full ARM keyboard decode: new arch/arm/hid.rs, and dwc2 unsafe SHRINKS (feat/pi2-arm32)

Full HID boot-keyboard support for the ARM in-kernel USB driver (numeric keypad, arrows/navigation, F-keys, Caps Lock latch, Ctrl+letter, typematic auto-repeat) moved into a new arch/arm/hid.rs containing ZERO unsafe - it is pure decode logic (usage code -> bytes), no MMIO and no statics. The driver's old decode_report + its PREV_KEYS unsafe block are gone, replaced by a single KBD_STATE struct accessed inside poll's ALREADY-EXISTING unsafe block, so dwc2.rs shrinks 15 -> 14. A new file with no unsafe and a net reduction in the driver.

FileChangeWhy
arch/arm/hid.rsnew, 0Pure HID decode logic (keymap, CSI sequences, auto-repeat timing). No unsafe: no MMIO, no statics - the caller owns the state and passes it in.

2026-07-24 - DWC2 split debug + VideoCore USB power-on + board MAC (feat/pi2-arm32)

Two arch/arm/ files gained unsafe this session; all blocks are in the permitted arch layer (§18.1), each carries a // SAFETY: comment, and each is single-threaded (core-0-only) hardware access.

  • dwc2.rs 13 -> 15 (+2): the split-transaction microframe trace added while diagnosing the low-speed-keyboard split XactErr. trace_split captures (phase, HFNUM, HCINT, GNPTXSTS, GINTSTS) into a fixed SPLIT_TRACE array (write), and the one-shot split-fail dump reads it back (read). Both are a bounded fixed array indexed < SPLIT_TRACE_MAX, single-writer on core 0. (The HubAddr/PrtAddr fix, the multi-packet split loop, the toggle-parity fix, and the driver-review fixes are all safe code - rd/wr/atomics - and added no unsafe.)
  • video.rs 6 -> 11 (+5): set_usb_power_on (the VideoCore SET_POWER_STATE mailbox call that powers the DWC2 AXI master - the breakthrough that made USB work on real silicon) is one MBOX-fill block; read_board_mac is three (MBOX fill, MBOX response read, and the BOARD_MAC static write - a plain pre-MMU store, NOT an atomic, because ARMv7 STREXD is UNPREDICTABLE before the MMU is on); board_mac is one (the BOARD_MAC static read). These read the board Ethernet MAC (GET_BOARD_MAC, tag 0x00010003) since the Pi 2 has no EEPROM.
FileChangeWhy
arch/arm/video.rs6 -> 11 (+5)set_usb_power_on (USB-HCD power mailbox, +1); read_board_mac (MBOX fill + read + BOARD_MAC pre-MMU store, +3); board_mac (BOARD_MAC read, +1).

2026-07-23 - BCM2835 GPIO (feat/pi2-arm32)

Added gpio_op(op, pin) - drive a SoC GPIO pin's direction/level/read (BCM2835 GPFSEL/GPSET/GPCLR/GPLEV in the already-Device-mapped peripheral window). Exposed via a new capability-gated Gpio syscall (45), GPIO carries the UART/SD lines so it is granted only to the shell (GPIO_DEVICE cap, id 11), with a gpio shell command (gpio <input|output|high|low|read> <pin>). QEMU-verified: drive pin 21 high -> read 1, low -> read 0. arch/arm/mod.rs unsafe 41 -> 42 (+1, one SAFETY-commented block of GPIO MMIO). Other arches stub gpio_op to -1 (no unsafe).

FileChangeWhy
arch/arm/mod.rs41 -> 42 (+1)gpio_op drives the BCM2835 GPIO (GPFSEL/GPSET/GPCLR/GPLEV MMIO).

2026-07-23 - BCM2835 hardware RNG (feat/pi2-arm32)

Added a hardware entropy source: hw_random() reads the BCM2835 SoC RNG (one-time enable + bounded wait for a word, then read RNG_DATA) in the already-Device-mapped peripheral window. Exposed ungated as InspectKernel query 19 (entropy confers no authority, like the raw TSC) with a random shell utility as its consumer. QEMU-verified (random 3 returns three distinct u32s). arch/arm/mod.rs unsafe 40 -> 41 (+1, one SAFETY-commented block of RNG MMIO). The other arches stub hw_random to None (no unsafe; x86 RDRAND is a trivial follow-up).

FileChangeWhy
arch/arm/mod.rs40 -> 41 (+1)hw_random reads the BCM2835 RNG (RNG_CTRL/STATUS/DATA MMIO).

2026-07-23 - BCM2835 watchdog reset (feat/pi2-arm32)

hardware_reset() on ARM was a stub that spun, so the shell reboot (and Ctrl+Alt+Del) hung the Pi 2 instead of resetting it. Implemented the BCM2835 power-management watchdog reset: one unsafe block of volatile 32-bit writes to PM_WDOG/PM_RSTC in the already-Device-mapped peripheral window, gated by the 0x5A password (the documented reset poke). QEMU-verified (a reboot re-runs the kernel from its boot banner). arch/arm/mod.rs unsafe 39 -> 40 (+1), permitted arch layer, SAFETY-commented.

FileChangeWhy
arch/arm/mod.rs39 -> 40 (+1)hardware_reset does the BCM2835 PM watchdog reset (PM_WDOG/PM_RSTC MMIO writes).

2026-07-23 - Soundness audit of the DWC2 USB unsafe (feat/pi2-arm32)

transfers, cache maintenance, the keyboard/net/storage device paths) plus 3 SDK ABI wrappers (net_frame_tx/rx/info). Audited all of them - a self-review plus an independent adversarial pass (a second reviewer briefed to find UB, checked against the actual concurrency machinery: arm_irq_dispatch, stub_svc, NEUTRAL_SCHED boot ordering, uart_rx_poll). Verdict: no memory-safety bug (UB / OOB / data race). The load-bearing facts, each verified against code not comments:

  • &mut *addr_of_mut!(DMA) never overlaps. The three accessors (ctrl_xfer/bulk_xfer/poll) are all core-0 only (MPIDR gate on poll's call site + on_core0() on the net syscalls) and mutually exclusive in time: poll runs from the timer IRQ; the net syscalls run IRQs-masked (SVC cpsid i) and never block, so the timer cannot fire mid-transfer. During boot enumeration poll is unreachable - arm_irq_dispatch routes the tick to the demo scheduler until NEUTRAL_SCHED flips after dwc2::init (this closes the one real-looking race: the hub walk keeps issuing ctrl_xfers after KBD_READY/ NET_READY are set mid-walk). This invariant is now documented at the DMA static.
  • Device-controlled lengths are all bounded before a copy (bulk_xfer HCTSIZ residual only shrinks recv; net_frame_rx smsc flen hard-checked 4 + flen > got; every descriptor-parse loop guards i + k <= total). No transfer exceeds the 2048-byte scratch buffer. No MPS0 == 0 div-by-zero.
  • NET_MAC / PREV_KEYS statics are boot-single-writer (Release) / core-0-single-accessor; net_info reads NET_MAC after an Acquire on NET_READY. SDK wrappers pass a 32-bit pointer + length <= 1600 (no ABI truncation) and the kernel range-checks every user pointer.

Two latent robustness gaps hardened (P1/P2, neither reachable by any current caller or device input): ctrl_xfer's OUT path could panic (&data[..n] with n = dlen.min(2048), not .min(data.len())) and programmed the DMA length unclamped to the scratch buffer. Both now clamp to min(d.data.len(), data.len())

  • symmetric with bulk_xfer - so a future caller with dlen > data.len() can neither panic nor DMA past the buffer. No new unsafe (edits to an existing block + comments); dwc2.rs stays at 13. One liveness (not safety) note recorded: a wedged controller spins the core IRQs-off for the bounded wait_halt timeout.

2026-07-23 - DWC2 USB keyboard: DMA mode + hub enumeration + HID poll (feat/pi2-arm32)

The slave/PIO experiment (entry below) got control transfers working on QEMU's DWC2 model only for the SETUP stage: QEMU emulates the DWC2 DMA engine, not slave/PIO, so DATA-IN never delivered bytes. Reverted enumeration to internal DMA mode - which is also how u-boot/Linux drive the Pi 2 core - and completed the keyboard: point HCDMA at the DMA scratch static, bracket every transfer with cache maintenance (flush_dcache, DCCIMVAC + dsb, the A7's DMA is not coherent), enumerate the hub the keyboard sits behind (the Pi 2's LAN9514 topology, and QEMU's NEC-hub model), select boot protocol, and poll the interrupt IN endpoint from the timer tick (poll -> decode_report -> console_push_byte). QEMU-verified end to end: keys typed on the emulated usb-kbd reach the shell. The DMA buffer address is the VideoCore bus alias 0xC000_0000 | phys on real hardware and identity (0) under QEMU, selected by the new qemu cargo feature (scripts/arm_build.py --qemu); the shipped image stays hardware-correct.

So dwc2.rs unsafe returns to 3 -> 8: the DMA path is back (flush_dcache's 2 asm blocks + the DMA-static access in ctrl_xfer and poll), plus one PREV_KEYS-static access in decode_report (the edge-trigger previous-report buffer, reached via addr_of_mut to avoid a mutable-static reference). All in permitted arch/; every block carries a SAFETY comment.

Bulk transfers (+1 -> 9): the same session added bulk_xfer (the USB bulk-transfer primitive, the shared foundation for USB mass storage and later USB-Ethernet), which accesses the DMA scratch static the same way ctrl_xfer/poll do (addr_of_mut, core-0 only, cache-bracketed). Verified in QEMU end to end against usb-storage (READ CAPACITY + READ(10) of a planted block-0 signature). The BOT/SCSI layer on top of it (bot_command, probe_mass_storage) is all safe code. So dwc2.rs is 9.

CDC-ECM USB-Ethernet (+1 -> 10): the same session added a CDC-ECM driver (configure_cdc_ecm + net_verify_arp) - raw ethernet frames over the bulk endpoints, verified in QEMU by an ARP round-trip through usb-net. The one added unsafe is a single write of the station MAC into the NET_MAC static (addr_of_mut, core-0 enumeration only); the MAC is otherwise passed as a local, and the frame build + BOT/SCSI code is all safe. So dwc2.rs is 10.

USB-net bridge to userspace (+2 -> 12): the same session added the mechanism the userspace ARM nic-driver calls (net_frame_tx/net_frame_rx/net_info, syscalls 42-44) to move ethernet frames to the in-kernel CDC-ECM device. Two added unsafe: on_core0 reads MPIDR (mrc, side-effect-free) to guard the single-channel DWC2 against off-core access; net_info reads the NET_MAC static (addr_of, read-only). Both in permitted arch/. So dwc2.rs is 12. (net_verify_arp was removed - net-stack now drives networking end to end.)

Multi-device USB + smsc95xx (+1 -> 13): the same session made keyboard/ethernet/storage coexist (per- device channel selection, all safe) and added the smsc95xx (Pi 2 LAN9514) driver. The one added unsafe is a second NET_MAC-static write (in configure_smsc95xx, core-0 enumeration only) - the register R/W, PHY/MDIO, and TX/RX-framing code is all safe. So dwc2.rs is 13.

FileChangeWhy

2026-07-23 - DWC2 control transfers via slave/PIO mode (feat/pi2-arm32)

The DWC2's internal DMA master never initiated a transfer on the Pi 2: across a dozen HW tests the channel armed (ChEna set), the host framed (HFNUM advanced) and every config register read correct, yet GRSTCTL.AHBIdle stayed 1 and HCDMA never advanced. Switched enumeration to slave / PIO mode - the mode every working bare-metal Pi USB driver uses: DMA disabled (GAHBCFG.DmaEn=0), OUT data pushed word-by-word into the NP TX FIFO and IN data popped from the RX FIFO after GRXSTSP, no bus-mastering. This removed the DMA scratch static, the flush_dcache cache-coherency bracket, and the tick-driven state machine, so dwc2.rs unsafe shrank 8 -> 3 (only the rd/wr Device-MMIO accessors + the nop spin remain; the slave-mode transfer code is all safe rd/wr). Enumeration is now synchronous (a one-time bounded boot cost).

FileChangeWhy

2026-07-22 - DWC2 USB host bring-up, increment 1 (feat/pi2-arm32)

The Pi 2's USB is a Synopsys DesignWare USB 2.0 OTG (DWC2) core. dwc2.rs brings it up in host mode and detects the attached device (the first step toward a USB keyboard): read the Synopsys core ID, soft-reset the core, force host mode, power + reset the root port, report the connected device's speed. All new unsafe is Device-mapped MMIO (the DWC2 register block is inside the already-Device-mapped peripheral window) plus a nop-spin, both permitted arch/. QEMU (-M raspi2b,usb=on -device usb-kbd): core GSNPSID=0x4f54294a, device detected + port enabled at full-speed.

FileChangeWhy
arch/arm/mod.rs38 -> 39 (+1)uart_rx_poll reads MPIDR to gate the USB dwc2::poll() to core 0 (it is the single writer of the DWC2 channel + DMA).

2026-07-22 - ARM serial input works: idle + scheduler-context fixes (feat/pi2-arm32)

The core-0 block-path idle bug (typing did nothing) is fixed. wait_for_interrupt was a bare wfi that never re-enabled IRQs, and the scheduler context was seeded with cr3=0 because the timer preempted the bootstrap before run(0) seeded it; masking IRQs before arming the neutral scheduler closes that race. New unsafe is the clrex before the serial-lock acquire (exclusive-monitor hygiene) and the cpsie i added to the idle wfi, both permitted arch/.

FileChangeWhy
arch/arm/mod.rs36 -> 38 (+2)clrex before the SERIAL_BUSY compare-exchange (clear a stale ARMv7 exclusive-monitor that wedged the shell's 2nd console echo); GPIO14/15 -> ALT0 mux in gpio_init_uart so serial RECEIVE works, not just transmit.

2026-07-16 - SEC-21 security fix (feat/hardening)

FileChangeWhy
memory/allocator.rs43 → 44 (+1)SEC-21: new safe zero_frame(phys) helper (one unsafe write_bytes block via the HHDM alias) so the AllocMem syscall can zero a frame before it becomes user-readable, closing a cross-task info leak (alloc_frame returns un-zeroed frames). Permitted memory/ layer with a SAFETY comment; keeping the unsafe here lets the caller (syscall/dispatch.rs, a grandfathered file) stay unsafe-free per §18.5.

SEC-4 (bounds-checking the SDK Dma/Mmio wrappers) adds 0 to this inventory: the SDK's permitted-layer unsafe is not tracked here (see the intro), and the change adds only safe assert! bounds checks, not new unsafe. SEC-5 (fs subtree revoke) is unsafe-free service code.

2026-07-22 - HDMI framebuffer on the Pi 2, Phase 2: text console (feat/pi2-arm32)

fbcon.rs renders the serial stream onto the framebuffer (glyphs via the shared noto-sans-mono-bitmap font), so the boot log + gsh> prompt appear on the TV; pl011_write mirrors to it under the same SERIAL_BUSY guard. New unsafe is the framebuffer pixel writes + the single FBCON static, permitted arch/. QEMU screendump: text renders (231 distinct colours in the top-left region = antialiased glyphs).

FileChangeWhy
arch/arm/fbcon.rsnew, 3put_pixel (device-mapped framebuffer store), the FBCON static in init + put_byte - the glyph renderer + cursor.

2026-07-22 - HDMI framebuffer on the Pi 2, Phase 1 (feat/pi2-arm32)

Toward x86-parity local console. The ARM has no Limine to hand it a framebuffer, so video.rs asks the VideoCore GPU for one via the mailbox property interface, mmu::map_framebuffer maps it Device, and a solid fill proves the pipeline (QEMU screendump: a clean 1024x768 blue). New unsafe is the MMIO/mailbox and framebuffer writes (video.rs) and the live-L1 mapping + TLB flush (mmu.rs), both permitted arch/.

FileChangeWhy
arch/arm/video.rsnew, 4VideoCore mailbox (Device MMIO), framebuffer writes, MBOX static access - the framebuffer acquisition + fill.
arch/arm/mmu.rs6 -> 8 (+2)map_framebuffer: write the framebuffer's Device sections into the live L1, then clean the D-cache + TLBIALL so the walker sees them.

2026-07-22 - AP bring-up: park a mis-identified core (feat/pi2-arm32)

Real Pi 2: releasing core 3 brought up a core whose MPIDR read back as 0 - it registered as a SECOND core 0, two cores ran scheduler::run(0), raced, and one crashed the boot (UNDEF halt). ap_boot_main now parks any core that finds its own id ALREADY ready (a confused/duplicate release), so the system boots reliably on the good cores. +1 unsafe: the wfi park loop.

FileChangeWhy
arch/arm/mod.rs35 -> 36 (+1)Park (wfi) a released AP whose id is already ready - the mis-identified-core guard.

2026-07-22 - AP bring-up: vectors-first + barrier (feat/pi2-arm32)

On real HW core 3's bring-up intermittently faulted BEFORE it installed its vectors, so with VBAR still 0 it branched into low memory (an UNDEF at 0x618) and halted the boot. ap_boot_main now installs the per-core vectors FIRST (before ACTLR.SMP/MMU) so any bring-up fault is REPORTED through the vectors instead of wandering, plus a dsb sy/isb to synchronize with core 0's published boot state (SEC-25/28 weak-ordering hygiene). +1 unsafe: the barrier block.

FileChangeWhy
arch/arm/mod.rs34 -> 35 (+1)dsb sy/isb barrier at the top of ap_boot_main (weak-ordering sync before an AP relies on core 0's tables/arenas). install_for_core moved ahead of the MMU enable so bring-up faults are loud, not wild.

2026-07-22 - ARM frame reclaim on task death (feat/pi2-arm32)

The ARM kill path reclaimed nothing (reclaim_user_frames was a { 0 } stub) and the neutral kill path free_framed the page-table root - fine on x86 (root = a general frame) but on ARM the root is an ARENA L1 slot, so it corrupted the frame bitmap (the alloc_frame returned kernel-range frame panic on the first respawn). Real reclaim: reclaim_user_frames walks the dying task's L1/L2, free_frames its USER pages (AP[1:0] >= 0b10; distinguished from shared kernel hole-fill pages) and returns its own L2s to the arena; the arenas gained per-slot used flags so freed L1/L2 slots are reused (a free_frame-of-root would still corrupt, so the root goes back to the arena via free_page_table_root). QEMU-proven: 15 logger kill/restart cycles, 0 panic, 0 leak/exhaustion (freed 76 frames each, was freed 0).

FileChangeWhy
arch/arm/page_tables.rs25 -> 27 (+2)reclaim_user_frames (walk L1/L2, free USER pages + L2 slots) and free_page_table_root (return the L1 root to the arena) - the two unsafe fn bodies that free a dead task's memory. alloc_l1/l2 gained CAS-on-used + free_l1/l2 (safe: atomic store + addr_of).
arch/x86_64/page_tables.rs47 -> 48 (+1)free_page_table_root = free_frame (behaviour-identical to the old inline neutral root free), so the neutral kill path can be arch-neutral for both ISAs.

2026-07-22 - Fault-survival on ARM: kill the faulting task, keep the kernel alive (feat/pi2-arm32)

The data/prefetch abort handlers went from report-and-halt to the x86 C2/A14/A15 property: a USER-mode (PL0) fault kills just that task and reschedules; a kernel fault still reports and halts. stub_dabt / stub_pabt now branch on the faulting mode (SPSR & 0x1f == 0x10) - no new unsafe (asm inside the existing naked blocks). The +1 is the wfi guard loop in the new arm_user_fault_kill, which calls the neutral kill_current() (sets the task Dead, yield_current switches to the next task). HW-verifiable; QEMU-proven: spawn greet (rigged to read address 0) -> "user task faulted ... killing it; kernel continues", and the shell + ping/pong keep running, no panic.

FileChangeWhy
arch/arm/exceptions.rs23 -> 24 (+1)arm_user_fault_kill (reached from the abort stubs in SVC mode on the faulting task's kernel stack) logs the kill loudly and calls kill_current(); the +1 is its wfi guard loop (kill_current does not return for a Dead task).

2026-07-22 - SMP: cores 1-3 online on the Pi 2 (feat/pi2-arm32)

Bring the other three Cortex-A7s online. All new unsafe is in the permitted arch/arm/ layer (§18.1), each block SAFETY-commented. QEMU-verified: smp: 4 cores ready, services placed on cores 0+1, cross-core IPC flowing, 0 faults. (Weak-memory-ordering hardening SEC-25..28 for real HW is a documented follow-up.)

FileChangeWhy
arch/arm/mod.rs33 -> 34 (+1)Core-3 lost-wakeup fix: a periodic re-sev in smp_bringup's AP-ready wait re-arms the event line for a core that entered WFE just as the first SEV fired (its mailbox is still set, so it proceeds on the nudge). HW-proven: cores 1-2 came up but core 3 hung on release until this landed; now all 4 A7s come up on the Pi 2. The +1 is the dsb/sev block. (Diagnostic breadcrumbs used to find this were removed once understood.)
arch/arm/mod.rs26 -> 33 (+7)get_lapic_id reads MPIDR (the core id - the linchpin for current_core_id); ap_entry (naked AP entry: HYP-drop, VFP, per-core stack); ap_boot_main (ACTLR.SMP + mmu::enable_on_this_core + vectors + timer, one asm block); smp_bringup (D-cache clean before release + per-core mailbox-3 SET write + dsb/sev). The arm_ap_park release loop is global_asm! (not counted as a Rust unsafe block).
arch/arm/mmu.rs4 -> 6 (+2)Split enable into build_tables + enable_on_this_core (a pub unsafe fn, +1) so each AP loads the SAME L1 into its TTBR0; core 0 calls it too. The register-write blocks are unchanged; the +2 is the new unsafe fn wrapper and core 0's call site.
arch/arm/exceptions.rs21 -> 23 (+2)install_for_core(core) gives each AP its OWN banked ABT/UND/IRQ/FIQ stacks (BSS AP_MODE_STACKS) instead of the shared linker-symbol stacks - two cores taking a timer IRQ at once would otherwise corrupt the one IRQ stack. The +2 are the raw-pointer stack-top computation and the VBAR/banked-SP asm block.
arch/arm/irq.rs10 -> 11 (+1)this_core() reads MPIDR so the dispatch reads THIS core's CORE_IRQ_SOURCE/CORE_TIMER_IRQCNTL (+4*core), and start_tick_ap routes each AP's own timer. The +1 is the MPIDR read.

2026-07-22 - The interactive shell on ARM (feat/pi2-arm32, increment 5)

FileChangeWhy
arch/arm/mod.rs23 -> 26 (+3)Real console I/O: console_write_bytes_gated -> pl011_write (output); a PL011-RX -> input-ring path (pl011_rx_drain, uart_rx_pop, uart_rx_poll, uart_rx_drain_now, console_push_byte, set_input_ready/input_ready) so the shell reads serial input via ConsoleRead. The +3 unsafe are the three MMIO/ring blocks (pl011_rx_drain, uart_rx_pop, console_push_byte).
arch/arm/exceptions.rsunchanged countstub_svc now saves/restores the caller's USER-banked SP_usr/LR_usr around the syscall (asm inside the existing naked block, no new unsafe). A syscall that blocks (recv/console_read) switches to another USER task, which clobbers the shared USER bank; the shell, woken from console_read, resumed on the logger's shallow SP and faulted just above the stack top. Saving on the task's own kernel stack (like stub_irq's trap frame) fixes it.

The interactive shell runs on ARM. gsh> prompt, reads serial input, echoes, and executes commands: help prints the command list, version prints GodspeedOS 0.7.0. 0 faults. The committed increments are unregressed (IPC 6600+ messages, supervisor bootstrap - both 0 faults - with the stub_svc USER-bank change). New ARM boot: arm-shell (sched_shell.rs); the shell is built for ARM (arm_built += shell). x86 unchanged (all changes in arch/arm/; the shell-spawn helper is #[cfg(target_arch = "arm")]).

2026-07-21 - The NEUTRAL spawn works on ARM (feat/pi2-arm32, increment 4a)

FileChangeWhy
arch/arm/page_tables.rs23 -> 25 (+2)finalize_service_address_space(cr3) - the arch hook the neutral spawn calls after building a service page table: clones the kernel identity into it + cleans the D-cache (ARM has no shared higher-half kernel). The unsafe fn + its block.
arch/x86_64/page_tables.rs46 -> 47 (+1)The x86 finalize_service_address_space is a pub unsafe fn no-op (kernel is shared higher-half); the empty unsafe fn is the +1.
arch/arm/mod.rs22 -> 23 (+1)syscall_slot now returns a real per-core PerCoreSyscallData (an addr_of_mut unsafe) instead of null: the neutral spawn commits is_user=true, and prepare_ring3_switch writes through this pointer for every user task. Also added the safe note_user_task hook (irq::mark_task_user; no unsafe).
task/mod.rsunchanged (7, at the grandfathered floor)The neutral spawn_service_with_config gained ONE line calling finalize_service_address_space, and arm_spawn_logger_neutral (an ARM-only pub probe). The finalize call was folded into the existing unsafe { TaskContext::new_user } block so task/'s floor holds (§18.5) - no amendment. scheduler::commit_task gained a safe note_user_task hook call (no unsafe).

The neutral spawn machinery runs unchanged on ARM. task::spawn_service_with_config - the exact path the supervisor's spawn syscall uses (ELF load, user-stack + ctx-page map, kstack-pool alloc, cap minting, ServiceContext write) - spawns the logger on ARM: task: 'logger' spawned OK on core 0 (slot 0) -> logger: ready. The two ARM-specific steps are now arch-seam hooks the neutral code calls itself (both no-ops on x86, so x86 is byte-for-byte unchanged - verified it still compiles). This is the foundation the supervisor stands on. Gated behind arm-sched-spawn.

2026-07-21 - Atomic syscalls + CLREX on ARM (feat/pi2-arm32, increment 3b hunt cont'd)

FileChangeWhy
arch/arm/irq.rs9 -> 10 (+1)arm_irq_dispatch reads the interrupted CPSR from the trap frame (frame_sp + 68) to implement atomic syscalls: skip timer preemption when a USER task is in SVC (a syscall), since preempting ARM kernel code mid-syscall corrupts (SPSR_svc + SVC-banked sp are shared). Gated on ARM_TASK_IS_USER[slot] (set by mark_task_user) so a kernel task running in SVC stays preemptible. The +1 unsafe is the frame read.
arch/arm/context_switch.rsunchanged countAdded clrex at the top of switch_context: a voluntary switch does not implicitly CLREX like an exception entry, so a task switched out mid-ldrex/strex could leak the exclusive monitor and wedge a SpinLock. Inside the existing naked block, no new unsafe.
arch/arm/mod.rsunchanged countDoc-only: syscall_slot stays null (ARM tracks user tasks arch-locally, so the neutral prepare_ring3_switch never runs and never derefs it).

Status: the mid-syscall preemption FAULT is fixed (verified: no EXCEPTION over 30 s, sched_demo/ sched_user still rotate/preempt); the IPC still hangs on a residual corruption across the voluntary syscall-context switch_context (block_and_reschedule's slot local, asserted < MAX_TASKS at entry, reads back garbage at the tail). Full diagnosis in sched_ipc.rs. The SPSR-window fix (stub_svc cpsid i) from the prior commit stays.

2026-07-21 - Cross-service IPC wiring (feat/pi2-arm32, increment 3b - WIP, blocked on a diagnosed bug)

FileChangeWhy
arch/arm/spawn.rsunchanged countRefactored to expose load_service_raw(elf, extra_caps) + map_stack_and_ctx (shared with sched_ipc): load any service ELF, map its stack/ctx, reserve a slot, install LOG_WRITE + caller-supplied endpoint caps, leaving the ctx write + fill_kernel_identity to the caller. load_logger_into_slot now rides on it. No net unsafe change; load_service_raw's block is the cap inserts.
arch/arm/sched_ipc.rs6 -> 9 (+3)Rewritten from the 2-logger frame proof to a real ping->pong IPC attempt: create pong's endpoint (the spawn_service_with_config sequence - alloc_endpoint_id + register resource/routing/name), mint a RECV cap for pong and a SEND cap for ping, hand-build both ServiceContexts (write_ipc_ctx), and commit both as scheduled USER tasks. The +3 unsafe is write_ipc_ctx (raw ctx writes), commit_user, and the halt WFI. build.rs now builds ping/pong for armv7a-none-eabi. Gated behind arm-sched-ipc.

Status: the wiring is correct, the runtime is blocked on a diagnosed kernel bug. Verified: the ctx is wired correctly (a dump confirmed send_peer_count=1, peer0.slot=1, name="pong"), both services reach PL0 and log (ping: starting, pong: ready on core 0). But once ping loops issuing syscalls alongside a second running user task, a task's registers are corrupted and it jumps to a wild PC (garbage syscall numbers, then a DATA ABORT to 0xfffffeae from a PC in a data page). Bisected: pong's blocking recv alone is fine; ping ALONE (self-scheduling) loops forever clean; ping+pong together corrupts. So the fault is in a real cross-task switch_context reached from a syscall context (yield/block) - a path #1/#2/#3a never exercised (they switch only via the timer IRQ; #3a's two user tasks busy-loop on a non-blocking recv, never yielding). The USER-banked SP_usr/LR_usr were ruled out (saving them in both stub_svc and switch_context left the corruption unchanged, so those attempts were reverted). Next leads: the AAPCS callee-saved contract across a syscall-context switch between two different user address spaces (TTBR0 change), or SVC-stack nesting when the timer preempts a task mid-syscall. The 2-logger banked-frame proof it replaced is preserved in commit 3e6cb3f; the banked frame (stub_irq) stays, and ping+pong both reaching PL0 still exercises it. The committed increments (#1/#2/#3a) are unregressed (default preempt selftest PASS 9/9/9, sched-user logger: ready).

2026-07-21 - Two USER services at once: the banked-register trap frame (feat/pi2-arm32, increment 3a)

FileChangeWhy
arch/arm/exceptions.rsunchanged countstub_irq now stacks the interrupted task's USER-banked SP_usr/LR_usr (stmdb r0, {sp, lr}^ on save, ldmia r0, {sp, lr}^ on restore) - the prerequisite for more than one user task. With one, nothing else touched the USER bank across a round trip; with two, task B's ring-3 execution would clobber task A's user stack unless it is saved per task. The extra instructions live in the existing naked block, so no new unsafe. Frame grew 16 -> 18 words.
arch/arm/context.rsunchanged countTrapFrame gains usr_sp/usr_lr (matching the 18-word layout) and prepare_task zeroes them for kernel tasks (their USER bank is unused). Struct/field change only, no new unsafe.
arch/arm/page_tables.rsunchanged countL1 arena 2 -> 8, L2 arena 16 -> 64: the boot loader selftest takes one L1 and each live service takes one, so two only left room for a single service. Sized (bounded static, §26.6.1) for the running service set - IPC pair, supervisor, shell. Constants only, no unsafe.
arch/arm/sched_ipc.rs0 -> 6 (new file)Loads two logger instances as scheduled USER tasks (each its own address space) plus two kernel spinners, and runs them under scheduler::run. The isolation test for the banked frame; grows into real send/recv (increment 3b). The unsafe is the static-stack setup and the new_user/new_kernel/commit_task calls. Gated behind arm-sched-ipc.

What this proves. Two independent USER services run concurrently in ring 3 under the scheduler and both reach PL0 and issue their cap-validated syscall (logger: ready twice) with no corruption and no fault. Were the banked frame wrong, the second user task's ring-3 execution would clobber the first's SP_usr and one would fault - so "both ready, no fault, system live" is the proof the per-task SP_usr/LR_usr save/restore is correct. This is the trap-frame foundation IPC stands on. Verified in QEMU (raspi2b); the default image is unregressed (preempt selftest PASS 9/9/9).

2026-07-21 - A USER service runs through the scheduler, preemptively (feat/pi2-arm32)

FileChangeWhy
arch/arm/context_switch.rs14 -> 13 (-1)new_user is now real: it builds a context whose first switch_context drops to PL0 via a new user_entry_trampoline (installs the user stack, fabricates a USR-mode SPSR with IRQs on, movs pc). The loud user_mode_unimplemented stub it replaced is deleted - net one fewer unsafe. switch_context also gained a TLBIALL on the TTBR0-change branch (SEC-26/27: an ARM address-space switch does not implicitly flush), inside the existing naked block (no new unsafe).
arch/arm/page_tables.rs21 -> 23 (+2)clean_invalidate_dcache_all (set/way DCCISW) moved here from spawn.rs as the shared home for cache maintenance: it makes a service's page-table descriptors visible to the non-cacheable walker once at spawn, so switch_context needs no per-switch cache work. The unsafe fn + its asm block are the +2.
arch/arm/spawn.rsunchanged countRefactored to expose neutral_bootstrap + load_logger_into_slot (shared with sched_user) and to call page_tables::clean_invalidate_dcache_all rather than a private copy. No net unsafe change.

What this proves. A real GodspeedOS service (logger) runs through the neutral scheduler on ARM, not entered directly: loaded into its own address space, committed to a task slot, and preempted in ring 3 by the timer (its trap frame lands on its own kernel stack), while spinning kernel tasks are round-robined around it. logger: ready is its cap-validated syscall, issued from PL0 under its own TTBR0 - so the per-task page table, the switch_context TTBR0-swap + TLBIALL, and the one-shot descriptor D-cache clean all hold end to end. Verified in QEMU (raspi2b): logger: ready once, kernel tasks advancing past tick 3, no fault. Single user task by design: a second one needs stub_irq to also stack the banked SP_usr/LR_usr (the next increment); with one, nothing else touches the USER bank across the round trip. The default image is unregressed (preempt selftest PASS 9/9/9, neutral surface PASS).

2026-07-21 - Timer preemption via the neutral scheduler (feat/pi2-arm32)

FileChangeWhy
arch/arm/irq.rs8 -> 9 (+1)arm_irq_dispatch now routes the timer tick to the neutral timer_tick_from_irq (the preemptive switch_context path) once NEUTRAL_SCHED is set, instead of the early context.rs demo scheduler. The +1 unsafe is the timer_tick_from_irq call.
arch/arm/sched_demo.rsunchanged countThe demo tasks now spin (no yield) and arm NEUTRAL_SCHED, proving the timer preempts a non-cooperating task.

The mechanism, and why the same IRQ stub serves both paths. The stub saves the full interrupted frame on the task's kernel (SVC) stack and calls arm_irq_dispatch, which returns an sp the stub adopts (mov sp, r0). The early demo scheduler returns a different task's frame to adopt. The neutral timer_tick_from_irq instead does the switch_context INTERNALLY - it swaps sp to the next task's kernel stack itself - so arm_irq_dispatch returns frame_sp unchanged, and after this task is later resumed (switch_context unwinding back into the call), frame_sp again names THIS task's frame, making the mov sp a no-op. One stub, two mechanisms.

Non-yielding tasks are genuinely preempted, proven by output interleaved mid-print (a tick caught a task between arbitrary instructions). The boot preempt_selftest still uses the demo path (NEUTRAL_SCHED defaults false) and still passes 9/9/9, so the default image is unregressed. This is the preemption real services need (they block on recv, they do not yield).

2026-07-21 - Neutral scheduler runs tasks on ARM (feat/pi2-arm32)

FileChangeWhy
arch/arm/sched_demo.rs0 -> 6 (new file)Commits three kernel tasks and enters the neutral scheduler::run(0); it round-robins them (A->B->C->A...) via pick_next + switch_context + yield_current. Proves the neutral scheduler - the foundation the supervisor and every service stand on - runs on ARM. The unsafe is the static-stack setup, the new_kernel/commit_task calls, and the BootInfo construction for the neutral bootstrap. Gated behind arm-sched-demo.

Cooperative first, deliberately. The tasks yield (a scheduling point), so this exercises the scheduler's task table + switch_context without the timer-preemption rework - running the timer IRQ on per-task kernel stacks so it can switch_context a non-yielding task - that real services need. That is the next increment; this proves the layer beneath it. No per-task page tables (all tasks share the kernel identity map), so a switch never changes TTBR0 and the D-cache dance from the service spawn does not arise.

2026-07-21 - Minimal service spawn (feat/pi2-arm32)

Increment 6 groundwork: enough to load a real service, set up its task + capability, and run it at PL0 issuing syscalls. All in the permitted arch/ layer with SAFETY comments; no grandfathered floor moves (the one neutral helper, set_current_task, is a safe fn - the atomic store is not UB - so scheduler.rs stays at its floor).

FileChangeWhy
arch/arm/spawn.rs0 -> 7 (new file)The minimal spawn: build a BootInfo, run the neutral memory/percpu/capability init, load the logger ELF, map its user stack + service-context page, reserve a task slot with a LOG_WRITE cap, clone the kernel into the service address space, switch TTBR0, and drop to PL0.
arch/arm/page_tables.rs19 -> 21fill_kernel_identity: clone the live kernel identity map into a service page table (whole-section where the service slot is empty; page-fill the L2 where the service tabled-over a kernel section, so kernel data sharing the ctx/code 1 MiB stays reachable).
arch/arm/mod.rs21 -> 22The interrupts module's disable/enable/local_irq_save/restore/wfi are now REAL (cpsid/cpsie/wfi), the ARM read/write_user_bytes/validate_user_ptr are real, switch_to_boot_stack sets SP, and ACTLR.SMP is set at boot.

MILESTONE REACHED: logger: ready. A real GodspeedOS service, loaded from an ELF, runs unprivileged (PL0) on 32-bit ARM under its own address space, and logs through a capability-checked svc into the neutral dispatcher. clean_invalidate_dcache_all (a set/way DCCISW sweep, +1 unsafe) before the TTBR0 switch was the final fix: the kernel maps its memory as 1 MiB sections but the service maps the shared 1 MiB as 4 KiB pages, and stale D-cache lines from the section view made the cap-table spinlock's LDREX/STREX fail under the page view. Cleaning the D-cache makes every line coherent before the walker and exclusive monitor see the new mappings, and the lock acquires. Gated behind arm-spawn-logger so the default image still boots to the selftest halt; the feature build runs the service to ready.

2026-07-21 - ARMv7 user mode / PL0 (feat/pi2-arm32)

FileChangeWhy
arch/arm/usermode.rs0 -> 15 (new file)A task runs UNPRIVILEGED for the first time. Enters USR mode (PL0), runs a stub that cannot touch kernel memory, and has it svc back. The unsafe is: enter_user (the fabricated exception return that drops to PL0), resume_boot (restores the kernel context on the magic svc), the user stub, I-cache sync for the copied code, the ATS1CPUR/W unprivileged translation probes, and the frame copy/map in the selftest.
arch/arm/page_tables.rs19 (unchanged)l2_small_page now encodes PL0 access from the USER flag: AP=0b11 (PL0 RW), 0b10 (PL0 RO), 0b01 (PL0 none) - the page's whole two-level security model. No new unsafe.
arch/arm/exceptions.rs / syscall.rsunchanged countsThe SVC entry publishes the caller's SPSR (so a syscall can see its privilege), and the magic test syscall routes to on_magic_svc.

Entering USR mode is a fabricated exception return. No iret: set SPSR to USR (IRQs enabled), set LR to the entry PC, arrange the USR banked SP (via a brief system-mode switch), and movs pc, lr - which copies SPSR->CPSR and LR->PC atomically, dropping privilege. The ARM analogue of x86's IRETQ.

The proof of PL0 is the SPSR at the svc, not that the code ran. The CPU records the caller's mode in SPSR_svc; SPSR.mode == 0x10 (USR) is unforgeable evidence the stub executed unprivileged. The selftest checks exactly that (observed 0x10), and separately probes the permission model with the unprivileged-access translation ops (ATS1CPUR/W): user code is user-readable, user stack user-writable, and a KERNEL page is NOT user-accessible - isolation, proven non-faulting. Getting back out with no scheduler: enter_user saves the kernel context first; the magic svc restores it.

2026-07-21 - ARMv7 SVC syscall entry (feat/pi2-arm32)

FileChangeWhy
arch/arm/syscall.rs0 -> 5 (new file)The SVC syscall entry - svc #0 traps into the neutral syscall_handler. The unsafe is arm_svc_dispatch (forwards to the unsafe neutral handler) and the issue_svc/selftest asm.
arch/arm/exceptions.rs21 (unchanged)The SVC vector went from report-and-halt to a real entry: save LR_svc/SPSR_svc, call the dispatcher, movs pc, lr to return restoring CPSR. No new unsafe block - the naked stub was already one.

Two ARM-specific things had to be right, and one was a bug. (1) SVC targets SVC mode and the kernel already runs in SVC, so LR_svc/SPSR_svc are saved first thing like a nested exception, or the next bl clobbers the return address; done that way the entry works from a USR caller (real tasks) and an SVC caller (the selftest) alike. (2) The 32-bit ABI bug: syscall_handler takes u64 parameters, and on 32-bit ARM each u64 is a register pair (number in r0:r1, arg0 in r2:r3, rest on the stack). Passing the four r0-r3 values to a u64-parameter function read the arguments shifted - it showed up as a wrong echo (7400 vs 7345). arm_svc_dispatch takes u32s (one register each, matching r0-r3) and widens to u64 for the neutral call; most syscall arguments on this arch (pointer, handle, cap slot, length) fit in 32 bits, so the widening is loss-free for them. The one exception is a value that can exceed 32 bits - a recv_timeout in generic-timer ticks - which the single-register ABI would truncate; its SDK wrapper pre-clamps it on ARM (userspace-audit A-U1). The ABI convention + this constraint are now documented for SDK/service authors in arch/arm/CLAUDE.md. That widening is the seam the SDK port uses.

No user tasks yet (increment 3), and the real handlers touch per-task state, so the selftest proves the entry mechanism through a test dispatch (a mix of all four args, so a correct result proves each survived the mode switch) and leaves syscall_handler wired for when tasks arrive. A second trap confirms the path is re-entrant.

2026-07-21 - Neutral frame allocator live on ARM (feat/pi2-arm32)

FileChangeWhy
arch/arm/meminit.rs0 -> 4 (new file)Wires the neutral memory::init on ARM - the first shared (non-arch-layer) subsystem running on 32-bit ARM, and the prerequisite for per-task page tables and service spawn. Builds a BootInfo from the DTB memory map + linker kernel bounds, reserves the kernel image as a low region, and runs the neutral bitmap allocator. The unsafe is the static mut MEM_REGIONS/BootInfo construction, the __fiq_stack_top linker-symbol read, and reconstructing a Frame to free in the selftest.
memory/allocator.rs + 7 arch page_tablesguard relaxed + PHYS_IS_IDENTITY constThe allocator panicked on hhdm == 0 ("HHDM offset not set"). That is true on x86/Limine but WRONG on ARM: the kernel runs identity-mapped, so hhdm=0 is the correct value (hhdm + phys == phys already addresses the frame). Fixed the boundary-correct way (arch/CLAUDE.md): each arch declares page_tables::PHYS_IS_IDENTITY (true on ARM, false elsewhere), and the guard only fires where a zero offset genuinely means "unset". x86 identity 24/24 confirms the shipping arch is unaffected.

memory::init fit the neutral allocator unchanged because two ARM facts line up with what it wants: hhdm=0 works (identity map), and protect_kernel_page_table_frames - the one Limine-table-specific step - already returns early when hhdm == 0, a clean no-op rather than a special case. Result on hardware-shaped input: frame allocator ready (946 MiB free), and the selftest allocates 8 distinct page-aligned frames, checks the free count drops by 8, frees them, and checks it returns to baseline.

2026-07-21 - ARMv7 two-level page tables (feat/pi2-arm32)

FileChangeWhy
arch/arm/page_tables.rs0 -> 17 (new file)Real two-level 4 KiB page tables, replacing the compile-only stub inline in mod.rs. mmu.rs gave 1 MiB sections; this gives an L2 table under an L1 entry, so individual pages carry their own permissions. The unsafe is: the TTBR0/TLB primitives (invalidate_tlb_page = TLBIMVA, read/write_page_table_base), the descriptor writes into the live and fresh L1/L2 tables, the static-arena table allocators, and the ATS1CPR/ATS1CPW translation probes the selftest uses.

The read-only proof needs no fault. ATS1CPW runs a privileged-write address translation and reports the result in PAR.F - so a read-only page returns "denied" for a write while ATS1CPR (read) still returns its address. The selftest maps one page RW and one RO into the live tables and checks: both translate for read, RW is writable, RO is not. The negative is the load-bearing one (same discipline as the MMU and IOMMU selftests): "RW translates" only shows the L2 was built; "RO refuses a write" shows the AP/APX permission bits are actually enforced. That is real per-page protection, the thing 1 MiB sections could not give.

The frame source is a bounded static arena, deliberately. x86's PageTable::new pulls table frames from the neutral alloc_frame, which needs memory::init + a real memory map - and that pulls in Limine-shaped assumptions (protect_kernel_page_table_frames) that are a separate integration step. So table memory is a fixed static arena here (§26.6.1), with the alloc_frame swap called out as the one remaining seam. The algorithm - build an L2, point an L1 entry at it, encode the page with its permissions - is the real one the neutral path will drive unchanged. map_in_active_tables fills a currently-unmapped L1 slot (a VA in the gap between RAM end and the peripherals) rather than converting a live section, so running code is never momentarily unmapped.

2026-07-21 - Neutral context-switch surface, real (feat/pi2-arm32)

FileChangeWhy
arch/arm/context_switch.rs0 -> 14 (new file)The real arch::imp::context_switch surface the neutral scheduler imports: TaskContext, new_kernel/new_user, and the naked switch_context, plus a selftest driving a kernel task through that exact neutral API. The unsafe is the two naked asm fns (switch + first-entry trampoline), the constructors, user_mode_unimplemented, and the selftest's static/ptr manipulation. Replaces the compile-only stub that was inline in mod.rs.
arch/x86_64/context_switch.rs + 5 arch stubs+1 line each (TaskContext::ZERO)A neutral leak fixed by an arch primitive, not a special case (arch/CLAUDE.md). The scheduler built a zero context with a literal TaskContext { rbx: 0, ... }, naming x86 registers in neutral code - which does not compile once ARM's TaskContext is ARM-shaped. Each arch now exposes const ZERO: Self; the scheduler uses TaskContext::ZERO, naming no register.

Kernel-only integration, honestly scoped. The neutral scheduler spawns only ring-3 tasks (new_user, from ELF binaries) - it has no path that calls new_kernel - so scheduler::run() end-to-end genuinely needs userspace, which ARM does not have (0-byte service placeholder). What is provable kernel-only is the scheduler's core primitive: TaskContext::new_kernel + switch_context driving an ARM kernel task, which the selftest exercises through the neutral types.

The switch mirrors x86 semantics exactly, and a bug proved it. Like x86, the ARM switch saves callee-saved + sp + lr but not cr3 (it only loads TTBR0). The first selftest faulted in a loop with TTBR0=0 - because SCHED_CTX.cr3 stayed zero from its ZERO init, and switching back loaded it. That is the exact gotcha x86 documents at scheduler.rs ("seed the scheduler context's CR3 ... switch_context never saves CR3, only loads it"); reproducing it confirms the semantics match. The fix seeds SCHED_CTX.cr3 with the live TTBR0, as the neutral run() does. new_user builds a context that halts loudly if entered - ring-3 needs an SPSR return, per-task page tables, and SVC syscalls, none of which exist yet, so a premature user spawn fails visibly rather than running undefined.

2026-07-20 - Device tree parsing: learn the memory map (feat/pi2-arm32)

FileChangeWhy
arch/arm/dtb.rs0 -> 6 (new file)Flattened Device Tree parsing. Six SAFETY-commented blocks, all bounds-checked reads of the firmware-supplied blob: big-endian u32 reads, node-name comparison, and reading DTB_PTR itself. Every offset is checked against the blob's own declared totalsize before being walked - a corrupt header pointing outside the blob is exactly how a parser wanders into unmapped memory.
arch/arm/mod.rs+1 asm site (no new unsafe block)_start now stashes r2 (the DTB pointer) into r10 before the mode check clobbers r0-r2, and publishes it into DTB_PTR after the BSS zero - which would otherwise wipe it.

Why this stops being optional here. Every layer so far tolerated a hardcoded RAM_END, copied from what the firmware told Linux, with a comment admitting that was not how a real port should learn it. That is fine while nothing depends on it, and stops being fine the moment the neutral kernel's frame allocator does: a wrong constant hands out frames backed by memory that does not exist. The firmware already knows the answer and passes it in r2.

FDT is big-endian on a little-endian CPU, so every u32 needs swapping - the most common way to get nonsense from this format, hence a single be32 rather than byte-swapping at each site. The parser is deliberately minimal: find /memory, read reg, stop. It does not pretend to general #address-cells handling it has not implemented.

A missing or unparsable blob falls back to the old constant but announces it (invariant 12), because a silently wrong memory size becomes allocator corruption much later, far from its cause. Note QEMU cannot exercise the real path here: -device loader sets the PC without emulating the firmware's r0/r1/r2 handoff, so DTB_PTR is 0 there and only hardware tests the parse.

2026-07-20 - ARMv7 PREEMPTIVE switch (feat/pi2-arm32)

FileChangeWhy
arch/arm/context.rs4 -> 6 (+2)Preemptive switching. Two further SAFETY-commented blocks: fabricating a trap frame on a fresh task's stack, and the WFI halt for a task that returns from its entry function.
arch/arm/exceptions.rs21 (unchanged)stub_irq grew from a five-register handler into a full trap-frame entry: srsdb + cps + push {r0-r12, lr}, dispatch, mov sp, r0, pop, rfeia sp!. No new unsafe - the naked stub was already one block.

Cooperative and preemptive switching are genuinely different problems, and the difference is AAPCS. A cooperative switch happens inside a function call, so the compiler has already spilled the caller-saved half and ten registers suffice. A preemptive switch is forced between two arbitrary instructions with anything live, so the entire register file plus the resume PC and SPSR must be captured.

The ARMv7 obstacle is register banking: on IRQ entry the CPU is in IRQ mode, where the interrupted mode's sp and lr are banked away and unreachable. srsdb sp!, #0x13 reaches across that by pushing LR_irq/SPSR_irq onto the SVC stack; cps #0x13 then stands on the interrupted task's own stack to save the rest. The frame therefore lives on the task's own stack, which is what makes a switch cheap - the state is already parked where it belongs, so switching tasks is switching sp and nothing else. The dispatcher returns the frame to resume; returning a different pointer is the entire mechanism of preemption. rfeia sp! restores PC and CPSR atomically.

TrapFrame's field order mirrors the push order and is as load-bearing as Context's; a mismatch would resume tasks with scrambled registers, looking like random corruption far from the cause. A fresh task is started by fabricating its frame rather than special-casing "never run" in the switch - the same trick as Context::prepare, one layer down. SPSR is set with IRQs enabled: a task started with them masked would run to completion and never yield, silently killing preemption with no error anywhere.

The selftest runs three tasks that never cooperate and checks all three were scheduled - a switch that always picked one task, or worked once then wedged, would still show a task running.

2026-07-20 - ARMv7 kernel context switch (feat/pi2-arm32)

FileChangeWhy
arch/arm/context.rs0 -> 4 (new file)Cooperative kernel-mode context switch. The switch itself is a three-instruction naked fn (stmia/ldmia/bx lr); the remaining blocks are the ping-pong selftest driving it. Only ten registers are saved and that is not a shortcut: AAPCS makes r0-r3/r12 caller-saved, so the compiler has already spilled anything live at the call site, leaving the switch responsible for r4-r11, sp, lr - the same division as the x86 side.

Context's field order is load-bearing: stmia/ldmia transfer in increasing register number regardless of how the list is written, so the struct must read r4..r11, sp, lr under repr(C). Reordering the fields would silently restore registers into the wrong slots.

A fresh context is started by fabricating its lr as the entry point, so the ordinary restore path starts it - no special case in the switch. The selftest checks the round trip rather than mere arrival: the counter is incremented by the other context and read back, which only works if state survives in both directions (a half-working switch that transfers control but corrupts registers is the dangerous case).

This is cooperative - called, not forced. A preemptive switch from the timer IRQ must save the full register file, because an interrupt can land between any two instructions with anything live. That is the next increment. No address-space switch either: all contexts share the identity mapping, and per-task TTBR0 writes bring the SEC-26/27 TLB obligations with them.

2026-07-20 - BCM2836 interrupt controller / timer tick (feat/pi2-arm32)

FileChangeWhy
arch/arm/irq.rs0 -> 8 (new file)Routing the timer IRQ so the counter becomes a tick - the prerequisite for preemption. Eight SAFETY-commented blocks: volatile read/write of the Device-mapped BCM2836 core-local block (routing + pending), CNTP_TVAL and CNTP_CTL writes to arm the timer, CNTP_CTL and CPSR reads for diagnostics, and cpsie i / cpsid i to unmask and mask IRQs.
arch/arm/exceptions.rs21 (unchanged)The IRQ stub changed shape without changing its count: it now saves r0-r3, r12, lr, calls the dispatcher and returns via ldm sp!, {r0-r3, r12, pc}^ (the trailing ^ restores CPSR from SPSR atomically). It is the only exception in the port that returns rather than halting.

The tick selftest caught a real routing bug, and the diagnostics located it precisely. The first version counted zero interrupts. The follow-up print made the cause unambiguous: CNTP_CTL read 0x5 (ENABLE set, IMASK clear, ISTATUS set - the timer was firing), CPSR showed SVC mode with IRQs unmasked, yet the core-local pending register read 0x0. Timer firing + interrupts enabled + nothing pending means the timer was raising a source nobody was listening to.

Cause: CNTP_* addresses the secure OR the non-secure physical timer depending on the CPU's security state, and those are two different interrupt sources - CNTPSIRQ (bit 0) and CNTPNSIRQ (bit 1). The Pi firmware enters an ARMv7 kernel in HYP (non-secure), so hardware raises bit 1; QEMU's raspi2b stub passes through the secure monitor into secure SVC and raises bit 0. Routing only the non-secure bit therefore worked on neither in the same image. The fix routes and accepts both, exactly as _start accepts either HYP or SVC entry: one image, either security state, no assumption left to be wrong about.

2026-07-20 - ARMv7 generic timer (feat/pi2-arm32)

FileChangeWhy
arch/arm/timer.rs0 -> 4 (new file)ARM generic timer + BCM2835 System Timer. Four SAFETY-commented blocks, all side-effect-free reads: CNTFRQ (the firmware-programmed frequency), CNTPCT via mrrc into a register pair (the 64-bit counter, with an ISB so the read is not reordered), a volatile read of the System Timer's counter-low register, and local_reg reading the BCM2836 core-local block (timer control + prescaler). The last two are in ranges mmu.rs maps as Device memory.

ARM needs no timer calibration - CNTFRQ reports the frequency architecturally, so the whole x86 PIT-calibration apparatus (and the ~1 second-quantum bug it existed to fix on the T630) has no counterpart here.

But CNTFRQ is still cross-checked, because it is firmware-programmed rather than hardware-discovered. It is an ordinary read/write register that firmware is supposed to set; firmware that forgets leaves it 0 or wrong, and every duration derived from it is then silently wrong - surfacing much later as mysterious timing bugs. The Pi carries a second, independent clock (the BCM2835 System Timer, fixed at 1 MHz by hardware), so the selftest measures one against the other over 100 ms and compares the result with what CNTFRQ claims. That turns "the register says 19.2 MHz" into "two independent clocks agree on how long a second is". A zero CNTFRQ is reported loudly and degrades to the System Timer rather than computing nonsense (invariant 12).

The cross-check immediately paid for itself: on the Raspberry Pi 2, CNTFRQ is wrong by 19.2x. Hardware reports CNTFRQ = 19200000 while the counter measurably advances at 1 MHz. The BCM2836 feeds the generic timer through a core timer prescaler (0x4000_0008) at source * prescaler / 2^31; firmware programs 0x06AAAAAB, which divides the 19.2 MHz crystal to exactly 1 MHz, and then never updates CNTFRQ - so the register still advertises the undivided crystal. Trusting it would have made every delay and every scheduler quantum wrong by 19.2x, with the symptom appearing far from the cause. QEMU cannot reproduce this: it does not model the prescaler (both registers read 0) and its CNTFRQ is truthful, so only hardware could have caught it. timer_hz() therefore returns the measured rate, never CNTFRQ, and the selftest distinguishes a deviation explained by the prescaler (a known board quirk, reported and continued) from an unexplained one (a real failure).

2026-07-20 - ARMv7 MMU (feat/pi2-arm32)

FileChangeWhy
arch/arm/mmu.rs0 -> 4 (new file)ARMv7 short-descriptor translation, 1 MiB sections. Four SAFETY-commented blocks: filling the L1 table (static mut, boot-only, secondaries parked and MMU off so nothing is walking it); the enable sequence (TLB/BP/I-cache invalidate, DACR=client, TTBCR=0, TTBR0, then SCTLR.M, with the DSB/ISB pairs the ARM ARM requires); enabling caches afterwards; and translate(), which runs the CPU's own table walker via ATS1CPR and reads PAR. translate is deliberately safe to call on an address expected to be UNMAPPED - a failed walk sets PAR.F rather than raising an exception, which is what lets the selftest prove the table bounds anything.

The MMU is the gate on task isolation, and it comes after the vectors on purpose: a bad mapping is a translation fault, and without a vector table that fault is a silent hang rather than a printed translation fault (section) - NOT MAPPED.

The selftest checks a negative, not just a positive (same reasoning as the x86 IOMMU selftest, §22 Test 12): confirming that mapped addresses translate only shows the table is non-empty, so it also confirms that an address outside every mapped range does not translate. The three checks are mutually validating - a broken translate() that always failed would break checks 1-2, and a blanket identity map would break check 3.

2026-07-20 - ARMv7 exception vectors (feat/pi2-arm32)

The 32-bit ARM port gains its vector table. All additions are in the permitted arch/ layer with // SAFETY: comments, so no §18.5 amendment is needed and no grandfathered floor moves.

FileChangeWhy
arch/arm/exceptions.rs0 -> 21 (new file)ARMv7 exception vectors. Until this existed, ANY fault on ARMv7 was a silent lockup - no vector table means the CPU jumps to whatever sits at address 0 and wanders off, which is exactly the silent failure invariant 12 forbids. The count is dominated by the eight one-instruction vector entries plus their naked stubs (each loads the exception kind, the LR-adjusted faulting PC, and DFSR/DFAR or IFSR/IFAR, then branches to a common reporter). install() holds one block that programs VBAR and primes the ABT/UND/IRQ/FIQ banked stacks; trigger_test_fault() holds one deliberately-unsound read behind the arm-fault-test feature, which is the ARM twin of the x86 A14/A15/C2 adversarial fault tests - a fault path never observed firing is not evidence that it works.

ARMv7 trap worth recording: FIQ mode banks r8-r12. The first version of install() stashed the caller's CPSR in r12, walked through FIQ mode to set its banked stack, then restored CPSR from r12 - but inside FIQ that register name refers to a different physical register holding garbage, so the restore loaded a nonsense mode and reset the CPU. The symptom was oblique (the boot banner printing twice, and VBAR reading back as 0x00000000 instead of the table address). The fix carries nothing across a mode switch: VBAR is programmed first while still in SVC, and the walk ends by naming SVC explicitly rather than restoring a saved value.

2026-07-16 - SEC-1 / SEC-18 security fixes (feat/hardening)

Two HIGH findings from the security audit (audits/security-audit.md), both fixed with // SAFETY:- commented blocks in the permitted arch/ layer (no §18.5 amendment needed):

FileChangeWhy
arch/x86_64/mod.rs36 -> 37 (+1)COM2 presence probe: com2_probe writes and restores the UART scratch register (offset 7) to tell a real 16550 from an absent port. Needed because an absent ISA port reads 0xFF on every register, so the Line Status Register's Data Ready bit reads as SET forever and com2_try_read_byte handed control an endless stream of phantom 0xFF bytes - measured at 93-100% of core 1, starving the fs and block-driver placed there. Sound: the scratch register is defined by the 16550 as general storage with NO side effects, the probe restores the prior value, and it runs once at init on this core's own port.
arch/x86_64/boot.rs104 -> 107 (+3)APIC-timer calibration: read_apic (a volatile MMIO read, the counterpart of the existing write_apic) and pit_calibrate_apic_ticks_per_10ms (an unsafe fn measuring the LAPIC timer against a PIT-gated 50 ms window, mirroring the proven TSC calibration). Needed because the periodic period is init_count * divisor / f_apic and f_apic is machine-dependent: the old hardcoded count gave ~100 ms on QEMU but ~1 s on the T630, 100x the intended 10 ms quantum. Same PIT ports and stuck-hardware bail-out as the TSC path; the timer LVT is masked during the measurement so it cannot deliver an interrupt.
arch/x86_64/boot.rs100 -> 104 (+4)Phase 2a (tickless idle, docs/power.md §14): two safe wrappers, rearm_idle_timer (arm the TSC-Deadline at IDLE_QUANTUM_MULT quanta, ~1 s, so an idle AP wakes ~100x less often) and rearm_quantum_timer (restore the ~10 ms preemption quantum on an idle wake). Each wraps one arm_tsc_deadline_now / rearm_tsc_deadline call in a SAFETY-commented block, guarded by TSC_DEADLINE_MODE. Deliberately safe fns in the arch layer so the neutral scheduler.rs calls them without unsafe - §18.5's rule that new unsafe lives in a permitted layer rather than growing a grandfathered file (scheduler.rs stays at its floor of 37). Each helper handles BOTH timer modes, so each has two SAFETY-commented blocks: the TSC-Deadline arm, and a LAPIC APIC_TIMER_INIT write for the periodic path (the T630 runs periodic, where the hardware auto-reloads and the initial count is the only way to slow the tick).
arch/x86_64/boot.rs98 → 100 (+2)SEC-18: new broadcast_nmi_all_but_self (a pub unsafe fn + one unsafe ICR-write block) so the panic path stops every core, not just the caller. Models the sibling broadcast_ipi_all_but_self; NMI delivery mode (ICR bits 10:8 = 0b100) reaches a core even while it spins IF=0 on a lock. idt[2] is also repointed to exception_halt (a same-file IDT re-wire, no new unsafe).
arch/x86_64/mod.rs35 → 36 (+1)SEC-18: halt_all_cores now calls boot::broadcast_nmi_all_but_self() before its cli+hlt, so a panic on one core halts the whole machine (§6.2 / §19). The +1 is that unsafe { boot::... } call block.

SEC-1 (the freed-CR3 UAF fix in task/scheduler.rs) adds 0 here: its Dekker-handshake edits to yield_current / block_and_reschedule live inside those functions' pre-existing unsafe blocks.

2026-07-12 - userspace audit M8: probe made unsafe-free; unsafe_check.py now scans services/

probe (the §22 adversarial/fuzz/chaos test harness) held raw-SYSCALL asm! plus deliberate ring-3 faults (null read, non-canonical read, divide-by-zero) - unsafe in a userspace service, forbidden by §18.2, and INVISIBLE because unsafe_check.py scanned only kernel/src/. Both gaps closed:

  • The unsafe moved to a new audited SDK module sdk/rust/src/adversarial.rs (§18.1 amendment): safe fuzz_syscall (wraps the ABI raw_syscall; the kernel validates every fuzzed call) and safe fault_null_read / fault_noncanonical_read / fault_divide_by_zero (the deliberate faults, each SAFETY-commented). probe calls these safe wrappers and is now unsafe-free.
  • scripts/unsafe_check.py now also scans services/ and FAILS on any service unsafe line - mechanically enforcing §18.2 and catching any future regression (the M8 blind spot). As a bonus, fuzz_syscall uses the SDK's ud2 trap, removing probe's old raw syscall instruction (a latent AMD-GX-420GI stall hazard).

Verified: osdev test adv 15/0 (A10 fuzz, A14 faults, A15 bad-ptr all pass through the SDK wrappers); unsafe_check.py passes with services/ scanned. Kernel inventory unchanged (471 lines).

2026-07-11 - core count fully dynamic (MAX_CORES ceiling removed)

Every remaining fixed [_; MAX_CORES] per-core structure became a boot arena sized to the machine's real core count, and the MAX_CORES sanity ceiling was deleted. All changes are in the permitted arch//smp/ layers, each block carrying a // SAFETY: comment.

FileChangeWhy
arch/x86_64/mod.rs35 → 33 (-2)The fixed AP_ID_BUF: [u32; MAX_CORES-1] staging buffer (2 single-threaded-boot unsafe writes) is gone: start_all_aps already walks Limine's live cpus() slice directly, and the new ap_count() counts it on demand, so no AP list is staged. Net -2.
arch/x86_64/syscall_entry.rs14 → 15 (+1)PER_CORE_SYSCALL moved from a [_; MAX_CORES] .data array to a PerCoreMut boot arena, with a BSP_SYSCALL bootstrap slot for the pre-arena window (the BSP sets its syscall GS in init_bsp, before the allocator). The +1 is syscall_slot's addr_of_mut!(BSP_SYSCALL) fallback; the arena's own unsafe lives in smp/percpu.rs.
smp/ipi.rs23 → 25 (+2)The TLB-shootdown ack bitmask was a fixed PerCore<[AtomicU64; MAX_WORDS]> (MAX_WORDS = ceil(MAX_CORES/64)); it is now two FLAT num_cores * ceil(num_cores/64) AtomicU64 arenas (ACK + EXPECTED), so the per-initiator mask WIDTH scales with the real core count. The +2 is the ack_word/exp_word accessors (&*base.add(initiator*wpc + word)); the arenas are carved by percpu::alloc_atomic_u64_slice.
smp/percpu.rs6 → 8 (+2)New alloc_atomic_u64_slice(n, init) - carves a flat [AtomicU64; n] from the frame allocator (2 blocks: ptr::write init loop + from_raw_parts) for the dynamic-width shootdown masks, which no fixed PerCore<[_; K]> can size. Plus PerCoreMut::initialised() (safe).

Net across the four: +3. MAX_CORES is deleted entirely - nothing is a fixed per-core array. The only ceiling left is a genuine hardware one: the xAPIC IPI destination field is 8-bit, so a core with LAPIC id > 255 is excluded LOUDLY (§26.7) until the APIC layer gains x2APIC. Validated: identity 24/24, adv 15/15, QEMU boot 1..128 cores + a 72-core 2-word-shootdown restart, arenas carve for 260 cores.


2026-07-11 - per-core user-copy arenas (RAM-sized, not [_; MAX_CORES])

FileChangeWhy
arch/x86_64/syscall_entry.rs15 → 14 (-1)The V1 per-core user-copy state (USER_COPY_ACTIVE, and the 1 MiB USER_READ_SCRATCH = [[u8; 4096]; MAX_CORES]) moved from fixed [_; MAX_CORES] statics to boot-sized PerCore/PerCoreMut arenas (sized to the cores Limine reported, §26.6.1) - so per-core memory scales to the machine, not a 256-core ceiling. The count DROPS by one: read_user_bytes's addr_of_mut! on the static scratch became the safe PerCoreMut::as_mut_ptr accessor (all the arena's unsafe lives in smp/percpu.rs). The two copy_nonoverlapping + one from_raw_parts blocks are unchanged. Removes ~1 MiB of fixed .bss. Boot-validated across QEMU -smp 1..16 + identity 24/24 + adv 15/15.

2026-07-11 - dynamic (RAM-sized) frame bitmap

FileChangeWhy
memory/allocator.rs37 → 44 (+7)The frame bitmaps are no longer fixed static [u8; N] arrays; they are sized to the machine's actual RAM at boot and carved from RAM, reached via the HHDM. The +7 is the raw-pointer machinery that replaces the (safe-indexed) static arrays: the bitmap() / kpt() slice accessors (slice::from_raw_parts_mut over BITMAP_PTR/KPT_PTR, 2 blocks), and in init_from_map the pointer publish + KPT_PTR = BITMAP_PTR.add(bitmap_len) + write_bytes zeroing of the carved region + the hhdm==0 guard. Each carries a // SAFETY: note; the region is HHDM-mapped RAM reserved before any alloc and only ever touched under ALLOC_LOCKED. Permitted memory layer. Net effect: 64 MiB of fixed .bss (at the 1 TiB static cap) replaced by a bitmap of RAM / 16 KiB (e.g. 14 KiB on a 256 MiB box, 4 MiB on 64 GiB), sized dynamically - validated across 256 MiB..64 GiB in QEMU + identity 24/24.

2026-07-14 - aarch64 Phase 0: isolate arch asm behind arch::imp primitives

The arch-boundary seal (docs/aarch64.md) moved every inline-asm operation out of the arch-NEUTRAL layers and behind arch::imp primitives, so unsafe asm consolidated INTO the permitted arch layer and OUT of the neutral files. New primitives (each one // SAFETY:-commented): page_tables::{read_page_table_base, write_page_table_base, invalidate_tlb_page}, interrupts::{local_irq_save, local_irq_restore}, mod::switch_to_boot_stack.

FileChangeWhy
arch/x86_64/page_tables.rs41 → 46 (+5)CR3 read/write + invlpg primitives (the MMU-base + TLB seam).
arch/x86_64/mod.rs33 → 35 (+2)switch_to_boot_stack (the boot stack-pointer seam; #[inline(always)]).
arch/aarch64/mod.rs68 -> 69 (+1)claim_serial reads MPIDR_EL1 (via boot::get_lapic_id) to identify the core holding the UART, so a write that re-enters on the SAME core - an ISR logging mid-line, or a panic raised inside a write - is recognised as nested rather than waiting on a claim it already holds.

A side-effect-free system-register read, valid at any point including early boot, which is what makes it safe to take on EVERY log line here. The x86 equivalent is not: core identity there costs an APIC MMIO read with a boot-ordering precondition, and putting it on the serial path was a fix worse than the bug it addressed. Same intent, different cost, different answer. | | arch/x86_64/pci.rs | 21 -> 22 (+1) | bar_len (2026-10-09, backlog/fixed/80 K1) - sizes a memory BAR at grant time so a driver's register window is its device's and not a fixed 64 KiB. The standard probe (memory decode off, all ones written to the BAR, the mask read back, BAR and decode restored), as ONE block under PCI_CONFIG_LOCK: split across the existing config_read32/config_write32, each of which takes the lock alone, another core's configuration read could land between the writes and see the BAR holding the mask. Only the command half of the command/status dword is written back, because the status half is write-1-to-clear. Every value written is restored before the lock is released. | | arch/x86_64/syscall_entry.rs | 16 -> 14 (-2) | shrank (2026-10-10, backlog/fixed/80 K7): int80_entry deleted - the naked int 0x80 syscall stub and its #[unsafe(naked)]. It was still installed at IDT[0x80] with DPL=3, so any service could raise it, and it ran the syscall chain on the top-of-kstack region the timer switch writes - the Bug 2 class the ud2 path was moved off. The SDK traps with ud2 on every x86 machine; nothing used it. | | smp/placement.rs | 1 -> deleted (-1) | 2026-10-10, backlog/fixed/80 K20: the whole file had no callers (resolve and its static mut RR_COUNTER); the live placement is task::resolve_spawn_core. Its one block, the RR_COUNTER increment, goes with it. | | arch/x86_64/mod.rs | 40 -> 38 (-2) | shrank (2026-10-10, backlog/fixed/80 K20): uart_rx_enable deleted - the unsafe fn and its port-write block. It had no caller by design (unmasking PIC IRQ 4 jams the PIC on real hardware, which the boot comment in main.rs records); COM1 RX is polled from the core-0 timer. | | arch/arm/exceptions.rs | 24 -> 23 (-1) | shrank (2026-10-10, backlog/fixed/80 K25): the kernel-fault reporter's closing wfi loop is replaced by halt_all_cores, so a kernel fault halts every core rather than parking only the one that faulted (CLAUDE.md 6.2, 19). The loop's one asm! block goes. | | arch/arm/irq.rs | 18 -> 17 (-1) | shrank (2026-10-10, backlog/fixed/80 K25): route_usb_irq_to_core0 deleted - it had no callers, and its one block (the legacy-controller enable write) never ran. unmask_usb_irq is the live enable. | | arch/aarch64/mmu.rs | 23 -> 24 (+1) | 2026-10-10, backlog/fixed/80 K21: remap_high_nc, one block. The Pi 4 framebuffer was mapped Normal write-back in the kernel's 2 MiB direct-map blocks and Normal non-cacheable in the console service - mismatched attributes for one physical page, which ARM leaves UNPREDICTABLE. The block splits the blocks the framebuffer touches into pages (the same arena and the same attribute-carrying copy as the kstack-guard split) and gives only the framebuffer's pages MAIR slot 2, break-before-make: the range cleaned from the cache first, each L2 entry invalidated and the TLB dropped before its table goes in. Sound because it runs on the BSP before any secondary starts and before the console is up, on this module's own tables, over a range nothing is touching. | | arch/x86_64/iommu.rs | 74 -> 79 (+5) | 2026-10-10, backlog/fixed/80 K11: a confinement is never left unrecorded. unconfine is the one way a confinement is undone (unsafe fn, +1, with the four blocks release_device had - write the passthrough DTE, sfence, invalidate, free the table - moved into it unchanged); release_device calls it (+1 for the call, -4 for the blocks that moved). confine_device gains three: freeing a partial table when an arena page fails to map (it leaked, root and all), freeing a device's PREVIOUS table when it is confined again (its record was overwritten and the table kept forever), and calling unconfine when the last record slot was taken by another core while the table was being built. Each frees or reverts only a table built here and reachable from nothing but this device's DTE, which each SAFETY comment states. | | arch/x86_64/pci.rs | 20 -> 21 (+1) | cfg_read_gated - the one gated configuration read a userspace enumerator needs (step D2). An out dx, eax to 0xCF8 and an in eax, dx from 0xCFC, held together under the PCI_CONFIG_LOCK that already guards this pair.

This block replaces FOUR that an earlier revision of the same feature added (pci_cfg_out32 / pci_cfg_in32 in both arch/x86_64/mod.rs and arch/aarch64/mod.rs, +2 each). Those exposed SELECT and READ as separate operations, which was wrong on its own terms: the index/data pair is stateful, and the kernel drives it too on its spawn and kill paths, so a split interface let a service and the kernel interleave and each act on the other's selected register. A lock could not close that, because holding one across two syscalls means the kernel waiting on a service. Folding them into one atomic operation removed the race AND three of the four unsafe lines, and the aarch64 access moved into pcie.rs beside the registers it drives rather than reaching in through an exported pointer helper. Both mod.rs files return to their pre-branch counts. | | arch/x86_64/interrupts.rs | 21 → 22 (+1) | local_irq_save (the irq-save half; local_irq_restore calls enable_interrupts). |

Placed here DELIBERATELY. The first version put the unsafe in syscall/dispatch.rs and grew that file 2 -> 4 - and dispatch.rs is a §18.5 GRANDFATHERED FLOOR, which may decrease freely but may increase only by an amendment with a written rationale. §18.5 says new unsafe a feature needs must first try to live in a permitted layer, so it moved to arch/ and dispatch.rs is back at its floor with no amendment required - the worked example that section describes, hit for real.

The port ALLOWLIST moved with it, which is the better placement independently: the check now sits beside the instruction it guards, so the two cannot drift apart, and the wrappers are SAFE by construction rather than by a contract a caller could get wrong. | | arch/x86_64/interrupts.rs | 22 → 26 (+4) | The MSI VECTOR POOL (step D1b): macro_rules! msi_pool_vector generates eight identical naked ISR stubs, so the four lines are written ONCE in the macro body and expanded eight times - #[unsafe(naked)], pub unsafe extern "C" fn (the stub), unsafe extern "C" fn (its handler), and the unsafe { dispatch_irq(vec) } inside it. Same shape as xhci_msi_isr_stub beside it, which has run on four machines; the macro means the shape is written once instead of eight times, which is why the count grew by 4 and not 32. Permitted layer (arch/, §18.1); each carries a // SAFETY: and the asm resolves its handler with sym rather than a #[no_mangle] name nothing checks. | | memory/allocator.rs | 44 → 43 (-1) | CR3-read asm replaced by arch::imp::read_page_table_base. | | smp/ipi.rs | 25 → 23 (-2) | CR3 reload + invlpg + rflags save/restore replaced by arch::imp primitives. | | smp/spinlock.rs | 9 → 5 (-4) | irq-save/restore asm replaced by arch::imp::local_irq_save/restore (no-op stub in the host lib). |

Follow-on same day - IPI-send extraction: smp/ipi.rs held the last APIC MMIO in a permitted-but-not-arch layer (the ICR programming for send_ipi + the shootdown broadcast). Moved to arch/x86_64/boot.rs as send_ipi_to_lapic(lapic_id, vector) + broadcast_ipi_all_but_self(vector) (+ apic_wait_icr_idle); smp/ipi.rs now resolves core->LAPIC and holds the neutral shootdown PROTOCOL only, calling the arch seam for the send. smp/ipi.rs 23 -> 17 (-6, incl. the removed read/write_apic_reg helpers + ICR consts); arch/x86_64/boot.rs 92 -> 98 (+6). smp/ipi.rs is now APIC-MMIO-free; arch owns ALL hardware MMIO. Identity 24/0 (9A cross-core IPC + shootdown exercise the moved paths).

Net: neutral-layer asm now ZERO (enforced by scripts/arch_boundary_check.py, CI-wired); the arch layer is the sole home of unsafe asm, as §18.1 intends. task/scheduler.rs + main.rs asm was removed too but their unsafe blocks (other ops) stayed, so their counts are unchanged. Identity 24/0.


2026-07-13 - kernel-audit-3 fix: spurious-interrupt stub (K3)

FileChangeWhy
arch/x86_64/boot.rs90 → 92 (+2)K3 (kernel-audit-3): dedicated APIC spurious-interrupt handler. The LAPIC spurious vector 0xFF was routed to the default exception_halt, so a spurious IRQ (a normal, rare hardware event the SDM says to ignore-and-return) would wedge the whole machine. New spurious_stub (#[unsafe(naked)] unsafe extern "C" fn + naked_asm!("iretq")) gives 0xFF a return-without-EOI handler - the exact naked-stub pattern of the sibling ipi_wake_stub, no register save / no swapgs needed because it touches nothing. The +2 is the #[unsafe(naked)] attribute + the unsafe extern "C" fn. Permitted arch layer; carries a doc comment explaining soundness.

2026-07-11 - kernel-audit fixes: user-copy fault guard (V1) + exception-handler backfill

FileChangeWhy
arch/x86_64/syscall_entry.rs13 → 15 (+2)V1 (kernel-audit-2): user-copy fault guard. read_user_bytes no longer returns a borrowed raw-user slice; it copies the user bytes into a per-core kernel scratch under a USER_COPY_ACTIVE guard and returns a slice into the SCRATCH (so no caller ever dereferences raw user memory). The net +2 is: read_user_bytes now has 3 blocks (addr_of_mut! on the per-core scratch, copy_nonoverlapping for the guarded copy, from_raw_parts for the returned slice) vs 1 before; write_user_bytes keeps its 1 copy_nonoverlapping block. Each has a // SAFETY: comment. The guard makes a fault on a range-valid-but-unmapped user pointer recoverable (pf_handler kills the caller) instead of a whole-machine halt. Permitted arch layer.
arch/x86_64/boot.rs84 → 90 (+6)Backfill (not this change): reconciles the C1/C2 exception-kill handlers added earlier this session by af74086 (gpf_stub / gpf_handler / exc_stub_noec / exc_stub_ec / exc_dispatch - the ring-3 CPU-exception discriminator), which did not bump this table. All in the permitted arch layer, each block carries a // SAFETY: comment. V1's own pf_handler change adds 0 here - its user-copy-fault branch is safe code (calls to current_core_id / user_copy_active / clear_user_copy_active) inside the existing print unsafe block.

2026-07-10 - fast fbcon blits for the 4K Wyse 5070 + drift reconcile

FileChangeWhy
arch/x86_64/fb.rs3 → 5 (+2)Fast blit path for a dense (4K) panel. fill_rect writes a solid rectangle as contiguous per-row runs; draw_glyph's fast 32bpp path writes each glyph output row as one contiguous run of aligned u32 stores. The old per-pixel byte loop crawled repainting ~6.6M pixels/scroll on the Wyse's 3840x2160 panel. Both bounds-check the whole rect/cell ONCE against the reported geometry (cols/rows are sized so cells fit) then write the run unchecked, so write-combining coalesces the stores - the same raw-framebuffer-write pattern as the existing put_pixel/clear, in the permitted arch layer, each with a // SAFETY: comment. There is no safe route: writing Limine's linear framebuffer is a raw-pointer store, and a bounds-checked &mut [u32] would defeat the purpose (a compare per pixel is the very overhead removed).
arch/x86_64/boot.rs80 → 84 (+4)Reconcile only: pre-existing drift accumulated since 2026-06-08 by the feat/networking bring-up (H1 IOMMU / NIC / PCI / AHCI, merged to main) that did not update this audit. All in the permitted arch layer; count corrected here, per-block detail is a backfill owed by that work.
arch/x86_64/mod.rs34 → 35 (+1)Reconcile only: pre-existing drift from feat/networking (merged to main); permitted arch layer, count corrected, per-block detail backfill pending.

2026-06-08 - fbcon scroll without VRAM read-back

FileChangeWhy
arch/x86_64/fb.rs4 → 3 (−1)scroll no longer core::ptr::copys the framebuffer up in place (which read uncached/WC VRAM - ~130 ms/line on the T630, the fbcon perf trap behind the "40× respawn"). It now shifts a RAM char-grid shadow and repaints from it - write-only via draw_glyph/put_pixel - so the block is gone.

Reduction only; locks in the lower count. The three remaining blocks (clear, put_pixel, wc_flush) keep their // SAFETY: comments. Hardware-verified (T630): pixel-correct after thousands of scrolls; spawn 0.906 s → 9.9 ms.

Note. This same day also reconciled 3 files the earlier H4b/H4 hardening merges left unaccounted (page_tables.rs 25→35 permitted; main.rs and task/mod.rs held at their floors 2 and 7 by the clip) - see the entry below.


2026-06-08 - H4 hardening reconcile, grandfathered floors held (no amendment)

The W^X-remap (H4a/H4b) and kstack-guard (H4) work that merged earlier this session added unsafe (all // SAFETY:-commented in source) without updating this audit. It briefly raised two grandfathered floors; that was then clipped back so the grandfathered counts return to their long-standing floors and no §18 amendment is needed. The hardening's page-table unsafe now lives in the permitted arch layer, where §18.1 says page-table manipulation belongs.

FileNetLayerWhat
arch/x86_64/page_tables.rs25 → 35 (+10)permittedentry_for_va/walk PTE-walk + unmap_active_4k + harden_hhdm_nx (now a safe fn) + new unmap_4k_strided (the kstack guard-unmap loop, moved here from task/). Permitted-layer growth, allowed with SAFETY comments + this entry.
main.rs2 → 2 (no change)grandfatheredinstall_kstack_guards / harden_hhdm_nx are now safe fns (their preconditions are boot-ordering, not UB - same shape as memory::init/smp::init), so the call sites need no unsafe.
task/mod.rs7 → 7 (no change)grandfatheredinstall_kstack_guards is now a safe fn whose guard-unmap delegates to page_tables::unmap_4k_strided (arch); the static-pool-address unsafe is centralised in kstack_pool_base() and reused by free_kstack, so the net count is unchanged.

Why this is better than amending (the clip). unsafe fn is for memory-safety preconditions whose violation is UB; harden_hhdm_nx / install_kstack_guards have only boot-ordering preconditions (calling them out of order wedges boot - a liveness bug, not UB), exactly like the already-safe memory::init / smp::init. Marking them safe is both more honest and removes the call-site unsafe. The genuinely-unsafe work (CR3 reads, PTE writes, the page unmap) stays in unsafe {} blocks inside the permitted arch layer (§18.1). Net: the security hardening landed with zero grandfathered growth. Hardware-verified on the T630 (guard pages install; W^X holds).


2026-06-04 - idle-halt (cool when idle) + introspection holds-check reconcile

FileChangeWhy
arch/x86_64/interrupts.rs12 → 13 (+1)wait_for_interrupt gains a sti; hlt branch so ARAT-capable cores halt (run cool) instead of spinning; the no-ARAT branch keeps the legacy sti-only spin.
arch/x86_64/boot.rs79 → 81 (+2)cpuid_arat_supported (unsafe fn + __cpuid(6)) - detects whether the LAPIC timer survives a C-state, gating the halt.
task/scheduler.rs36 → 37 (+1, grandfathered)reconciles current_task_holds_resource - the §3.1 introspection holds-check (mirrors the existing grandfathered current_task_lookup_cap: reads TASK_CAP[cur].assume_init_ref()). Added with the introspection gate; the audit count was not bumped then - corrected here. A single read-only line for a security gate, same pattern as the lines already grandfathered in this file.

All blocks carry // SAFETY: comments. The hlt is ARAT/TSC-Deadline-gated, so on hardware without an always-running timer it never executes (no regression).


2026-06-03 - USB/xHCI stack (boot-verified, T630)

Branch feat/usb-keyboard. The userspace USB keyboard stack (§12) added unsafe in the permitted arch + memory layers (the driver service itself is unsafe-free behind the SDK's audited Mmio/Dma wrappers - §18.1).

FileChangeWhy
arch/x86_64/pci.rsnew, 5 linesPCI config mechanism #1 port I/O (outl/inl + config_read32) to locate the xHCI controller.
arch/x86_64/mod.rs33 → 34 (+1)console_push_byte pushes a USB-decoded key into the COM1 RX ring (uart_rx_push) so keystrokes reach the shell's ConsoleRead.
memory/allocator.rs29 → 32 (+3)alloc_contiguous(n) - bitmap scan for a physically-contiguous run, for the driver's DMA arena.

All blocks carry // SAFETY: comments in source. SDK mmio.rs/dma.rs unsafe lives outside kernel/src/ (the §18.1-amended SDK hardware/ABI layer) and is not counted by scripts/unsafe_check.py, which scans kernel/src/ only.


2026-05-31 - static-analysis + unsafe-audit pass (boot-verified, T630)

Full write-up: milestones/testing/static-analysis-audit.md. Branch verify/static-analysis-unsafe-audit, commit d276566.

AreaResult
Policy violationFixed - unsafe removed from ipc/ (§18.1); moved to SpinLock::ZEROED in smp/spinlock.rs.
Safety / correctness lints0 - 11 unnecessary unsafe, 11 static mut refs (→ addr_of!), 14 fn-item→int casts, 6 no-op mem::forget.
Cruft removedorphaned page_fault_handler + INTERRUPTED_* statics.
Inventoryreconciled below - 302 lines / 23 files, passes clean; task/scheduler.rs 37 → 36 (under floor).
Kernel warnings104 → 57 (rest intentional unwired architecture).
Hardwareboots clean on T630, cross-core ping/pong to 83k+ msgs, zero #PF/panic.

Policy (§18)

unsafe is permitted only in:

Permitted layerPath
Architecturekernel/src/arch/
Memorykernel/src/memory/
Capability tablekernel/src/capability/
SMPkernel/src/smp/

All other locations are outside policy. The files marked grandfathered in the table below contain unsafe that pre-dates this audit. Their counts are frozen: they may decrease (fix welcome) but may not increase. New unsafe in those files requires a policy amendment in CLAUDE.md §18 before CI will accept it.

When you add an unsafe block anywhere:

  1. Add // SAFETY: <argument> on the line immediately above it in the source.
  2. Increase the count for that file in the inventory table below.
  3. Add a SAFETY argument entry under that file in the Entries section.
  4. Both changes must land in the same commit; CI checks the count.

Inventory

Counts are non-comment lines containing the unsafe keyword. CI script: scripts/unsafe_check.py - parses the table between the markers.

File (kernel/src/)CountLayer
arch/aarch64/mod.rs75permitted
arch/aarch64/sched_user.rs4permitted
arch/aarch64/uart_rx.rs3permitted
arch/aarch64/sdio.rs3permitted
arch/aarch64/exceptions.rs17permitted
arch/aarch64/uaccess.rs7permitted
arch/aarch64/context.rs8permitted
arch/aarch64/sched_demo.rs5permitted
arch/aarch64/ctxdemo.rs7permitted
arch/aarch64/gic.rs7permitted
arch/aarch64/timer.rs5permitted
arch/aarch64/mmu.rs24permitted
arch/aarch64/ptables.rs28permitted
arch/aarch64/usermode.rs16permitted
arch/aarch64/mailbox.rs4permitted
arch/aarch64/memmap.rs8permitted
arch/aarch64/video.rs2permitted
arch/aarch64/genet.rs1permitted
arch/aarch64/pcie.rs4permitted
arch/aarch64/smp_boot.rs9permitted
arch/arm/exceptions.rs23permitted
arch/arm/context.rs6permitted
arch/arm/context_switch.rs13permitted
arch/arm/dtb.rs6permitted
arch/arm/irq.rs17permitted
arch/arm/meminit.rs4permitted
arch/arm/mmu.rs8permitted
arch/arm/video.rs17permitted
arch/arm/bootcon.rs2permitted
arch/arm/page_tables.rs37permitted
arch/arm/sched_demo.rs6permitted
arch/arm/sched_ipc.rs9permitted
arch/arm/spawn.rs4permitted
arch/arm/syscall.rs5permitted
arch/arm/usermode.rs17permitted
arch/arm/timer.rs7permitted
arch/arm/mod.rs54permitted
arch/loongarch64/mod.rs24permitted
arch/riscv32/mod.rs24permitted
arch/riscv64/fdt.rs3permitted
arch/riscv64/sbi.rs12permitted
arch/riscv64/sv39.rs29permitted
arch/riscv64/trap.rs7permitted
arch/riscv64/syscall.rs1permitted
arch/riscv64/context_switch.rs35permitted
arch/riscv64/usermode.rs17permitted
arch/riscv64/uaccess.rs7permitted
arch/riscv64/display.rs6permitted
arch/riscv64/mod.rs64permitted
arch/s390x/mod.rs19permitted
arch/x86_64/ap_boot.rs3permitted
arch/x86_64/boot.rs103permitted
arch/x86_64/context_switch.rs11permitted
arch/x86_64/fb.rs2permitted
arch/x86_64/interrupts.rs26permitted
arch/x86_64/ioapic.rs9permitted
arch/x86_64/acpi.rs5permitted
arch/x86_64/iommu.rs79permitted
arch/x86_64/mod.rs36permitted
arch/x86_64/page_tables.rs51permitted
arch/x86_64/pci.rs22permitted
arch/x86_64/rtc.rs1permitted
arch/x86_64/syscall_entry.rs14permitted
capability/table.rs7permitted
memory/allocator.rs48permitted
memory/frame.rs1permitted
memory/mod.rs1permitted
memory/page.rs1permitted
smp/ipi.rs4permitted
smp/mod.rs1permitted
smp/percpu.rs6permitted
smp/names.rs4permitted
smp/spinlock.rs5permitted
interrupt/route.rs1grandfathered
loader.rs1grandfathered
main.rs2grandfathered
syscall/dispatch.rs2grandfathered
task/mod.rs7grandfathered
task/scheduler.rs37grandfathered

Permitted total: 1133 lines across 72 files
Grandfathered total: 50 lines across 6 files
Grand total: 1183 lines across 78 files

These three lines read 394/22, 53/6 and 447/28 until 2026-09-12 - a tree of 28 files that had not existed for months, and a grand total 2.7x low. They are derived by summing the inventory block immediately above, and scripts/unsafe_check.py independently prints the same grand total ("78 audited files, 1208 total unsafe lines"). The check validates the inventory rows, never this prose, which is how a number inside the file the check guards drifted that far. Re-derive them whenever a row moves.

2026-06-28 (branch hardening/dma-reserve-pool). Audit reconciliation - three permitted-layer files drifted (each line already carrying a // SAFETY: comment; the counts just weren't bumped as the arch/x86_64/pci.rs 19 → 20 (C8-1, 2026-08-14): one rdtsc() helper, added when the two BIOS-handoff waits stopped counting register reads and started bounding themselves by a clock (Commandment VIII). A helper rather than four inline blocks: the same idea is tested twice in each of two places, and repeating unsafe would have grown the audited surface fourfold for one read of a CPU counter. arch/x86_64/pci.rs 17 → 19: clear_bus_master + set_bus_master, the PCI bus-master quiesce on DMA-driver kill/spawn that cures the max-carnage DMA-after-free (commit ffe1a0f). memory/allocator.rs 32 → 37: one from the page-table reclaim guard (phys_in_ram, commit b9dbc4c) and four from the DMA permanent-reserve net (§12) added on this branch - alloc_dma_arena (the reserving allocator + its public wrapper + the table-full undo) so a driver's DMA arena is never recycled into a page table. smp/spinlock.rs 7 → 9: a second without_interrupts guard from the per-core shootdown work. New grand total: 447 / 28 files (also corrects the prose totals, which had drifted ~5 low vs the inventory sum).

2026-06-22 (branch fix/unsafe-audit-reconcile). Audit reconciliation - caught up four drifted files and shrank one back under its floor. Permitted-layer count catch-ups (all arch/, each line already carrying a SAFETY comment - no policy issue, the counts just weren't bumped as the work landed): arch/x86_64/interrupts.rs 13 → 21 (USB MSI ISR plumbing), arch/x86_64/pci.rs 15 → 17 (MSI-X table mapping), and the previously-unlisted file arch/x86_64/ioapic.rs (+8, IOAPIC MMIO register reads/writes for legacy-INTx routing). smp/spinlock.rs 5 → 7 (the without_interrupts cli/sti added for the kstack-lock irqsafe fix). task/scheduler.rs 40 → 37 - back at floor, NO §18.5 amendment: the 3 file-as-capability (§7.10) accessors that had drifted it over - current_task_endpoint, set_last_recv_badge, take_last_recv_badge - were converted from static mut (TASK_ENDPOINT, TASK_LAST_BADGE) to AtomicU64, making them unsafe-free. The grandfathered floor stays 37 and there are still no grandfathered-floor amendments. New grand total: 433 / 28 files.

2026-06-13 (branch feat/persistence). ATA PIO / hw_pio retired - the AHCI (MMIO+DMA) backend replaced ATA PIO (the T630's SSD is AHCI-only). Reverts the 2026-06-12 addition below: arch/x86_64/mod.rs 38 → 34 (the port_in8/16, port_out8/16 wrappers removed; inb/outb stay - used by serial + reboot), and capability/hw_pio.rs deleted (−3). Back to 413/27. The PortRead/PortWrite syscalls and the SDK pio.rs (not kernel-audited) are gone too.

2026-06-12 (branch feat/persistence). Persistence Phase 1 (ATA PIO block driver, docs/persistence.md §5). arch/x86_64/mod.rs +4 (permitted): safe public port-I/O wrappers port_in8/16 + port_out8/16 (the in/out asm, isolated in the arch layer; callers validate the port first). New file capability/hw_pio.rs +3 (permitted): the per-task hw_pio grant store (set/clear/allowed) - placed in the capability layer on purpose, so the per-task port-range state does not grow the grandfathered unsafe floor in task/ (§18.5). task/scheduler.rs and syscall/dispatch.rs gained no unsafe (they call the safe wrappers / the capability-layer functions).

2026-06-10 (branch feat/iommu-dma-confinement). New file arch/x86_64/iommu.rs (+60, permitted): the H1 AMD-Vi IOMMU work. Phase 0 (+18) is ACPI-table reads (RSDP → RSDT/XSDT → IVRS) through the HHDM. Phase 1 (+42) is the IOMMU control interface and translation setup: uncached MMIO register read/write, device-table /command-buffer/event-log allocation and DTE writes, the 4-level I/O page-table builder/translator/free, and command-buffer invalidation. Every block carries a // SAFETY: argument that the target is a kernel-mapped IOMMU structure (MMIO window, device table, command buffer, or I/O page table) and the access is in bounds. All hardware unsafe is contained here behind the safe wrapper confine_device(); task/mod.rs calls it without any new unsafe (its grandfathered floor of 7 is unchanged). See the arch/x86_64/iommu.rs entry below.

Reconciled 2026-05-31 (branch verify/static-analysis-unsafe-audit). The permitted-layer growth since the prior baseline is from the AMD GX-420GI ring-3 / TSC-Deadline-APIC / COM1 work that landed on main (boot.rs, mod.rs, interrupts.rs, ipi.rs, allocator.rs). smp/spinlock.rs +1 is the new ZEROED const (below). Reductions: the static-analysis pass removed unnecessary unsafe blocks (ap_boot, boot, mod, scheduler) and the orphaned page_fault_handler / INTERRUPTED_* diagnostics (interrupts.rs net still up from the AMD work). task/scheduler.rs is back to 36 - under its grandfathered floor again.


Entries

Each entry documents WHY an unsafe block is sound. Entries are grouped by file. Files with thorough existing // SAFETY: comments in source reference them here. Files lacking source comments are noted with (SAFETY comment missing in source).

New entries must be added in the same commit as the unsafe block they cover.


arch/x86_64/ap_boot.rs

Unsafe in this file: AP trampoline entry, AP boot identity mapping, and calling ap_main after the long-mode switch. All three are sound because the trampoline runs before any Rust invariants apply; the stack is valid; identity mapping holds for the trampoline duration and is torn down by the kernel immediately after.

A fourth (publish_bsp_lapic_id) reads the local APIC's own ID register via boot::get_lapic_id. Sound because the function is called from smp::init, which runs on the BSP after init_local_apic has mapped and initialised the APIC (the get_lapic_id safety contract) and after smp::percpu_init has allocated the per-core arenas it then writes. It is a single volatile MMIO read of a fixed, already-mapped register and stores no pointer.


arch/x86_64/boot.rs

Largest unsafe surface in the kernel. Covers: BSP init (GDT/IDT/TSS per core), APIC MMIO mapping and register writes, serial I/O port access, TSS RSP0 reads and writes, paging init, and IPI delivery. All operations are sound because they target fixed hardware addresses verified against the Limine memory map, or operate on per-core structures indexed by a valid core_id bounded by MAX_CORES. APIC MMIO is mapped once before any AP comes up.

One additional unsafe {} block (count +1): write_apic(apic_virt, APIC_TPR, 0x00) in init_local_apic - zeroes the Task Priority Register so all interrupt vector classes (including WAKE_RECEIVER at 0xF0) are accepted. Sound because apic_virt is established by the preceding map_in_active_tables call within the same function; APIC_TPR offset is within the mapped 4 KiB MMIO page. // SAFETY: comment present in source.


arch/x86_64/context_switch.rs

Stack construction for new kernel and user tasks. new_kernel and new_user write a synthetic initial register frame to a freshly allocated kernel stack pointer. Sound because the stack buffer is owned exclusively by the new task and not yet visible to the scheduler.


arch/x86_64/fb.rs

Framebuffer text console (Phase 1 boot output, §11.4). Five blocks; four write to Limine's linear framebuffer at base + y*pitch + x*bpp:

  • clear: write_bytes(base, 0, height*pitch) - fills the whole buffer.
  • put_pixel: writes bpp bytes (one aligned u32 store on the 32bpp fast path) at a bounds-checked offset (x<width, y<height).
  • fill_rect: fills a w x h rectangle clamped to width/height, writing each row as a contiguous run (aligned u32 stores on the 32bpp path). Sound: the clamped rect stays inside the mapped height*pitch region; x*bpp/pitch are 4-aligned on the 32bpp path.
  • draw_glyph (fast 32bpp path): writes each glyph output row as one contiguous run of cw aligned u32 stores. Sound: it first checks the whole cell [x0,x0+cw) x [y0,y0+chh) lies inside the framebuffer (cols/rows are sized so cells fit; otherwise it falls back to the checked fill_rect), so the unchecked run stays within height*pitch, and x0*4/pitch are 4-aligned.

Sound because the framebuffer is the region Limine mapped and sized (height*pitch bytes), it lives in the higher half (PML4 256-511) that every address space inherits via PageTable::new, so it is valid for writes for the system lifetime; every offset is bounds-checked against the reported geometry.

scroll previously held a fourth block - an in-place copy/write_bytes that shifted the framebuffer up one glyph row. That copy read the framebuffer back (uncached/WC VRAM, ~130 ms/line on the T630); it was replaced by a RAM char-grid shadow that scroll shifts and repaints from, leaving scroll entirely safe (write-only via draw_glyph → put_pixel). Net 4 → 3 (2026-06-08).

The remaining wc_flush block is a single SFENCE instruction. The framebuffer is mapped write-combining (Limine HHDM default), so the FB lock's atomic release does not order the WC store buffer - a scroll's pixel stores on one core could flush after the next line's first glyph drawn on another core, erasing it. Each put_byte/put_bytes issues SFENCE before releasing the lock so its WC stores are globally visible in order. Sound because SFENCE only orders stores and has no memory or privilege effects.


arch/x86_64/interrupts.rs

IRQ dispatch and CR2 read on page fault. Sound because the IRQ handler runs at known IDT vector; CR2 is only read inside the page-fault handler where it is valid.

Three additional unsafe {} blocks (count +3): enable_interrupts (STI), disable_interrupts (CLI), and wait_for_interrupt (STI+HLT). All three are ring-0 privileged instructions with no memory effects; the callers are responsible for the context invariants (e.g., interrupts were disabled before calling wait_for_interrupt). // SAFETY: comments present in source.

One additional unsafe {} block (count +1): send_eoi - writes the local APIC EOI register via boot::apic_send_eoi. Sound because the APIC is mapped before any IRQ fires and EOI register writes are idempotent with no memory-safety implications. Exposes APIC EOI as a safe call site in interrupt/route.rs (§12) without increasing the grandfathered count there.

One additional unsafe {} block (count +1): fire_test_irq - calls interrupt::route::deliver(irq) after disabling interrupts and before re-enabling them. Sound because IF=0 satisfies deliver's calling convention; the surrounding disable_interrupts() / enable_interrupts() calls are safe arch functions; EOI inside deliver is idempotent outside a real hardware interrupt. Used only by the FIRE_IRQ COM2 control command (§22 Tests IR1A/IR1B).


arch/x86_64/ioapic.rs

IOAPIC programming for legacy-INTx interrupt routing. 8 unsafe lines, each with a SAFETY comment - uncached MMIO access to the IOAPIC index/data window (write the register selector, then read/write the 32-bit data register) to read the IOAPIC id/version and program redirection-table entries that route a legacy IRQ line to a CPU vector. Permitted arch/ layer (direct hardware access, §18.1). The file was not previously in the inventory; its count was correct in source, just unaudited - added 2026-06-22.


arch/x86_64/iommu.rs

AMD-Vi IOMMU detection (H1 Phase 0). All 18 unsafe lines are raw reads of firmware ACPI tables - the RSDP, the RSDT/XSDT, and the IVRS - through the HHDM. The helpers read_bytes, read32, read64 are unsafe fn; detect calls them at every step. Each block is sound because:

  • The RSDP virtual address comes from Limine's RsdpRequest, which points at a table Limine keeps mapped in the HHDM; the signature is checked before any further read.
  • Every subsequent table is reached only through a physical pointer that lives inside an already-validated parent table, converted to a virtual address via the HHDM (hhdm + phys), which Limine maps for all usable + ACPI memory.
  • Each read stays within the table's own length field (sdt_len, ivrs_len), and the IVHD walk advances by the block's self-reported length and stops on a zero length, so it cannot run off the end or loop forever.

Detection only - no behaviour change, no writes, no device programming. The results are published in two atomics (IOMMU_PRESENT, IOMMU_MMIO_BASE).

Phase 1 (translation setup), +42. The remaining unsafe in this file programs the IOMMU and builds translation structures. Grouped:

  • mmio_read64 / mmio_write64 - volatile access to the IOMMU MMIO control registers, which bringup maps uncached (PCD|PWT) at their HHDM alias before any access. Offsets are compile-time constants within the mapped 0x4000 window.
  • setup_structures / write_dte - allocate the device table (2 MiB contiguous), command buffer, and event log from the frame allocator, zero them through the HHDM, and write DTEs. All writes target the freshly-allocated, HHDM-mapped structures; the DTE index is a 16-bit BDF, in bounds of the 64K-entry table.
  • io_walk_or_alloc / io_map_page / io_translate / free_io_table - the 4-level AMD-Vi I/O page-table builder, read-only translator, and frame reclaim. Each level VA is the HHDM alias of a present/just-allocated table; indices are masked to 9 bits (< 512), so every read/write is in bounds of a 4 KiB table. free_io_table frees only the page-table frames (reached top-down from a root that release_device has already detached from the device), never the leaf arena pages.
  • invalidate_device - writes 16-byte commands into the mapped command-buffer ring at the hardware tail offset (masked to the 4 KiB ring) and rings the tail register; serialised by CMD_LOCK.
  • drain_event_log - reads decoded fault events from the mapped 4 KiB event-log ring (head < 0x1000) and advances the head register; bounded per call so it is safe to invoke from the timer-tick path (control::process_pending). Also recovers from event-log overflow (disable EvtLogEn, RW1C the status bit, reset head/tail, re-enable) - all writes to valid IOMMU control/status/pointer regs.
  • confine_device / confinement_selftest / release_device - orchestrate the above; the raw work they do directly is zeroing a freshly-allocated page table, an sfence (no memory-safety effect, orders prior stores), and (on release) reverting a DTE before freeing the now-unreachable I/O page table.

confine_device, release_device, event_log_state, and bringup are the safe entry points; all callers outside the arch layer (e.g. task/mod.rs) use them without unsafe. // SAFETY: comments present on every block.


arch/x86_64/mod.rs

Serial port init and COM2 init via outb/inb, cli/hlt in the halt loop, and the init call chain into boot.rs. Sound because serial ports are exclusively owned by the kernel at these call sites; cli/hlt is the correct halt sequence; all callers are within the single-threaded BSP init path.

One additional unsafe {} block (count +1): console_push_byte calls uart_rx_push(b) to enqueue a USB-keyboard-decoded byte into the COM1 RX ring, then wakes any task blocked in ConsoleRead. Sound because the RX ring is a single-logical-producer buffer (the timer-ISR UART drain and the xHCI driver's ConsolePush syscall both run on Core 0's serial path); the push is a bounded ring write with head/tail wrap. // SAFETY: comment present in source.


arch/x86_64/page_tables.rs

HHDM offset reads and writes, PTE reads and writes via read_volatile/ write_volatile, map_in_active_tables (reads CR3, walks and modifies the active page table), and reclaim_user_frames (walks a dead task's page table after the TLB shootdown has completed). All are sound because: HHDM offset is written once before any AP starts; PTE access goes through the HHDM which is valid after set_hhdm_offset; map_in_active_tables holds the frame allocator lock for the duration; reclaim_user_frames is called only after TLB shootdown acknowledgment from all cores.

Ten additional unsafe lines (count 25 → 35) from the W^X / guard-page hardening (H4a/H4b, 2026-06-07/08):

  • entry_for_va / walk / read_entry chain - read-only PTE walk used to probe a VA's mapping (PTE/large-page) for the W^X audit and the kstack-guard install.
  • unmap_active_4k(virt) (unsafe fn + CR3 read + present-entry walk + clear PTE
    • invlpg) - marks a 4 KiB page non-present; no-ops on a large page (fails safe).
  • unmap_4k_strided(base, stride, count) - a safe fn that unmaps the low page of each kstack slot via unmap_active_4k; the guard-unmap loop moved here from task/ (§18.1 - page-table work belongs in arch) so it adds no grandfathered unsafe. Boot-ordering contract (BSP, before APs).
  • harden_hhdm_nx() - a safe fn (CR3 read + HHDM subtree walk OR-ing NX into every present PDPT/PD/PT, then CR3 reload) that flips the HHDM NO_EXEC, closing the Limine-mapped RWX direct map (§3.12). Boot-ordering precondition (after smp::init), not UB - hence safe; the CR3/PTE work inside stays unsafe {}.

Six further unsafe lines (count 35 -> 41) from the alloc_mem reclaim-leak fix (2026-06-23, surfaced by chaos mem-pressure):

  • free_phys_frame(phys) (an unsafe fn + one unsafe { free_frame }) - frees one physical frame by address during task-death teardown.
  • reclaim_user_frames now frees each leaf / PDPT / PD / PT frame INLINE via free_phys_frame (four call sites) instead of collecting into the fixed 512-entry ReclaimBuffer, whose push silently DROPPED - i.e. LEAKED - every frame past 512 (a 32 MiB alloc_mem task leaked ~30 MiB on every kill, violating §10.5 / §26.7). The walk itself is unchanged; only "collect into a buffer" became "free inline". Sound for the same reason as the original: called only after the TLB shootdown has been acknowledged by all cores, so no core's page-walker can reach a freed frame.

All sound for the same reason as the rest of the file: HHDM is live, the tables are reached via present entries rooted at the live CR3-referenced PML4, and these run BSP-only at boot before APs execute from the affected region. // SAFETY: comments and # Safety docs present in source for every block.


arch/x86_64/pci.rs

PCI configuration-space access via legacy mechanism #1 (port 0xCF8 address / 0xCFC data), used once at boot to locate the xHCI USB host controller and record its MMIO base + IRQ (§12). Five unsafe lines:

  • unsafe fn outl / its inner unsafe {} block - 32-bit out dx, eax port write.
  • unsafe fn inl / its inner unsafe {} block - 32-bit in eax, dx port read.
  • unsafe {} in config_read32 - pairs an outl(address) then inl(data).

Sound because port I/O is ring-0 and these ports are the architecturally fixed PCI config registers, owned exclusively by the kernel during single-threaded BSP boot (the scan runs before any AP or task exists); the address dword is constructed from bounded bus/dev/func/offset values with the enable bit set per the mechanism-#1 spec. // SAFETY: comments present in source.

Three additional unsafe lines (+3) for the EHCI BIOS→OS handoff (ehci_bios_handoff): the unsafe {} in config_write32 (paired outl(address)

  • outl(data), same discipline as config_read32), the map_in_active_tables call mapping the EHCI MMIO page to read HCCPARAMS, and the read_volatile of HCCPARAMS. Sound for the same reason - ring-0 BSP boot, architecturally fixed ports, the MMIO page mapped uncached before the single aligned read.

Seven more (+7) for the xHCI BIOS→OS handoff (xhci_bios_handoff): xHCI's legacy support lives in MMIO (not PCI config), so this maps the xHCI MMIO (16 pages, uncached), reads HCCPARAMS1 for the xECP, then walks the MMIO extended-capability list - read_volatile/write_volatile of USBLEGSUP (claim OS ownership, poll for BIOS release) and USBLEGCTLSTS (disable firmware SMIs). Each access is within the just-mapped 64 KiB MMIO window at a bounded offset (< 0x10000), during single-threaded BSP boot. All carry // SAFETY: comments.

One more (+1, 2026-10-09) for bar_len, the BAR-sizing probe at grant time (backlog/fixed/80 K1): the command register and one BAR (two for a 64-bit BAR) written and read back through the same port pair, the whole sequence under PCI_CONFIG_LOCK so no other access observes a BAR holding its size mask, and every value restored before the lock is released. // SAFETY: comment present.


arch/x86_64/rtc.rs

MC146818 CMOS real-time-clock read via the legacy index/data ports (0x70 / 0x71), used to answer InspectKernel query 11 (wall-clock date/time) for the shell's date/time commands (§12). One unsafe line:

  • unsafe {} in cmos_read - wraps an out dx, al (select register) followed by an in al, dx (read its value); the two asm blocks are not pure, so their order is preserved.

Sound because port I/O is ring-0 and these are the architecturally fixed CMOS ports; only a register number (0x00..0x3F) is written, and the read is side-effect-free. The driver is read-only - it never writes CMOS - so it cannot disturb other clock/NMI state. // SAFETY: comment present in source.


arch/x86_64/syscall_entry.rs

Serial output helpers (ser_putc, ser_puts, ser_hex64) and per-core SYSCALL MSR setup. Sound because serial helpers are guarded by the kernel's serial spinlock; SYSCALL MSR setup runs once during per-core init before the core enters the scheduler.

Three additional unsafe {} blocks (count +3): read_user_bytes (from_raw_parts), write_user_bytes (copy_nonoverlapping), and read_cycle_counter (_rdtsc). All three are sound because the pointer/length pair is validated by validate_user_ptr before the unsafe call, ensuring the range lies in user-space (below USER_END) and cannot overlap kernel memory; _rdtsc is a read-only counter with no side effects. // SAFETY: comments present in source.


capability/table.rs

Access to GLOBAL_RESOURCES - a static ResourceTable protected by an internal SpinLock. All seven unsafe calls go through the lock; the lock ensures mutual exclusion across cores. // SAFETY: comments present in source.


memory/allocator.rs

Frame allocator internals: bitmap manipulation, guard-page checks, allocator init from the Limine memory map. Sound because the allocator is protected by a SpinLock; bitmap indices are bounds-checked before access; guard-page ranges are set once during init. // SAFETY: comments present in source for most blocks; a small number need back-fill (see grandfathered note in §18).

Three additional unsafe lines (count 29 → 32): the alloc_contiguous(n) path for driver DMA arenas (§12) - the unsafe fn alloc_contiguous method, its inner &mut *addr_of_mut!(BITMAP) access, and the public alloc_contiguous wrapper's (*addr_of_mut!(ALLOCATOR)).alloc_contiguous(n) call. Sound for the same reason as the rest of the allocator: every access holds ALLOC_LOCKED (single writer across all cores), and the bitmap scan is bounds-checked against max_valid_frame. // SAFETY: comments present in source for all three.

Five further unsafe lines (count 32 → 37): one from the page-table reclaim guard (phys_in_ram's ALLOCATOR.max_valid_frame read, commit b9dbc4c) and four from the DMA permanent-reserve net (§12, the DMA-safety net): unsafe fn alloc_dma_arena, its inner self.alloc_contiguous(n) call, the table-full bitmap_set_free undo, and the public alloc_dma_arena wrapper's (*addr_of_mut!(ALLOCATOR)).alloc_dma_arena(n) call. alloc_dma_arena records the run in dma_reserves so free skips it - the arena is never returned to the general pool to be recycled as a page table (a stray DMA then hits DMA-reserved memory, not a PTE). Sound for the same reason as the rest of the allocator: every access holds ALLOC_LOCKED. // SAFETY: comments present in source for all five.


memory/frame.rs

Frame::from_phys - constructs a Frame from a raw physical address. Sound because all callers are in the frame allocator or page-table walker, both of which obtain addresses from the validated Limine memory map. (SAFETY comment missing in source - needs back-fill.)


memory/mod.rs

Calls set_hhdm_offset with the Limine-supplied HHDM offset during early init. Sound because this runs exactly once, on the BSP, before any AP or task sees virtual memory. // SAFETY: comment present in source.


memory/page.rs

Page::from_virt - constructs a Page from a raw virtual address. Used only by the page-table walker with addresses derived from the HHDM. Sound for the same reason as Frame::from_phys.


smp/core.rs

Per-core ready-flag manipulation via static arrays indexed by core_id. core_id is bounded by MAX_CORES at all call sites. // SAFETY: comments present in source.


smp/ipi.rs

APIC IPI delivery: reads APIC_VIRT_BASE, writes to APIC ICR register, and dispatches IPI handler. Sound because APIC_VIRT_BASE is set during BSP init before any IPI is issued; ICR writes follow the APIC specification (write high word first, then low word to trigger). // SAFETY: comments present in source for most blocks; a small number need back-fill.


smp/mod.rs

AP startup via start_all_aps. Delegates to arch/x86_64/ap_boot.rs. // SAFETY: comment present in source.


smp/spinlock.rs

SpinLock<T> interior-mutable spinlock. Seven unsafe constructs:

  • without_interrupts(f) - two blocks: unsafe { pushfq; pop; cli } to capture RFLAGS.IF and mask interrupts on the local core, and unsafe { sti } to restore the prior enabled state. Local-core, no memory effects, IF restored exactly (nests correctly). REQUIRED for locks taken in both syscall and interrupt context (KSTACK_USED): without it a timer firing mid-critical-section re-enters the lock in the ISR on that core and self-deadlocks (the chaos max-carnage freeze).
  • unsafe impl Send for SpinLock<T>: sound because the atomic spinlock serialises all access to T; T: Send is required.
  • unsafe impl Sync for SpinLock<T>: same reasoning - mutual exclusion is enforced by the atomic before any shared reference is handed out.
  • unsafe { &*self.lock.data.get() } in Deref: sound because the lock is held (we have a SpinLockGuard); no other reference to the inner data can exist simultaneously.
  • unsafe { &mut *self.lock.data.get() } in DerefMut: same reasoning for mutable access.
  • pub const ZEROED: Self = unsafe { core::mem::zeroed() }: all-zeroes initializer for placing a large SpinLock<T> in .bss without the undef padding bytes that LLD rejects there. Sound only when the all-zeroes bit pattern is a valid T - the caller's responsibility via the T instantiated. Replaces a core::mem::zeroed() that previously sat in ipc/routing.rs (outside the permitted layers); moving it here keeps ipc/ unsafe-free (§18.1).

// SAFETY: comments present in source for all five.


interrupt/route.rs (grandfathered)

pub unsafe fn deliver(irq: u8) - called from the IDT stub with IF=0. One unsafe line remaining (the unsafe fn declaration). IRQ_TABLE is now protected by SpinLock; registration and delivery are safe with respect to the lock. The unsafe on deliver reflects the interrupt-context calling convention (must only be called from the IDT with IF=0). // SAFETY: comment present in source.


loader.rs (grandfathered)

ELF loader: two private helpers (read_ehdr, read_phdr) each contain one read_unaligned call that copies the entire packed ELF struct into a local value; all field accesses in load() then go through safe local copies with no unsafe at the call site. The remaining two unsafe blocks are write_bytes (BSS zeroing) and copy_nonoverlapping (segment copy); both are bounded by bounds checks performed above them. // SAFETY: comments present in source for all four blocks.


main.rs (grandfathered)

Two unsafe blocks: (1) BSP stack switch via inline ASM - sound because BSP_BOOT_STACK is a 512 KiB static buffer and the pointer arithmetic is bounded; (2) deref of boot_info_ptr - sound because the Limine bootloader guarantees alignment and validity. (The earlier COM2-init block was removed when com2_init was made a safe function.) // SAFETY: comments present in source.

The H4 hardening calls (install_kstack_guards, harden_hhdm_nx) are safe fns (boot-ordering preconditions, not UB), so they add no unsafe here - see the 2026-06-08 reconcile.


syscall/dispatch.rs (grandfathered)

2 unsafe lines remaining (reduced from 26):

  • pub unsafe extern "C" fn syscall_handler: the raw ring-3 → ring-0 entry point installed as the LSTAR target; must remain unsafe because it processes untrusted register values from user space.
  • unsafe { map_in_active_tables(va, phys, flags) } inside handle_alloc_mem: sound because va is in the task heap region (above 0x1_0000_0000); phys is a freshly allocated frame from the bitmap allocator; the active page table is the calling task's own CR3. // SAFETY: comment present in source.

All other handlers were converted from unsafe fn to fn - their user-pointer accesses moved to arch/x86_64::read_user_bytes / write_user_bytes which encapsulate the unsafe in the permitted arch layer.


task/mod.rs (grandfathered)

Seven unsafe blocks: two in the kstack pool - kstack_pool_base (addr_of! of the static mut KSTACK_STORAGE, the single encapsulated pool-address read, reused by free_kstack) and alloc_kstack ((addr_of_mut!(...) as *mut u8).add(...) slot-top arithmetic) - and five in the spawn path (write_bytes for stack zeroing, task_cap_init_empty, write_bytes + *mut ServiceContextData cast for the ctx page, TaskContext::new_user, and commit_task). All bounded by prior bounds checks or scheduler-layer invariants. // SAFETY: comments present in source.

The H4 kstack-guard install (install_kstack_guards) is a safe fn: it reads the pool base via kstack_pool_base() and delegates the per-slot page unmap to page_tables::unmap_4k_strided (the arch layer, §18.1), so it adds no unsafe here - see the 2026-06-08 reconcile. (Centralising the pool-address read in kstack_pool_base also let free_kstack drop its own addr_of! block, holding the net count at 7.)

The previous magic-word liveness scheme (KSTACK_MAGIC_USED volatile reads/writes at slot offset 0) was replaced by SpinLock<[bool; TASK_KSTACK_MAX]>, removing 5 unsafe lines.


task/scheduler.rs (grandfathered)

36 unsafe lines. Five formerly-static mut arrays converted to atomic types, removing eight standalone unsafe {} blocks (previous count was 42, then 38, now back to the original 36 floor after TASK_VALID was also converted):

  • CORE_CURRENT → [AtomicUsize; MAX_CORES]: removed standalone unsafe in current_task_slot(); accesses updated to .load()/.store().
  • CORE_RR_SLOT → [AtomicUsize; MAX_CORES]: removed both standalone unsafe blocks in pick_next().
  • CORE_PENDING_KSTACK_LEN → [AtomicUsize; MAX_CORES]: removed both standalone unsafe blocks in drain_pending_kstack().
  • TASK_KERNEL_STACK_TOP → [AtomicU64; MAX_TASKS]: removed the standalone unsafe in prepare_ring3_switch().
  • TASK_VALID → [AtomicBool; MAX_TASKS]: removed the standalone unsafe { TASK_VALID[slot] = false; } in release_task_slot() and the inline if !unsafe { TASK_VALID[slot] } in for_each_active_cap(). All stores use Release ordering; the lock-free for_each_active_cap read uses Acquire to pair with Release stores and ensure cap table visibility; all reads inside lock-protected regions use Relaxed.
  • CORE_PENDING_PML4 is AtomicU64 so its load/store sites are safe - only the Frame::from_phys + free_frame pair and the send_ipi call needed unsafe blocks.

One remaining line in for_each_active_cap is still unsafe:

  • unsafe { TASK_CAP[slot].assume_init_ref() }.for_each_slot(&mut f) - reads a MaybeUninit<CapTable> after TASK_VALID[slot].load(Acquire) returned true. Sound because the Acquire load pairs with the Release store in reserve_task_slot/enqueue, establishing that the CapTable write happened-before this read. CapTable cannot be const-constructed so MaybeUninit is necessary; assume_init_ref is the unavoidable unsafe assertion that the slot is initialised.

One additional unsafe {} block (count +1, net): TASK_CORE reads in pick_next - the wake-hint fast path (TASK_CORE[hint]) and the RR scan loop (TASK_CORE[idx]) both read this static mut [u32; MAX_TASKS] array. Sound because TASK_CORE[slot] is written exactly once at spawn and never modified thereafter (§9.1 static-placement invariant); all indices are bounded by MAX_TASKS; reads are unsynchronised but safe because the value is immutable after task spawn. Two new unsafe lines were added; one previously-unsafe access to a now-atomic variable was removed, yielding net +1. // SAFETY: comments present in source for both new blocks.

Sound in aggregate: all arrays are indexed by slot or core_id with bounds checked at their call sites; ring3 switch is called only from the scheduler with interrupts disabled; cap init runs before the task is visible to other cores; deferred PML4 free runs only after CR3 switch. // SAFETY: comments present in source for all blocks.

2026-07-15 - multi-arch stubs: aarch64 / arm / loongarch64 / riscv32 / riscv64 / s390x

Six per-arch scaffolds under arch/<isa>/mod.rs, added while proving the demarcation (docs/multi-arch.md). Each is the arch layer (a permitted §18.1 layer, exactly like arch/x86_64/) for a non-x86 target: the _start naked entry, a minimal boot bring-up, and a UART poke. All unsafe in them is the same class as x86's arch layer - inline asm! for the boot sequence and raw MMIO writes to a fixed UART register - and each block carries a // SAFETY: comment. They exist only to compile (all six) and boot (four: aarch64/riscv64/loongarch64 to a UART print, x86 to the full shell) the arch-neutral kernel; no neutral file gained any unsafe. arch/arm/mod.rs and arch/riscv32/mod.rs are the 32-bit word-size proof (docs/multi-arch.md, "Word size"); arm needs no atomics shim (ARMv7 LDREXD), riscv32 uses portable-atomic (RV32A has no 64-bit atomic). Counts are the current stub sizes; they may grow as a real port fills the arch surface, each increase carrying its own // SAFETY: and an audit bump.

copy_user_to_kernel (2026-08-30) - the loader can read an image out of USER memory; loader.rs 2 -> 1

Step C hands the kernel a service image from the SUPERVISOR's address space instead of its own rodata (docs/service-ownership.md). read_user_bytes cannot carry it: it is capped at one message (4 KiB) and lands in a shared per-core scratch slot, while the largest service image is 405 KiB.

New arch seam, one per arch: copy_user_to_kernel(src, dst, len) -> bool, bounded to ONE PAGE per call. The loader copies the overlap of a single destination page at a time, so the kernel never holds more than a page of an untrusted image at once. The bound lives in the seam, where it can be read off the source, rather than in the caller's discipline (26.6).

filecountwhat
arch/x86_64/syscall_entry.rs15 -> 16 (+1)the guarded copy_nonoverlapping, under the same USER_COPY_ACTIVE guard as read_user_bytes: a #PF on the source does not return, pf_handler kills the caller.
arch/arm/mod.rs49 -> 50 (+1)the same copy, after validate_user_ptr + user_range_accessible probing, as this port's read_user_bytes already does.
arch/aarch64/uaccess.rsunchangedthe new copy_user_to_kernel is a SAFE wrapper over the existing private copy_from_user.
loader.rs2 -> 1 (-1)shrank. See below.

loader.rs got smaller while gaining the ability to read user memory, which is worth recording because the obvious implementation would have grown it. The first attempt added an unsafe fn copy_out(off, *mut u8, len) beside the existing raw write_bytes zeroing and raw copy_nonoverlapping: 2 -> 4, in a file that is neither a permitted layer nor one of 18.5's grandfathered floors.

Instead the destination page is turned into a safe slice ONCE:

#![allow(unused)]
fn main() {
// SAFETY: `phys` is from the frame allocator and exclusively ours until mapped; the HHDM is
// established before load() is ever called.
let page: &mut [u8] = unsafe {
    core::slice::from_raw_parts_mut((get_hhdm_offset() + phys) as *mut u8, PAGE_SIZE)
};
page.fill(0);                                   // was: raw write_bytes
src.read_into(src_off, &mut page[a..b]);        // was: raw copy_nonoverlapping
}

Both raw writes become safe slice operations, and the only remaining raw access in the whole loader is that one from_raw_parts_mut. This is 18.5's rule applied rather than argued around: the new raw access belongs in a permitted layer (the arch seam), and the caller keeps none of it.

The double-fetch this design closes. A user image is mutable by its owner WHILE the kernel reads it, so validating program headers in place and then acting on them would let a supplier pass validation and rewrite the offsets before the copy - landing bytes at an address the kernel approved a different value for. The header window (one page: an ELF64 header is 64 bytes and a program header 56, so 72 of them fit) is therefore fetched ONCE into a bounded kernel buffer, and every decision is taken from that immutable copy. Segment CONTENT is streamed live and never interpreted, so a byte that changes mid-copy can only corrupt the image its own supplier provided. An image whose program headers sit beyond the window is refused, loudly, rather than read in place.

smp/names.rs NEW, 4 lines (2026-08-30) - owned task names, so a SPAWNER can name what it spawns

Task names were [&'static str; MAX_TASKS]. That is a small thing with a large consequence: a name had to be a string literal compiled into the kernel, so a caller could not supply one - which is exactly why the kernel held a service_config row per service. Making the bytes owned is the prerequisite for taking that catalogue out of ring 0 (docs/probe-params-design.md; 193 rows and 2,317 lines left kernel/src/task/mod.rs in the same change).

Why it is in smp/ and not task/. Owning the bytes needs interior mutability behind a shared static. Written where it is used, that would have grown task/scheduler.rs from 37 to 40 - a grandfathered floor, which 18.5 permits to grow only by a CLAUDE.md amendment, and only after trying a permitted layer first. A permitted layer fits here honestly rather than as a dodge: a shared array with one writer per slot and readers on every core IS a concurrency primitive, and it belongs beside SpinLock. task/scheduler.rs stays at 37 with no unsafe of its own; the four blocks are audited once, here.

BlockArgument
unsafe impl Sync for NameTableEvery write goes through set, whose caller guarantees it is the only writer for that slot; readers only read bytes below an Acquire-loaded length.
get: &*self.bytes.get()The length was published with Release after those bytes were written, so they are initialised and stable. The target is a static, so the returned 'static borrow is genuine and not an assertion.
set: &mut *self.bytes.get()Caller-guaranteed exclusive write access to the slot (the spawn path holds it reserved with interrupts off, before the task is enqueued and therefore before another core can observe it).
set (the unsafe fn itself)The single-writer precondition is a real data-race precondition, not boot ordering, so 18.5's "make it a safe fn" does not apply - violating it is UB, not a wedge.

Length last, and length first. The writer publishes bytes then length (Release); a reader loads length (Acquire) then bytes. A reader racing a write sees the old length over new bytes, or the new length over new bytes - never a length that outruns what has been written.

Bounded and flat (26.6.1): a fixed MAX_TASKS * 32 byte array in .bss. No heap, no interner.

arch/arm/irq.rs 13 -> 16 (+3): routing the USB interrupt to userspace (arm32 Phase 1)

arch/ is a permitted layer (§18.1), so this is an inventory update rather than an amendment.

The three lines are the mechanism that lets a device interrupt reach a userspace driver on arm32:

LinePurposeSAFETY argument
mask_usb_irqwrite bit 9 to IC_DISABLE_IRQS_1volatile write of ONE bit to the Device-mapped legacy IC. Writing 1 disables that line; 0s are ignored (the register is not read-modify-write), so it cannot disturb another line.
unmask_usb_irqwrite bit 9 to IC_ENABLE_IRQS_1as above, against the enable register. Reached from the IrqUnmask syscall.
route::deliver(USB_VECTOR)hand the IRQ to the registered endpointcalled from inside the IRQ handler with interrupts masked, which is deliver's documented contract.

The mask pair is load-bearing, not defensive. The DWC2 line is level-triggered: it stays asserted until the driver clears an HPRT change bit or a channel HCINT. The in-kernel driver never needed to mask it because it cleared the condition inline, before returning. A userspace driver has not run yet when the handler returns, so an unmasked line re-asserts immediately and the core makes no further progress - which the liveness watchdog correctly turns into a panic.

This is also why arch/arm/mod.rs's ioapic::mask_vector / unmask_vector stopped being no-op stubs. They were harmless while every device interrupt was serviced inside the kernel; routing one outward makes them the thing that holds the line off.

arch/arm/timer.rs 4 -> 7, arch/arm/irq.rs 16 -> 18, arch/arm/mod.rs 45 -> 49: microsecond timer, cross-core doorbell, console TX ring

Three mechanisms, all in arch/ (a permitted layer, §18.1), every block SAFETY-commented.

timer.rs +3 - the BCM2835 System Timer's compare registers. arm_oneshot_us and arm_oneshot_at clear the stale match then program compare 3; take_oneshot_match reads the status register and writes the bit back to clear it. All are volatile MMIO to the Device-mapped System Timer block, the same window the existing counter read already uses. Clearing before arming matters and is commented: an uncleared match makes a previous fire look like this one.

irq.rs +2 - the cross-core doorbell. ring_doorbell writes one bit to another core's mailbox WRITE-SET register, and the dispatcher reads the mailbox and writes the observed bits back to clear it. Write-set semantics ignore zeroes, so neither can disturb a bit another sender has set. The core index is bounds-checked against the four this SoC has before it becomes an offset.

mod.rs +4 - the console TX ring. Writers append to a fixed 8 KiB buffer and the timer tick pushes bytes into the PL011 FIFO. The unsafe is the ring access (an index masked into a fixed array, so always in bounds) and the volatile FIFO reads/writes. The drain stops the moment the FIFO reports full, so it cannot spin on a wedged UART, and the panic path's blocking flush keeps the same bounded TXFF poll the byte writer already had.

Why these exist: a serial write inside an un-preemptible syscall stalled its core for ~9 ms, the cross-core wake did nothing at all (so every IPC hop cost up to a 10 ms tick), and the kernel had no clock able to name the 125 us microframe the USB driver must hit. Each is measured in the commit history rather than asserted.


Framebuffer reservation (2026-08-29) - memory/allocator.rs 45 -> 46

reserve_no_free records a physical range the allocator must never return to the free pool, using the same table and the same refusal as a DMA arena. Its one unsafe is the &mut ALLOCATOR under alloc_lock() inside without_interrupts, exactly as alloc_dma_arena above it does.

Why it is needed. The framebuffer is DEVICE memory that the kernel MAPS into the console service, so the kill-path reclaim walks it like any other leaf and frees it. That inflates free_frames past total_frames and - far worse - marks the display's pages allocatable, so a later task can be handed the framebuffer as ordinary RAM. Measured on the Wyse after chaos max-carnage: 315392 double-frees, every one inside 0x90000000..0x91f97000 (exactly 3840x2160x4), and free 6666 frames above total.

The walker DID have a guard for precisely this, and it names the hazard in its own comment. It tested PCD && PWT, and the framebuffer is mapped PCD-only on x86 so the firmware's write-combining MTRR applies - the 596 ms -> 29 ms console fix. A performance change disarmed a safety check, because the check inferred "device memory" from cache bits. The guard is now PCD alone AND repeated at the resource, where how something is mapped cannot affect it.

Pi 4 shell-fault instruments (2026-08-28 / 29) - REMOVED, and what they actually found

These instruments are gone; the counts above are back to their pre-investigation values, except ptables.rs (+1, the framebuffer cache maintenance recorded below, which is a fix and stays). This section is kept because §1 makes an amendment ratified history, and because the conclusion the instruments produced was WRONG in a way worth recording.

The hunt was for a Pi 4 shell fault that smashed the task's own stack with 0x20. Successive instruments

  • a mutated canary, a kernel-side write-destination tracker, a cross-task frame scan, an allocator consistency check, and finally a read-only guard page - eliminated the shell, other tasks, the kernel and the allocator, and led me to state that the writer was a device (DMA), because the guarded page was written with no permission fault.

That conclusion was wrong, and the instrument that produced it was itself the last crash. The read-only guard page rested on the claim that no legitimate write could land there, since every byte belonged to a canary local to run_lines. That holds only while run_lines is LIVE. Once it returns, the stack unwinds and ordinary prompt-level code reuses the region - so the page I had permanently write-protected was written legitimately, taking a permission fault at SP_EL0 == FAR_EL1 with the canary fully intact. A CPU write to that page faults exactly as designed; the earlier "no fault" readings were of a stack that had already unwound, where a dead frame's contents mean nothing.

What actually fixed the machine was the framebuffer memory type below. With the framebuffer mapped Device-nGnRnE a single repaint took 582 ms, and selfcheck never reached its end. With it mapped Normal Non-cacheable, selfcheck runs to completion (356/0, repeatedly, hardware-verified) and the rendering is fast and smooth.

The lesson worth keeping: an instrument that perturbs the system is a suspect, not a witness. This one changed page permissions on a live stack, and every conclusion drawn from its silence was an artefact.

Framebuffer memory type (2026-08-29) - not a diagnostic, a fix

arch/aarch64/ptables.rs 31 -> 32 (+1), and it is the only one here that is permanent rather than an instrument. The framebuffer grant reaches the console service as an MMIO grant (PCD|PWT), which AArch64 faithfully translates to Device-nGnRnE - the type that forbids gathering, buffering and reordering. That is right for device registers and wrong for a framebuffer, which is RAM the display controller scans out: it makes every 4-byte pixel an individually-acknowledged bus transaction, measured at 582 ms for one 1920x1080 repaint (about 14 MB/s). task/mod.rs has said "Normal NON-cacheable" in a comment since the grant was written; the code did not do it, because MAIR had no such slot.

The new unsafe is a clean-and-invalidate of the page at the moment the alias is created. Handing a page to a non-cacheable mapping while the kernel still maps it cacheable through the direct map is a mismatched-attribute alias, which the ARM ARM leaves UNPREDICTABLE; the concrete hazard is a dirty line left from the boot console evicting later on top of what the terminal has since drawn - a display that corrupts itself seconds after a handover that looked clean. Flushing at the alias point removes it.

The hole in my own scan (2026-08-29, third run)

The third run came back valid on every precondition - guard page INTACT at arm time, descriptor UNCHANGED, page WRITTEN, allocator consistent (0 of 64 stack frames lost). And the shell's per-statement canary check had fired ZERO times across hundreds of statements, so the destruction is sudden and total INSIDE one statement, not a slow drip.

That combination exposed a hole in the cross-task scan: it skipped the faulting task itself. If ONE address space maps a frame twice, a write through the second VA reaches the first VA's memory through a DIFFERENT descriptor - so a read-only mapping on one of them stops nothing and raises nothing, no other task is involved, the allocator stays perfectly consistent, and no device is required. That reproduces every observation in this investigation at once, and neither existing check could see it: the "stack frame aliasing" line compares the 64 stack pages against each other, never against the rest of the address space, and the cross-task scan skipped this address space by construction.

find_all_pa_diag (ptables 29 -> 31) counts EVERY VA in a root that maps a given frame, and the dump now runs it on the faulting task's own root first. exceptions.rs 23 -> 24 for the call. Two or more VAs is the answer; one rules the hypothesis out.

It came back 1. So self-aliasing is out, and with it every CPU path: a read-only page written with no permission fault, one VA in one address space, no other address space mapping the frame, an allocator that has lost nothing, and zero kernel writes overlapping it. The writer is a device.

THAT CONCLUSION WAS WRONG. Kept because this file is append-only evidence and the reasoning is the point, but it must not be read as standing. The read-only guard page was ITSELF the last crash: the claim "no legitimate write can land there" holds only while run_lines is LIVE, and once it returns the stack unwinds and ordinary prompt code reuses those addresses. The final run took a PERMISSION FAULT at SP_EL0 == FAR_EL1 with the canary fully intact - a CPU write to that page faults exactly as designed - so the earlier "written with no fault" readings were of a stack that had already unwound, where a dead frame's contents mean nothing. What actually fixed the machine was the framebuffer memory type (Device-nGnRnE -> Normal non-cacheable, 582 ms a repaint); see the section above and docs/console-service.md 9.5.1.

exceptions.rs 24 -> 25 adds the instrument that follows from that. A device does not write "a stack"; it writes a PHYSICAL RANGE whose size and alignment come from the transfer that produced it. The dump now measures the contiguous 0x20 run in physical memory around the frame - 512 bytes is a disk sector, 1536/2048 a network frame buffer, a page or more a bulk transfer - which is what turns "a device did it" into "which device". It also settles whether the damage stops at the shell's stack or runs through it into neighbouring frames. Bounded to +/-2 MiB, every address checked against the RAM map before it is read.

find_pa_diag walks the TABLES rather than probing every VA deliberately: probing the 2 GiB user range a page at a time is half a million walks per address space, and on a machine with ~180 live tasks that turns a fault dump into a minutes-long hang - a diagnostic that looks like a lock-up gets read as one.

task/scheduler.rs does not move. The cross-task scan needs each task's page-table root, and that lives in a private static mut there - a grandfathered layer whose count may only fall (§18.5). Rather than add an accessor (which would have grown it, as an earlier attempt in this investigation did before being reverted), the root is returned as a new field on the existing task_stat snapshot, read inside the unsafe block that function already has. No new unsafe keyword, so the floor holds.

The trigger is a marker string on a line the kernel is already reading, not a syscall. That is deliberate: a service must not be able to ask the kernel to change page permissions, and adding a syscall for it would grow the kernel's responsibilities (§4.4). The marker only selects a page of the caller's OWN address space, and the kernel refuses any value that is not page-aligned and inside the user stack range, reporting the refusal rather than acting on it (invariant 12). It fires once per boot. All of it is diagnostic and goes when the fault does.

arch/arm/page_tables.rs 31 -> 32 (2026-10-07): PageTable::discard, and the arenas doubled

One block: the body of PageTable::discard, which gives back an address space that was built and never run - reclaim_user_frames then free_page_table_root, the kill path's two steps. Sound for the same reason those are sound on a Dead task's root, and more simply: discard consumes the PageTable, and a table that was never turned into a CR3 (into_cr3 takes self) was never written to TTBR0, so no core's walker can reach it. Its one caller is the ARMv7 loader selftest, which built a table to prove the loader and dropped it, keeping one of the arena's L1 roots with its L2s and frames for the life of the machine. loadtest.rs itself gains no unsafe.

Beside it, constants only: L1_TABLES 16 -> 32 and L2_TABLES 128 -> 256 (512 + 256 KiB, static, the bound still visible per 26.6.1), and a rate-limited line naming an exhausted arena where it is known - the failure reaches the spawner as FrameAllocFailed, which named a frame when no frame was short. Found by a Pi 2 chaos run whose spawns were refused with 9 MiB of 921 in use (docs/wifi-usb.md 47).

arch/arm/page_tables.rs 32 -> 35 (2026-10-12): kernel-stack guard pages, and no user page over kernel memory

Three blocks for one fault, and the first is not its cause. ask and recorder died whenever they ran in task slot 15 on the Pi 2. The cause was LAYOUT: the kernel reaches its own memory at its physical address, every service is linked at 0x400000 with its context page at 0x3ff000, and in a task's table its own pages replace the kernel's view of those addresses. The kernel's BSS started right after its data and covered that range, so kernel stack slot 15 (0x3ff000..0x40f000) was, while the slot-15 task's table was installed, that task's code: its first entry pushed onto ask's entry page. Measured, not inferred - the page changed between publish and first entry with no kernel store through the identity map and no user mapping writing it. kernel-arm.ld now starts BSS at 8 MiB, above every image (the largest ends at 0x6aa034), and asserts that text, rodata and data end at or below 0x3ff000.

Block 1 (+1): the body of unmap_4k_strided, which was an empty stub here. It was written while a stack overflow was the suspect, and the guard never fired, so it fixed nothing in this fault. It stays as the hardening x86 and the Pi 4 already have (RISC-V 64's is still a stub): the arm32 boot never called task::install_kstack_guards, so the Pi 2 had no guard pages at all. It splits each section the pool spans into a kernel-owned L2 table that maps every page exactly as the section did (l2_small_page with PRESENT|WRITABLE is the section's own attributes), then clears the guard entries. Sound because: it runs once on core 0 at boot with IRQs masked, before any other core or task exists; the tables are a dedicated static nothing else references, cleaned to the point of coherency before any L1 entry points at them (the walker reads non-cacheable); and every non-guard page keeps its physical address and attributes, so an access in the window before the TLBIALL reaches the same memory whichever descriptor it uses. The tables are outside the L2 arena, so reclaim_user_frames never frees one. entry_for_va asks the CPU's table walker (mmu::translate, ATS1CPR, already audited) and adds no unsafe. Proven in QEMU: a write to a guard page takes a translation fault.

Blocks 2 and 3 (+2): kernel_ranges, the addresses of three linker symbols (the unsafe extern declaring them, and the one unsafe block taking their addresses), so PageTable::map can refuse a USER page that lies on kernel memory - the same fault arriving another way, when an image grows into BSS. Sound because nothing is read through the symbols; only their addresses are taken. Proven in QEMU: a user page at 0x00900000 is refused with a line naming the kernel's ranges.

arch/arm/page_tables.rs 35 -> 37 and arch/arm/usermode.rs 15 -> 17 (2026-10-13): no boot selftest leaves a table in the kernel L1

The defect. The user-mode selftest mapped its code and stack pages at 0x5000_0000 through map_in_active_tables while the kernel's own L1 was active, and never unmapped them. That left a kernel-owned L2 for the megabyte at 0x5000_0000 in the kernel L1, and fill_kernel_identity copies every kernel L1 entry into each child's empty slots. 0x5000_0000 is TASK_HEAP_VA_START on 32-bit ports, so the first heap megabyte of EVERY task was mapped into that one shared table. The reclaim frees only tables the dying task owns, so it skipped it: measured in QEMU, every mem-pressure spawn and kill leaked 256 frames (7236 held, 6980 freed), and the dead task's user pages stayed in the table for the next task to inherit - two tasks' heaps in one table, against invariant 2. Found by a 1000-round chaos soak on the Pi 2 whose memory rose 2 MiB with every service's footprint unchanged.

page_tables.rs, block 1 (+2): release_active_l2, an unsafe fn and its block. It clears the L1 slot holding a VA in the ACTIVE table and returns that L2 to the arena, only when the active root owns it. Sound because it runs in single-threaded boot before any task exists; the L1 is identity-mapped; the entry is cleaned to the point of coherency for the non-cacheable walker before the TLB entries of the whole megabyte are invalidated; and an L2 the active root does not own is left untouched (false). The page-table selftest's own manual clear (which cleared the L1 slot and kept the L2, losing one arena table for the life of the machine) is replaced by a call to it - the call site's unsafe replaces the block it removes, so the count rises by the new function's two only.

usermode.rs, block 2 (+2): release_test_pages. One unsafe call to release_active_l2, and one block returning the selftest's two frames to the allocator (Frame::from_phys and free_frame are both unsafe). Sound because both frames came from alloc_frame in the same selftest and their only mapping was the slot just cleared, so each is freed exactly once; it is called on the failure path after mapping as well as at the end.

arch/x86_64/mod.rs 38 -> 40 (2026-10-16): hw_random answers from RDRAND

The x86 port answered InspectKernel query 19 with None ("x86 RDRAND is a trivial follow-up"), so nothing on a PC could make a secret: the TLS client the godspeed package manager uses (sdk/tls) refuses to run without a hardware random source rather than guess one. Two blocks, both in hw_random:

BlockWhy it is sound
__cpuid(1)Leaf 1 exists on every x86_64 CPU; CPUID is unprivileged, has no side effects and touches no memory. ECX bit 30 says whether RDRAND exists, and RDRAND is not executed unless it does
_rdrand32_step(&mut word)Executed only where CPUID says RDRAND exists. It writes one register and the carry flag; the only memory it touches is word, a local it holds the only reference to

Retried at most ten times, Intel's documented bound for a transient underflow. An all-ones word is refused, as Linux refuses it (arch/x86/kernel/cpu/rdrand.c): several AMD parts have reported success while returning 0xFFFFFFFF every time, and a constant handed back as randomness would become a key. A CPU without RDRAND answers None, as before. Which of the boards here have it is not checked yet; random says on each.

(Same day, later: both PCs have it - random answered on the T630's AMD GX-420GI and the Wyse 5070's Intel J5005, and godspeed check completed a TLS handshake on each. And the compiler reports the __cpuid(1) block as an unnecessary unsafe (unused_unsafe: the intrinsic is a safe function on this toolchain), so this count holds one block more than the code needs. Removing it is a code change, left to be made on its own; until then the count records the block that exists.)

arch/x86_64/mod.rs 40 -> 36 (2026-10-17): __cpuid is a safe function

The note above found one unnecessary block; the compiler reports four, all in this file and all the same case: core::arch::x86_64::__cpuid is a safe function on this toolchain, so an unsafe around it asks for nothing. Removed from hw_random (leaf 1) and from the three in cpu_identity (leaves 0x80000000, 0x80000002-4 and 0). The calls, their order and the extended-leaf guard are unchanged. _rdrand32_step keeps its block, which it needs. The __cpuid calls in boot.rs sit inside unsafe fn bodies, where the compiler does not flag them, and are left as they are.

GodspeedOS Licensing

Status: Settled. This is the recorded licensing decision, now backed by real LICENSE files: the OS is GPL-2.0-only (repo-root LICENSE) and the SDK + examples are Apache-2.0 (sdk/LICENSE). Per-file SPDX-License-Identifier tags mark every source file's zone. This document records the strategy and rationale behind that text.

Where the terms are written. sdk/LICENSE carries the Apache-2.0 text for the SDK (sdk/rust), the standard library (stdlib/rust) and examples/; the last two have no LICENSE file of their own and are marked by their SPDX tags. The protocol crates beside the SDK - sdk/wifi, sdk/tls and sdk/audio - are GPL-2.0-only, like the OS, and their tags say so. scripts/spdx_check.py checks on every build that each tracked .rs file carries a tag, and that the tag is its tree's licence.

1. The decision

ComponentLicenseWhy
The OS - kernel + trusted root (supervisor, arch, smp) + system services (events, block-driver, fs, drivers, shell) + host tooling (osdev) + testsGPL-2.0-only (like Linux)The OS itself stays free and open; improvements flow back.
SDK (sdk/rust) and the standard library (stdlib/rust)Apache-2.0Services link them; permissive keeps the app ecosystem free to choose any license. Apache-2.0 adds an explicit patent grant over plain MIT.
Examples (examples/)Apache-2.0They are reference apps meant to be copied as the starting point for real services; permissive keeps that copy unencumbered.
Userspace services / appsAny license (their authors' choice)They are separate programs over IPC - not derivative works of the kernel. A service built against the SDK carries no copyleft, so proprietary commercial services are fine.
Bundled bootloader (Limine)BSD-2-Clause (upstream; preserve its notice)A separate program; GPL-compatible (§3).

1a. Two SDK files relicensed, 2026-10-25

sdk/rust/src/trace.rs and sdk/rust/src/churn.rs carried GPL-2.0-only tags from their first commits, inside the Apache-2.0 SDK, and no commit or document gave a reason. It mattered most for trace.rs: the SDK's own service_context.rs calls it on every send and publish, so every program built on the SDK or on gs compiled GPL code in, which is the case this split exists to prevent. Both files have one author, the copyright holder, who relicensed them Apache-2.0 on 2026-10-25, so the SDK is wholly what the table above says it is. scripts/spdx_check.py had recorded the two as named exceptions; it records none now.

The one-line summary: copyleft OS, permissive ecosystem. GodspeedOS-the-OS is GPL-2.0-only and open; what you build on it (against the Apache-2.0 SDK) is yours.

2. The license boundary is the capability/IPC boundary

A microkernel gives GodspeedOS something Linux never cleanly had: an unambiguous non-derivative line.

  • Userspace services are separate programs that talk to the kernel only over the syscall ABI / capability IPC. They are never linked into the kernel, never share its address space (§2.2, §3.2, §10.1).
  • Therefore a service is not a derivative work of the kernel. The GPL's copyleft reaches the kernel and the trusted root; it does not reach across the IPC boundary to userspace.
  • Linux needs a "userspace programs that use syscalls are not derivative works" clarifying note because its boundary is fuzzier (in-kernel modules, etc.). GodspeedOS's strict isolation makes the boundary structural, not a promise - the IPC edge is the license edge.

The SDK is the one thing that needs care, because services link it. A GPL SDK could force apps to GPL by linkage. So the SDK is permissive (or LGPL): the kernel stays copyleft, but linking the SDK imposes nothing on the app. This is what keeps the "portable world" / app ecosystem (docs/prime.md) open to any license.

3. The bundled bootloader (Limine)

  • Limine is BSD-2-Clause (permissive). BSD-2-Clause is GPL-compatible: permissive code may be bundled with and distributed alongside a GPL project; the only obligation is preserving Limine's copyright notice.
  • Limine is a separate program (the bootloader binary), not linked into the kernel - they communicate via the Limine Boot Protocol (a data handoff at boot, arch/x86_64/ boot.rs). So the licenses do not entangle, exactly as a GPL Linux booted by GPL GRUB or by a BSD bootloader is fine.
  • Not a lock-in: Limine is a v1 commitment (Appendix A), not permanent. The kernel owns the handoff; swapping bootloaders (GRUB, direct UEFI, custom) is bounded work in one file. The Prime A/B model's UEFI-boot-variable variant is bootloader-agnostic (docs/prime.md §8.2). Limine is kept because it's good, not because we must.

4. Why copyleft for the OS

  • GodspeedOS's value is the coherence of the model (§26.1, "the model is the product"). Copyleft keeps that model - and every improvement to it - open and shared, which is the point of building a small, fully-understood system in the open.
  • Copyleft on the kernel + permissive ecosystem is a deliberate balance: the commons (the OS) stays a commons; the frontier (apps) stays free. It is the opposite of a permissive kernel that can be taken closed.

5. The recorded choices

These were the open questions; they are now decided:

  1. GPLv2 vs GPLv3 → GPL-2.0-only. "Like Linux" - the OS is GPL-2.0-only. This is the plain, broadly-understood copyleft for a kernel; the anti-tivoization and patent clauses of GPLv3 were not worth the narrower compatibility surface for v1.
  2. SDK permissive license → Apache-2.0 (for both the SDK and the examples). Apache-2.0 adds an explicit patent grant over plain MIT. Note the well-known one-way edge: Apache-2.0 is GPLv3-compatible but not GPLv2-compatible - which is exactly why the SDK is a separate, permissively-licensed component a service merely links, never code pulled into the GPL-2.0-only kernel. The capability/IPC boundary (§2) keeps the two zones from entangling.
  3. Where the LICENSE files live: repo-root LICENSE (the OS, GPL-2.0-only) + sdk/LICENSE (the SDK + examples, Apache-2.0). Every source file additionally carries a one-line // SPDX-License-Identifier: <id> tag naming its zone.
  4. Contributor terms: a CONTRIBUTING/DCO (sign-off) vs a CLA - how inbound contributions are licensed. (Still to formalize; does not affect the license of the code in tree.)

5a. Vendor device firmware, and the nonfree/ tree

Decided 2026-09-27. Some devices are not fixed-function silicon. The Pi 4's CYW43455 radio carries its own processor with no ROM firmware for the MAC, so it does nothing until a host uploads a vendor binary into it. Nobody writes that binary - not Linux, not the Pi's own boot firmware, not Windows - and its licence forbids modifying, reverse engineering, decompiling or disassembling it. There is no source to read and no permission to study the object, which makes it the one exception to §26.14's method in this whole project.

Such firmware IS committed here, under nonfree/<part>/, when its licence permits redistribution:

  • LICENCE beside it, because the terms require the notice to accompany every copy and a repository is a copy;
  • PROVENANCE - upstream URL, retrieval date, and a SHA-256 per file, so anyone can verify the copy against upstream without trusting this project;
  • scripts/nonfree_check.py enforces both on every build. A blob with no licence, or whose digest does not match its content, fails the build.

Where it does NOT permit redistribution, the blob stays out and scripts/get_firmware.py fetches it on the owner's machine. Two mechanisms for two legal situations, each declared rather than assumed.

The one exception: nonfree/aic8800d80/ (2026-10-04, the operator's decision). The VisionFive 2 Lite's AICSemi AIC8800D80 radio needs host-uploaded firmware like the CYW43455, and AICSemi has published NO licence for it that this project could find. It is in a THIRD situation, neither of the two above: not permitted, not forbidden, simply unstated. The files were committed anyway, on the operator's decision, from Radxa's packaging repository at the commit whose bytes match - MD5 for MD5 - what the board's own StarFive image loaded. Its LICENCE reproduces the only written term they travel under (Radxa's repository-wide GPL-3+ stanza, which this project does not read as a grant from AICSemi), names Gentoo's "freedist" label and StarFive's shipping of the same files, and says plainly that none of these is the vendor's licence. nonfree_check.py accepts it because the check guarantees that terms are PRESENT and bytes ACCOUNTED FOR, never that the terms permit redistribution - that was always a human reading, and here the reading is recorded rather than implied. If the vendor's terms appear and forbid redistribution, the files move to scripts/get_firmware.py.

Why not Linux's model. Linux keeps blobs in a separate linux-firmware repository, but that separation is Debian's social contract and the DFSG rather than a technical conclusion - a non-linked binary beside GPL code is mere aggregation, and Linux shipped them in-tree for years. GodspeedOS is not a distribution with a package manager, and fetch-at-setup was tried and failed three times on one file in an afternoon: the board name is a symlink, into a sibling directory, to a filename the packaging creates at build time. Absorbing that fragility once, here, beats pushing it onto every user.

(Note 2026-10-25: GodspeedOS has had a package manager since v0.29.0 - godspeed fetch then godspeed install, or a .gsp file. The firmware decision above was made before it and is unchanged: these blobs are still committed under nonfree/, not delivered as packages.)

The aggregation point matters and is worth stating plainly: the firmware is data uploaded to a device, not code linked into a GPL work. It does not make the OS a derivative of it, nor the reverse.


6. Scope of this document

The LICENSE files (repo-root GPL-2.0-only, sdk/LICENSE Apache-2.0) are the legal instruments; this document records the strategy behind them - copyleft OS, permissive ecosystem, IPC as the boundary - so the architecture and the license stay aligned rather than fighting each other.

Networking: a Capability-Mediated Userspace Service

Status: BUILT and shipping. net-stack and nic-driver are services on all four shipping ports - x86_64, Pi 2 (LAN9514), Pi 4 (GENET) and VisionFive 2 (dwmac); DHCP, ARP, ICMP, DNS and the shell's net/ping all work on real hardware. This header said "design, being built" until 2026-08-25. v2 (networking is out of v1 scope - §23.4). Non-normative until built and pinned by an identity test, at which point the relevant decisions are amended into CLAUDE.md. This doc records the architecture and the phased plan, mirroring docs/persistence.md.

Note (2026-10-09): TCP is built too. services/net-stack/src/tcp.rs implements it (design: docs/tcp-design.md; command surface: utilities/48_tcp.md). The sentences below that call TCP "next" or "committed" were written before it landed and are left as the plan they recorded.

Decision (2026-07-04, owner): TCP/IPv4 is committed from the start, not far-future. The build order is unchanged - the layers still stack NIC -> ARP -> IPv4 -> ICMP (ping) -> transport - but TCP is now a first-class goal of this effort, not a deferred maybe. The motivation is Commandment VIII made literal: TCP is "wait on truth, including the truth of failure" - an ACK is a truth, a peer's RST is a truth, and a retransmission timeout that fires is failure surfaced, not a guess. (TCP's own protocol timers - RTO, retransmission, TIME_WAIT - are protocol-mandated timing, the same exempt category as USB/AHCI hardware timing, not the correctness-by-timing that VIII forbids.) UDP (Phase 3) stays in as TCP's stateless stepping-stone - it proves the socket-cap model with no state machine - and TCP (Phase 5, brought forward) builds the state machine, retransmission, and ordered teardown on that same socket-cap foundation. Everything below the transport layer (Phases 0-2) is shared and identical either way, so work starts there regardless.


1. Thesis

Networking is a userspace service, and the kernel gains nothing. A socket is a capability - exactly as a file is a capability (§7.10, P2). The classic "this must live in the kernel" subsystem is, here, entirely additive userspace: a NIC driver service plus a network stack service, both built from capability mechanisms that already exist. No new kernel code.

That is not a happy accident - it is the constitution paying off. The kernel anti-scope (§4.4) forbade a network stack inside the kernel; the capability model (§7) made resources delegable to services; the delegated-resource-cap work (P2 / §7.10) already lets the kernel mint, route, and revoke caps for a resource whose meaning is owned by a service (it did this for files without learning what a file is). A network endpoint is just another such resource. The kernel will route opaque socket caps exactly as it routes opaque file caps, never learning what a socket is - so §4.4 holds verbatim.

This is the headline property and the reason to be confident in the design: the hardest "kernel" subsystem in the book is, in GodspeedOS, a couple of ordinary restartable services.


2. Why a service, not the kernel

  • §4.4 (kernel anti-scope): "The kernel does not contain ... network stack." Settled law.
  • §3.1 (no ambient authority): a network stack in the kernel would be ambient reach to the wire for anything that could call it. As a service holding the NIC caps, the network is reached only by holding a socket capability the stack minted - authority is explicit, not ambient.
  • §3.10 (30-minute whiteboard rule): a kernel TCP/IP stack is tens of thousands of lines no single engineer fully holds. Out of the kernel, the stack is just another service we can keep small.
  • §6 (TCB): in the kernel, a stack bug is a kernel bug. As a restartable service (like block-driver / xhci), a stack bug kills and restarts one service - it does not own the box.

The kernel's only involvement is what it already provides to every driver: MMIO/IRQ/DMA capabilities (§12.3), IOMMU confinement (§6.4, H1), and the delegated-resource-cap routing (§7.10). All of it exists.


3. Why our own minimal stack (not a port)

Same reasoning as "our own filesystem, not ext4/btrfs" (docs/persistence.md; §3.3 identity over authority, §3.10 whiteboard rule):

  • A ported POSIX/BSD stack (lwIP, or even smoltcp's socket layer) brings fd-and-ambient semantics that fight the capability model - file descriptors, blocking connect, an implicit routing table, ambient port binding. Retrofitting capability mediation onto that is more work than writing the thin layer we want, and it imports a vocabulary (§B.4) we deliberately don't have.
  • It is too big to fully understand. The value here is a stack a contributor can explain on a whiteboard, where every byte on the wire traces to a capability operation.

We write a minimal, capability-native stack: ARP + IPv4 + ICMP + UDP (all built + HW-proven on the T630, §11); TCP is the committed next phase, not deferred (the owner's decision at the head of this doc). smoltcp (Rust, no_std) is a fine reference for wire formats and the TCP state machine - we may borrow its parsing shape - but the socket layer is ours (caps, not fds) and the scope is "understandable," not "feature-complete."


4. Architecture

  ┌─────────────────────────────────────────────┐
  │  Services (hold socket capabilities)         │   e.g. a future `timeserver`, `httpd`, the shell
  ├─────────────────────────────────────────────┤
  │  net-stack   (service)                       │   ARP / IPv4 / ICMP / UDP (built)  [TCP next]
  │   - owns the host IP + ports (a resource)    │   mints + revokes SOCKET caps (§7.10)
  │   - routes datagrams <-> sockets             │
  ├─────────────────────────────────────────────┤
  │  nic-driver  (service, NOT IOMMU-confined)   │   per-board: e1000, RTL8168, GENET, dwmac;
  │                                              │   + a radio bridge to wifi-driver or wifi-usb (cable wins)
  │   - raw Ethernet frames in/out via DMA rings │   MMIO + DMA + IRQ caps (§12.3, §6.4)
  ├─────────────────────────────────────────────┤
  │  Kernel  (routes opaque socket caps only)    │   NO networking - delegated-resource-cap routing
  ├─────────────────────────────────────────────┤
  │  NIC hardware                                │
  └─────────────────────────────────────────────┘

Every arrow is IPC + capability-mediated:

  • A service ↔ net-stack: the service holds a socket cap; send/recv go through it.
  • net-stack ↔ nic-driver: a NIC-agnostic frame interface - "transmit this raw Ethernet frame", "here is a received frame". net-stack is therefore independent of the NIC model; swapping an e1000 driver for the T630's chipset is invisible above the frame interface.
  • nic-driver ↔ hardware: MMIO descriptor rings + DMA buffers + a receive IRQ (§12.2).

Two services, one clean seam (raw frames) between them, and the kernel underneath touching none of it.


5. The NIC driver

A userspace driver service, structurally identical to block-driver (AHCI) and xhci/ehci (USB):

  • Capabilities (§12.3): hw_mmio (the NIC's BARs), hw_interrupt (the receive IRQ line), and a DMA arena for the TX/RX descriptor rings + packet buffers.
  • NOT IOMMU-confined (§6.4). This said the opposite, and the correction matters because it is a trust claim. nic-driver is spawned hwclass::pci(0x02_00_00, BAR_AUTO, false) - the third argument is confine, and it is false (services/supervisor/src/main.rs, the nic-driver spawn row; on the boards whose NIC is not on PCI it is hwclass::NIC, with no confinement either). xhci and audio-driver are the only in-tree drivers spawned confined (an installed driver package's PCI device is asked for confinement too, CLAUDE.md 14.1); ehci and block-driver are deliberately left in passthrough because they keep a stale firmware DMA pointer that confinement would fault (kernel/src/task/mod.rs, the confine flag on HwClass::Pci). So the NIC driver's DMA is unconfined: a buggy one is bounded by the arena it was granted, but a compromised one can point the controller anywhere in RAM, which is kernel-equivalent reach by §6.4's own rule. It is restartable, and it is in the TCB on every machine today. All unsafe lives behind the SDK's audited Mmio/Dma wrappers (§18.1) - the driver itself is unsafe-free.
  • Model-specific, like AHCI. The dev driver is e1000 (Intel 82540EM) - exhaustively documented and emulated by QEMU (-device e1000). (virtio-net is simpler but paravirtual-only - it cannot run on bare metal, so it is not the dev target.) Phase 0 identified the T630 as a Realtek RTL8111/8168 (10ec:8168), NOT Intel - and QEMU has no RTL8168 model (it emulates the old rtl8139, e1000, virtio-net, but not the 8168). So the two drivers are genuinely separate: e1000 in QEMU carries all the stack development, and a Realtek RTL8168 driver is Phase 4, tested only on the T630 bench. The NIC-agnostic frame interface is exactly what makes this clean - the stack never knows the difference. This is the "agnostic stack, model-specific drivers" split being load-bearing, not theoretical.
  • Raw frames only. The driver knows Ethernet framing and the ring/DMA mechanics; it knows nothing about IP. TX: a service hands it a frame, it enqueues a TX descriptor, rings the doorbell. RX: the receive IRQ (routed to the driver, §12.2) wakes it; it copies the frame out of the DMA buffer and hands it up. The frame interface is the entire contract.

Phase 0 - identify the hardware

The kernel already enumerates PCI (arch/x86_64/pci.rs, used by block-driver), so the first concrete step was a probe that enumerates PCI and prints each NIC's vendor:device + BAR + IRQ at boot.

Done + hardware-confirmed (2026-06-24). pci::init now logs network controllers (class 0x02) alongside USB/AHCI. Flashed on the T630, it printed: pci: NIC (network ctrl, subclass 0x00) at 01:00.0 vendor=0x10ec device=0x8168 MMIO=0xe000 IRQ=5 - a Realtek RTL8111/8168 Gigabit NIC. (QEMU's e1000 prints vendor=0x8086 device=0x100e, confirming the detection path in the shell-test harness.) One follow-up for Phase 1: on the RTL8168 BAR0 is an I/O BAR (bit 0 set) - the printed MMIO=0xe000 is that I/O base; the real register MMIO is BAR2. Phase 0 should print all BARs and flag I/O-vs-memory so the driver grabs the right one.

Costs almost nothing and de-risked the hardware target: we now know Phase 4 writes a Realtek driver.


6. The capability model - a socket IS a capability (the centerpiece)

This is the part that makes networking cheap here, because the mechanism already shipped (P2).

net-stack owns the network resource: the host IP and the port space. There is no ambient network - a service that holds no socket cap cannot put a byte on the wire (§3.1).

  • Open (bind a UDP port, or connect to a peer): the service asks net-stack, which resource_mints a fresh socket capability (a delegated resource cap, §7.10) with chosen rights (SEND/RECV), records SocketId -> (proto, local, remote), and hands a narrowed copy to the service - the exact move fs makes on Open.
  • Send = invoke the cap. The holder sends on the socket cap; the kernel validates it (generation
    • required right) and routes the message to net-stack's endpoint badged with the opaque SocketId (the LastRecvBadge mechanism, §22 Test 14). net-stack builds the datagram and hands the frame to nic-driver. The kernel never learns this is a packet - it routes an opaque resource.
  • Recv. Incoming datagrams are matched to a socket by net-stack and delivered to the holder's recv endpoint. No socket cap, no delivery.
  • Close = revoke. net-stack resource_revokes the socket (generation bump, §7.5); every outstanding cap to it goes stale - the next use returns CapRevoked. Identical to deleting a file.

So a socket cap has every §7.3 property a file cap has - unforgeable, non-escalating (rights narrow on transfer), scoped to one SocketId, revocable, generationed. "A socket is a capability" is literally true, with zero new kernel mechanism. The kernel's resource_mint/Invoke/revoke are resource-agnostic; net-stack is simply a second RESOURCE_MINT holder alongside fs.

The security upside (Appendix D.4, for the wire)

curl evil.com cannot exist unless something explicitly grants the requester a socket cap to that destination. There is no ambient socket layer to reach. The shell (or a policy service) becomes the deliberate place where outbound-network authority is decided - the same property D.4 describes for rm -rf /, now for exfiltration. A compromised service with no socket cap is network-mute by construction.


7. Addressing and identity

  • The host IP. net-stack owns it. Static config first (an IP/mask/gateway baked into the stack's contract or a tiny config); DHCP is built (itself just a UDP client of net-stack - a real lease + gateway is HW-proven on the T630, §11 Phase 3), with static config still the fallback.
  • Ports are resources net-stack allocates. A UDP socket cap is authority over a (proto, local port) (and optionally a fixed remote); a future TCP socket cap is authority over one connection.
  • Identity over location (invariant 11). A socket cap is stable identity; the NIC it egresses, the route it takes, even the host's L2 address are location. This is the same separation SMP made between service identity and core, and it is the on-ramp to cluster mode (Appendix C.4): EndpointId -> (NodeId, CoreId) and a remote socket are the same generalization - a capability whose target may live on another machine. We are not building that now, but the socket-as-capability model does not have to be redesigned to get there.

8. The stack - layered, phased

Each layer is small and testable; the milestone for each is a concrete wire event.

  • ARP - resolve IP <-> MAC on the local segment (a tiny cache + request/reply). Needed before any IP frame can be addressed on the LAN. The shell surfaces it as net arp <ip> (resolve one host) and net scan (ARP-sweep the local /24 for live hosts).
  • IPv4 - parse/emit headers, checksum, fragmentation we refuse loudly rather than implement at first (datagrams over MTU are an error, §26.7), a single static default gateway (no routing table). IPv6 is far-future.
  • ICMP - echo request/reply. ping is the first end-to-end milestone - the networking analogue of v1's ping/pong: proof the whole NIC -> ARP -> IP -> ICMP -> back path works on real wire.
  • UDP - stateless datagrams. The first socket-cap milestone: a service opens a UDP socket cap, sends a datagram, recvs a reply. Stateless, so it survives a net-stack restart trivially.
  • TCP - the committed next phase (§11 Phase 5), not far-future. The state machine, retransmission, windows, and ordered teardown are the bulk of a real stack. Stateful, so a net-stack restart drops live connections (see §9). It builds on the now-solid UDP + socket-cap foundation.

9. TCB posture and restartability

  • nic-driver - restartable, but NOT confined and therefore in the TCB (§6.4). Restartability is real and hardware-proven: its death reclaims its DMA resources, the supervisor respawns it, it re-inits the NIC and re-exposes the frame interface, and net-stack reacquires it by name and retries (§14.3). What does not follow is the TCB drop - that needs confinement, and it is spawned confine=false. Restartable bounds the ACCIDENT; only the IOMMU bounds the COMPROMISE.
  • net-stack - a restartable service.
    • UDP is stateless: a restart loses nothing structural. Sockets are caps; holders see EndpointDead/CapRevoked, reacquire net-stack by name, and re-bind - the §14.3 client-recovery pattern. The host IP is config, re-read on mount.
    • TCP is stateful: a net-stack restart drops live connections - the peer sees a reset, the holder reconnects. We deliberately do not build a "TCP journal" (the way fs got one for its metadata): TCP connection state is ephemeral by the protocol's own design (a reset + reconnect is a defined, normal event), so reconnection IS the recovery. This is §14.3 ("cascading failure is the client's responsibility") applied to the wire, not a robustness gap.
  • Loud failure (§26.7): a dead NIC or stack surfaces as EndpointDead/CapRevoked, never a silent black hole. A datagram that cannot be sent fails loudly to the caller.

10. Failure semantics

EventResult
send on a closed socketCapRevoked (generation mismatch)
recv on a dead net-stackEndpointDead
UDP datagram sentqueued/transmitted, not delivery-guaranteed (like §8.6 IPC - the app builds acks if it needs them; for UDP that is the protocol's contract)
frame too large (no fragmentation, early phases)error returned, loud (§26.7)
ARP resolution failssend fails loudly after a bounded retry; never an indefinite hang (§26.6 bounded)
net-stack / nic-driver deathendpoint generation bumped; holders reacquire by name (§14.3)

No silent fallbacks at any boundary. TCP's own ack/retransmit (Phase 5, next) is the protocol providing reliability on top of this best-effort substrate - not the kernel, not magic.


11. Phased plan (mirrors persistence: ahci -> gsfs -> file-cap)

PhaseDeliverableMilestone / testStatus
0PCI-enumerate + print the NIC (vendor:device, BARs, IRQ)We learn the T630's chipset from one boot line✅ done
1nic-driver (e1000): raw Ethernet TX/RX via DMA rings + RX IRQSend a raw frame; receive a raw frame (host-side listener / loopback) in QEMU✅ done
2ARP + IPv4 + ICMP in net-stackping the host end to end - the networking ping/pong✅ done + HW-proven
3UDP + socket-as-capability (resource_mint/badge/revoke); a net shell utilityA service opens a UDP socket cap, send/recv a datagram; non-escalation + revoke pinned (a §22 "socket is a capability" test, mirroring Test 14)✅ done (DHCP + DNS ride UDP)
4The Realtek RTL8168 driver (10ec:8168, the T630's NIC), same frame interface - HW-only, no QEMU modelping from bare metal on the T630✅ done + HW-proven
5TCP - committed next (§8, no longer far-future)A TCP echo against a real peerbuilt (net-stack/src/tcp.rs, docs/tcp-design.md)

Each phase gets a design beat in this doc plus QEMU + (where it applies) T630 verification, exactly like the AHCI/GSFS/file-cap ladder.


12. Constitution alignment

DecisionSection
Not in the kernel§4.4
No ambient network - socket caps only§3.1
Socket is a delegated resource cap (= file mechanism)§7.10, P2
NIC driver restartable (confinement NOT applied, so no TCB drop)§6.4 (H1)
Receive IRQ routed to the driver§12.2
Our own minimal stack, not a port§3.3, §3.10
Loud failure, bounded retries§26.6, §26.7
Client reconnect/reacquire on restart§14.3
Socket = stable identity, route = locationinvariant 11; on-ramp to Appendix C.4
Capability-security upside (no ambient exfil)Appendix D.4
Networking is v2§23.4

When a phase ships and an identity test pins it, the load-bearing decision is amended into CLAUDE.md (e.g. a "socket is a capability" line beside the file-cap one), the way persistence and naming were.


13. Non-goals (scope discipline, §26.2)

  • No POSIX/BSD sockets API. Capability-native (socket caps), not file descriptors. We are not source-compatible with anything; that is a feature (§B.3, D.6).
  • No full TCP/IP parity. Understandable over complete. No options soup, no congestion-control research, no TLS in the stack (TLS, if ever, is a service on top of a TCP socket cap).
  • No routing/NAT/firewall. A single host on one LAN segment with a static default gateway.
  • No networking in the kernel - ever (§4.4).
  • Pulled into existence by need, not by "a real OS has it" (§26.2) - which is why TCP waits for a real consumer and DHCP waits for a reason.

14. Open questions (for sign-off)

  1. The T630's NIC chipset - RESOLVED (2026-06-24): Realtek RTL8111/8168 (10ec:8168). QEMU has no RTL8168 model, so dev is e1000 and the Realtek driver is Phase 4 (HW-only). See §5 Phase 0.
  2. Static IP first (baked config), DHCP deferred - RESOLVED: DHCP is built (a UDP client of net-stack, HW-proven on the T630); static config remains the fallback.
  3. e1000 as the QEMU dev driver - agreed? Note this is purely the dev NIC: the T630 is Realtek, so e1000 does NOT run on it (vs virtio-net, which can't run on bare metal at all). The HW driver is the Phase-4 RTL8168.
  4. IPv4 only to start, IPv6 far-future - agreed?
  5. UDP + the cap model is the v2 milestone - RESOLVED: done + HW-proven. TCP was subsequently committed as the next phase (owner's decision, 2026-07-04, at the head of this doc), no longer far-future.
  6. One net-stack service owning IP + all protocols, vs splitting (e.g. a separate udp service)? Recommendation: one net-stack for now (whiteboard-simple); split only if a real reason appears (§26.2).

Exercised on the T630 bench (real RTL8111/8168, live LAN). Once the stack ran end to end on hardware - a real DHCP lease, gateway, and ping - the question stopped being "does it work" and became "does it survive the two failures a real NIC actually sees": the cable moving, and the services dying. Both are now chaos-covered, held to the same discipline block-driver/fs got.

Link flap - the cable is location, not identity (invariant 11). A NIC's carrier goes up and down independently of anything the OS does (someone unplugs the cable, the switch reboots). The stack treats a link transition as a first-class, observable event, never a silent error (§26.7):

  • Self-configure on link-up. When the driver observes carrier (the RTL8168 PHY reports link a few seconds after cable-in), net-stack runs its configuration dance automatically - ARP announce, then DHCP DISCOVER/REQUEST to a lease. No command, no reboot; plugging in the cable is the trigger.
  • ping rides an unplug/replug. A running ping does not die when the cable is pulled - it reports the link down and resumes when the cable returns and the lease re-acquires, with an instant q abort throughout (the utility never blocks un-abortably on a dead link - Commandment VIII, and the "[q] quit" utility convention).
  • net reflects the live link. net reads carrier + lease state at call time, so it never shows a stale "up" for a cable that is out.
  • net renew recovers in place. A link that came up mis-configured, or a lease that expired, is repaired by re-running the configuration dance inside the running net-stack - no service restart, no reboot. Recovery is a re-configuration, not a re-spawn.

Multi-target max-carnage. The chaos utility (a userspace program) storms the networking services exactly as it storms storage. chaos max-carnage nic-driver,net-stack N takes a comma-separated target list and kills every named service each round, for N rounds (rounds uncapped - a count is not a resource, §6.2/§26.6). Killing the NIC driver and the stack together, repeatedly, exercises the full recovery order: the supervisor respawns nic-driver first, then net-stack reacquires it by name (§14.3) and re-configures on the freshly-initialised link. The random-target variant picks its victims from the live set, so no single kill-ordering is special-cased. Result on hardware: the link comes back and ping resumes after every round, with no kernel panic - "not even a blip." This is the networking half of the same restartability story the storage stack tells (docs/persistence.md §6.16, docs/naming-design.md §8 risk #11): the system reconverges from any perturbation.

16. The clock is not the network's (2026-10-01)

Status: fixed in code; passed the x86 shell suite 215/0 and a Pi 4 QEMU boot. NOT yet run on Pi 4 hardware - the card carries the old net-stack. (Run on Pi 4 hardware 2026-10-02 at 5dd1f1b8, the boot backlog/fixed/66 records: ping over the radio at one echo a second, 19 of 19.)

The coupling. net-stack fetched the wall clock (SNTP: a DNS resolve, then up to three send-and-drain tries, up to about fifteen seconds when the server or resolver was silent) INSIDE its single serve loop, and started it three ways while the clock was unset: at the end of the dance that configures the network, on the time service's nudge every twenty seconds, and on ordinary client requests - status, DNS, ping and ARP each could start one. For the whole exchange the loop served nobody. Boot 2026-10-01 11:17-11:18 on the Pi 4 shows the cost: ping echo 2 was answered after 7092 ms, while that same echo's wire round trip was 39 ms. date sync appeared to cure it only because a success latched the clock and quietened the nudges.

The decoupling.

  • No client request starts clock work. The per-request retry is gone.
  • Configuring the network does not fetch the clock. The dance ends when the network is configured.
  • time pursues the clock, as it owns it: its nudge (op 11) starts a BACKGROUND query. net-stack sends it and returns to the loop; the poll step, which already drains frames every POLL_MS, records the answer; the loop hands it to time, or after a deadline resends or gives up - and says so.
  • The nudge is recorded where it used to be lost. It is one capless byte, [11], and it used to be dropped by the dance and taken for the driver's answer by every driver wait (orphaning the real reply). It is now recorded as owed by the sifted driver waits (nic_req, nic_req_ms, nic_drain_ms), the dance's serve pass and the capless arm, and the loop serves it between requests. A nudge that finds no network stays wanted, and is served on the first pass after the network is configured. Still NOT covered: nic_status_req's try_recv clear (used by link_is_up) discards capless messages, and the unsifted waits in dns_resolve (its ARP and its rx-only drain, which would parse the nudge as a frame batch) and the ping drain's ARP acknowledgement eat it. A nudge there is lost, and time re-sends it 20 s later.
  • date sync (op 10) stays synchronous, because the operator asked and is waiting - the only synchronous fetch apart from the fallback below. On an unleased, unconfigured stack it can run the whole dance first.
  • Where the poll cannot run - net-stack's cycle counter never calibrated - a nudge falls back to the old synchronous fetch, and the log says so: a background query there could never be answered.
  • A nudge that finds the cable in but the stack unconfigured runs the dance first, and the dance still blocks the loop (below).
  • The background query goes straight to the anycast fallback (162.159.200.123, time.cloudflare.com), with no DNS: a DNS lookup would block the loop. A network that blocks that address but allows pool.ntp.org gets a clock only from date sync, which resolves the pool first. And a reply that lands while another operation is draining frames is not recorded by the job; it is recovered only by the resend.

What this does not fix, recorded rather than smoothed over. Two things still hold the serve loop. The dance itself (DHCP and ARP) blocks it while the network is being configured - backlog/28 and backlog/fixed/29 describe the incremental dance that would fix that. And on the Pi 4 every exchange with nic-driver can cost a second: backlog/fixed/66, then read as a lost wake-up in the kernel's blocked-receiver path. Corrected 2026-10-08: no wake was ever shown lost. What later failed net dns over the radio was nic-driver answering a drain that found no frame with an empty message, which the kernel refused on every port but ARM32 (fixed in e3fcf7ed); the one-second STATUS tax itself had stopped appearing by 2026-10-04, unexplained (backlog/fixed/66). The decoupling should remove the multi-second stalls the clock caused - not yet run on hardware; it does not touch that one-second tax, and a slow ping after this change is that.

16.1 Later the same day: the clock leaves net-stack entirely

The decoupling above still left clock work inside net-stack: the SNTP query and its parsing, the nudge and the four places that had to catch it, a bounded wait on time for every result it pushed (up to two seconds, twice, with every client queued behind it), and date sync running the whole exchange in the loop. The operator's call: "I don't want it in the way of a ping. We have a time service right?"

What net-stack has now: no clock code, and one generic operation. Op 12, a UDP ask: [wait_secs, ip(4), port(2), datagram..]. The datagram goes out at once from a random source port, the asker's reply capability is held in a four-slot table (UdpAsk), and the loop goes on serving. The poll step, which reads every frame anyway, hands the first datagram back from that address and port to that source port straight to the asker - [ASK_OK, payload] - and a deadline (at most 10 s) answers [ASK_TIMEOUT]. A reconfigured network answers every open ask [ASK_ABANDONED]. It knows nothing about what is inside, and it needs no more authority than opening a socket. Op 10 is retired and says so; the nudge (op 11), SET_CLOCK and the send capability to time are gone.

What time has now: the whole NTP client. It builds the 48-byte query with a hardware-random nonce in the transmit timestamp, sends it with op 12 through its existing non-blocking send, and matches the answer in its loop by tag - exactly as it already matched fs replies. It checks what net-stack used to check (server mode, a synchronised server, a real stratum, and its own nonce echoed as the originate timestamp), and Clock::set_network judges plausibility as before. A query with no answer after 8 s is given up on, and a late answer to it is refused by its nonce. OP_SET is retired: it let any client set the clock, and the only caller was net-stack. OP_SYNC (5) is date sync: time sends a query at once and answers immediately, and the shell watches the sync age in OP_NOW drop - bounded at 10 s, q quits.

Why a late answer is safe here when it was not for the stash (docs/net-tags-design.md 7.2): the client can tell. A reply that arrives after time gave up and asked again carries the old nonce, and the new question's check refuses it.

What is lost, said plainly: date sync no longer resolves pool.ntp.org first - every query goes to the anycast address (162.159.200.123), because a DNS lookup would block net-stack's loop. A network that blocks that address and allows the pool gets no clock. And an NTP answer that lands while another operation is draining frames is not delivered by the poll step; time gives up after 8 s and asks again.

Cluster Mode Design Notes

Status: Non-normative, far-future. Records architectural reasoning and open questions for a future clustered GodspeedOS. Nothing here is a commitment. This document does not amend the constitution (CLAUDE.md).


1. Routing table generalization

Expands on the routing-table bullet in Appendix C.3 and the headline in C.4 of CLAUDE.md.

Current GodspeedOS routing resolves:

EndpointId → CoreId

In a clustered model, the routing table generalizes to:

EndpointId → (NodeId, CoreId)

This extends "location" from a core to a (node, core) pair while leaving everything else - EndpointId, generation, liveness, capability structure - unchanged. The invariant "identity is stable; location is not" already encodes this. SMP taught the system that identity and execution location are separate concepts. Cluster mode extends the definition of location; it does not change the philosophy.

The generation mechanism already handles service mobility across nodes correctly. When a service moves to a new node, its endpoint generation is bumped. Clients receive EndpointDead on their next send, look up the new endpoint via the name directory, and resume with a new cap that routes to (NodeId=2, CoreId=0) instead of (NodeId=0, CoreId=1). Client code does not change - the pattern is identical to the existing cross-core restart flow demonstrated by restart_changes_core_transparently (§22 Test 10 in CLAUDE.md).


2. Why the remote IPC API should be distinct

Expands on the API choice in Appendix C.4 of CLAUDE.md.

Two options exist for the developer-facing API:

Option A - Explicit remote semantics:

#![allow(unused)]
fn main() {
send_remote(endpoint_cap, msg, timeout) -> Result<(), RemoteIpcError>
}

Option B - Transparent routing:

#![allow(unused)]
fn main() {
send(endpoint_cap, msg)  // kernel resolves (NodeId, CoreId) internally
}

The existing constitution invariants settle this without needing to re-argue it. "Loud failures, never silent" and "bounded behavior" (§3) rule out Option B: transparent routing means send() silently paying network latency and returning errors with semantics the caller was not written to handle. That is a silent fallback, which the constitution forbids.

The deeper reason is what "Ok" means. A successful local send guarantees delivery to a queue on this machine. A successful remote send guarantees handoff to a transport. These are different contracts - not different performance profiles, but different durability, different failure domains, and different recovery obligations. Pretending they are the same primitive is the architectural mistake transparent-clustering systems have historically made.

Conclusion: local IPC keeps its current synchronous semantics. Cross-node IPC uses a distinct API surface with explicit timeout and failure handling. This is not a retreat from "identity over location" - it is an honest representation of a different failure domain using the same capability model.


3. Developer experience

An application that communicates cross-node is written explicitly cluster-aware - but only at the boundary it crosses. Everything else is unchanged.

Contract declaration:

[capabilities]
ipc_send        = ["pong"]     # local - same-node send, current semantics
ipc_send_remote = ["ledger"]   # explicit cross-node send, different failure domain

SDK call sites:

#![allow(unused)]
fn main() {
// Local service - as today (gs::ipc::send), unchanged by clustering
gs::ipc::send(&ctx, "pong", &msg)?;

// Remote service - developer explicitly opts into a different failure domain
let ledger = ctx.remote_send_cap("ledger")?;
ledger.send_remote(msg, Duration::from_millis(500))?;
}

RemoteSendCap is a distinct type from LocalSendCap. The compiler prevents calling send() on a remote endpoint. That is "explicit authority" applied to the network boundary at compile time - the network boundary is visible at three layers: contract review, code review, and compiler.

The name directory is the cluster-aware component, not the application. When the app calls ctx.remote_send_cap("ledger"), the SDK queries the name directory, which resolves "ledger" to (NodeId=2, CoreId=0, EndpointId=7, Generation=3). The application receives a cap and does not need to know which node.

Mobility. If ledger restarts on node 3, the client receives RemoteEndpointDead, queries the name directory, and gets a new cap. The reacquire pattern is identical to the local restart flow. No new error-handling primitives are required.

Local-only services are unaffected. An application that never declares ipc_send_remote compiles and runs identically whether or not the machine is part of a cluster. Cluster semantics do not leak in.

Same-node optimisation. If a service declared ipc_send_remote happens to be on the same node as the caller, the kernel can route it as a local send internally. The developer always uses send_remote with a timeout; the kernel optimises transparently. Correctness does not depend on the optimisation.


4. Remote IPC failure semantics

§8.6 of the spec defines failure semantics for local IPC as a table. Remote IPC requires the same treatment. The key variable is delivery state - developers writing recovery logic must know whether a message was definitively not delivered, definitively delivered, or unknown.

EventPhasesend_remote resultDelivery state
Remote node unreachableBefore transport handoffErr(RemoteNodeUnreachable)Definitively not delivered
Timeout expiresBefore transport handoffErr(RemoteTimeout)Definitively not delivered
Remote endpoint dead (generation mismatch)AnyErr(RemoteEndpointDead)Definitively not delivered
Remote queue full, timeout expires while waitingWaiting for queue spaceErr(RemoteTimeout)Definitively not delivered
Transport errorAfter handoff, before remote ackErr(RemoteTransportError)Unknown
Timeout expiresAfter handoff, before remote ackErr(RemoteTimeout)Unknown
Remote service crashes after receiptAfter deliveryOk(()) already returnedDelivered; processing unknown
SuccessTransport handoff completeOk(())Handed off to remote transport

The Unknown rows are unavoidable without an explicit acknowledgment protocol. Ok(()) from send_remote means the message was handed off to the transport layer - it does not mean the remote service received or processed it. Developers who require delivery confirmation must build an acknowledgment into their protocol (a response message back), exactly as §8.6 states for local IPC: "No delivery guarantee. A successful send means the message was queued, not processed." The same rule applies cross-node.

This table must be finalized before the send_remote API is implemented. Developers will write recovery logic based on which errors mean "safe to retry" vs "unknown state, do not retry blindly."


5. Flow control across nodes

Local IPC has 16-message bounded queues; a blocking send waits at capacity until a slot is available (§8.5). The remote equivalent needs a defined saturation policy.

Proposed v1 cluster semantics: the timeout parameter covers the full call, including any waiting for remote queue space. If the remote queue is full and the timeout expires before a slot opens, send_remote returns Err(RemoteTimeout) - delivery state is definitively not delivered (no handoff occurred). This is consistent with the failure table above and requires no new primitives.

What this does not address: sustained asymmetric load where a fast sender floods a slow remote receiver. Without a backpressure signal, applications in this scenario have two options:

  • (a) Use timeouts as backpressure. The send_remote call blocks until the timeout fires on a full queue, then the caller retries at a reduced rate. This works but is wasteful: each failed call burns a full timeout duration before the caller learns the queue is congested.
  • (b) Build application-level rate limiting. The caller throttles its own send rate before the queue fills, avoiding timeouts entirely. This works but pushes flow-control design onto every application that talks cross-node under load.

Neither is wrong. Both impose protocol design work that a dedicated RemoteBackpressure error - signaling "queue full, back off" immediately, without consuming the timeout budget - would eliminate. The deferral is recorded honestly here: absent RemoteBackpressure, applications requiring sustained high throughput must solve flow control themselves. That cost should be weighed when the API is finalized.


6. Message ordering

Local IPC is FIFO per endpoint, guaranteed by the queue discipline (§8.5).

Remote IPC ordering is transport-dependent and not guaranteed at the send_remote API level. Two consecutive send_remote calls to the same endpoint may arrive out of order if:

  • The transport does not guarantee ordering (UDP-based).
  • A reconnection occurs between the two sends (any stateful transport).

Implication for protocol design: any protocol built on send_remote that requires ordering must either (a) use a transport that guarantees it (TCP, QUIC), or (b) include sequence numbers in the message payload and reorder at the receiver. The kernel does not provide sequence numbering on the remote path.

This should be stated explicitly in the API documentation for send_remote so developers do not assume FIFO and write ordering-dependent protocols that fail silently under reconnection.


7. The naming problem

The phrase "the registry consistency model" in Appendix C.4 of the current spec (the registry service is retired; read it as the name directory) is doing enormous work in one sentence. It covers at minimum:

  1. Distributed name resolution. How does node A learn about endpoints on node B? Push (nodes announce services on join), pull (query a central directory), or gossip? Each has failure modes.
  2. Node membership tracking. What nodes are in the cluster? Who decides? How is the membership list kept consistent across nodes?
  3. Handling unreachable nodes. If the node hosting "ledger" is unreachable, what does the name directory return? Stale data? An error? How long before a node is declared dead vs partitioned?
  4. Consistency vs availability. If the name directory is the cluster-aware component, it is also the distributed-systems component. A partitioned name directory either returns stale data (availability) or blocks (consistency). Neither is free.
  5. Possible consensus requirement. If multiple nodes can register services, the name directory may need distributed consensus (Raft or equivalent) to prevent split-brain registration of the same service name on two nodes simultaneously.

Making the name directory cluster-aware is likely the largest single piece of work in cluster mode - comparable in effort to most of v1. It is not an incremental extension of the existing name-lookup table. It is a new distributed system component. Future design work should scope this explicitly rather than treating it as a one-sentence prerequisite.


8. Transport layer

The remote IPC path requires a transport layer. The choice is deferred, but the candidates are not equivalent and the decision should be made before any cluster implementation begins - it directly determines the ordering guarantees (§6) and shapes the failure semantics table (§4).

Option 1 - TCP.

  • Pro: simple; FIFO guaranteed per connection; well-understood failure modes; lowest implementation complexity.
  • Pro: the right v1 cluster starting point - solves the problem without introducing new unknowns.
  • Con: connection-oriented setup cost; head-of-line blocking on packet loss; reconnect latency on node failures can be hundreds of milliseconds.

Option 2 - QUIC.

  • Pro: multiplexed streams with low reconnect latency; better suited to node churn (restarts, mobile nodes).
  • Pro: likely the right long-term choice if connection setup or reconnect cost proves significant in practice.
  • Con: more complex to implement; per-stream FIFO only - stream selection becomes a protocol concern if multiple endpoints share a connection.

Option 3 - Custom UDP + retries.

  • Pro: minimal overhead; full control over retry and ordering policy.
  • Con: most implementation work; effectively reinventing TCP or QUIC without decades of hardening. Adds complexity without proportional benefit given the other unsolved problems in this document.

Tentative lean: TCP as the starting point - simplest, FIFO guaranteed, failure modes well-understood. Migrate to QUIC if reconnect cost proves material. Custom UDP has no compelling advantage and should not be the first choice.

8.1 Cluster membership and certificate trust

All transport candidates should require mutual TLS (mTLS) to authenticate both endpoints before any message is exchanged. Without mTLS, a node joining the cluster can forge capability sends - the unforgeability guarantee of the capability system holds within a machine but must be enforced at the transport layer when the boundary crosses a network.

However, mTLS solves authentication between two endpoints, not cluster membership. These are different problems:

  1. Certificate issuance. Who is the Certificate Authority for the cluster? A central CA is a single point of failure and administrative burden. Per-node self-signed certs require pre-sharing trust anchors at join time. Neither is free.
  2. Certificate revocation. When a node is compromised or decommissioned, its certificate must be revoked and that revocation must propagate to all peers. Standard revocation (OCSP, CRL) has latency; during the propagation window a revoked cert remains valid.
  3. Former members with valid certs. A node that has been removed from the cluster may still hold certificates that pass mTLS validation until they expire. mTLS alone does not prevent a decommissioned node from re-authenticating - cluster membership must be checked at the application layer as well.

These three problems interact directly with §7 (the name directory controls which nodes are legitimate sources of name registrations) and §9 (TCB authority defines which nodes can issue restarts). Certificate trust, name authority, and restart authority are the same trust system viewed from three angles. They cannot be designed in isolation from each other.


9. TCB authority across nodes

Expands on the TCB authority question in Appendix C.4 of CLAUDE.md.

The current spec raises "cross-node TCB definition" as an open question. It deserves more weight: this is the central security question for cluster mode, and cluster mode cannot ship without a resolved answer.

The question: does the supervisor on node A have authority over services running on node B?

Option 1 - Federated supervisors (no cross-node authority). Each node has its own supervisor with authority only over local services. Cross-node restarts require a protocol between supervisors - node A's supervisor requests that node B's supervisor restart a service. Neither supervisor holds a capability to directly kill or spawn on the other node.

  • Pro: a compromised node A supervisor cannot directly harm node B.
  • Con: coordinated restarts require a distributed protocol between supervisors. Who initiates? Who has final authority? This needs its own design.

Option 2 - Hierarchical supervisors (one cluster supervisor governs all). A cluster-level supervisor holds authority over all node-level supervisors. Restart decisions flow from the cluster supervisor downward.

  • Pro: single point of control; simpler restart coordination.
  • Con: a compromised cluster supervisor compromises the entire cluster. This expands the TCB by one cross-node component and introduces a new single point of failure.

Option 3 - Capability delegation (services hold cross-node restart caps). The supervisor on each node holds a REVOKE-style cap that can be delegated cross-node. Cross-node restart authority is granted explicitly, not assumed.

  • Pro: consistent with the capability model; authority is explicit.
  • Con: cap delegation across nodes requires cryptographic authentication of the cap itself (see §8 on transport). The generation mechanism needs a cross-node equivalent.

No option is obviously correct. The choice shapes the entire cluster security model and cannot be resolved without a dedicated threat-model exercise. Any cluster design document that does not address this question explicitly is incomplete.


10. Summary of open questions

QuestionWhere addressedBlocking?
Transport protocol choice§8Yes - shapes failure semantics and ordering
Cluster membership and cert trust model§8.1Yes - mTLS alone is insufficient; membership check needed
Name-directory consistency model§7Yes - defines what remote_send_cap("name") can guarantee
TCB authority model across nodes§9Yes - cannot ship without this resolved
Flow control / backpressure primitive§5No - deferral forces app-level rate limiting; tradeoff stated
Ordering guarantee level§6No - "transport-dependent" with documentation is acceptable
Delivery acknowledgment semantics§4No - "Unknown" is a valid stated answer

The four blocking questions (transport, membership and cert trust, naming, TCB authority) are interdependent: the cert trust model in §8.1 connects all three. They cannot be resolved independently and should be addressed together in a dedicated threat-model exercise before any cluster implementation begins.

The System, Running

Every image on this page is a capture of the real GodspeedOS, booted in QEMU with an emulated framebuffer and photographed straight from the guest's video memory. website/capture/fb_shot.py grabs a single boot frame; website/capture/fb_capture.py drives the shell over the COM1 serial line (where the shell reads input) and screendumps the framebuffer at a chosen state. Nothing here is a mockup.

Boot to steady state

The kernel comes up on all cores, spawns the supervisor directly, and the supervisor wires each service from its name-cap map. Here the USB stack has just enumerated a keyboard end to end, and the shell is ready. Boot output reaches the display through the kernel's own minimal framebuffer blit, which hands the screen to the console service when the kernel grants it the framebuffer at spawn; the serial line keeps the full log either way.

GodspeedOS booting to steady state

The shell, and exactly what it may do

gsh is not a Unix shell - it is a capability-broker service. It holds a console capability and a handful of others, and it can do precisely what those grant, nothing more. Here it answers date, cores, and uptime, and then caps prints its own capability table: the resource each cap targets and the rights on it (log_write, spawn, console_read, introspect, service_control, and its IPC endpoints). Authority is never ambient or inherited - it is this list, and only this list.

The gsh shell listing its own capabilities

Live introspection: observe

observe is a live, full-screen view of every service the system is running - scheduler slot, name, core, state, memory against its contract limit, restart count, IPC queue depth, CPU share, and uptime. It reads structured per-service state the kernel and supervisor already track; there is no /proc text to parse. This capture is from July 2026, when the system ran eight services, all healthy here; it runs more than twice as many now.

The observe live view

Persistent storage: drives

State that must survive a reboot lives on disk, reached only through the fs service, which reaches the disk only through the block-driver, which alone holds the hardware capability. drives lists the mounted volumes: one GSFS filesystem, 16 MiB, with its free space. Every byte crossed an IPC boundary and a capability check to get there - there is no ambient file access anywhere in the system.

The drives view showing a mounted GSFS volume

Editing a file: edit

edit is a full-screen, modeless text editor - a title bar with the filename and a dirty mark, the text area, and a status bar showing the two keys you need (Ctrl-S save, Ctrl-Q quit), the column, the byte count, and the edit-buffer fill (buf 106/32768). It opens a file of any size without loading it whole: the original stays on disk, read in fixed windows as you scroll, while edits accumulate in a bounded add-buffer and a save streams the pieces back out. Bounded, no heap, loud when the buffer fills - never a silent truncation.

An edit session, a note open in the full-screen editor

Scripting: a .gsh script via run

Commands compose into scripts. Here a small script is authored in one line (statements separated by ;), saved as /demo.gsh, and executed with run: the runner echoes each statement, shows its output, and prints a pass/fail summary - run: ran 4, failed 0. This is the same mechanism the shell's own suites use - a .gsh file, run and checked. And like everything else, a script is capability-scoped: run can do only what the shell already holds.

A gsh script authored and run via run, ending in a pass summary

Maximum carnage: chaos max-carnage

This is the fire (Commandment II). chaos max-carnage all-services storms every live service at once - kill-storms, queue floods, system-wide memory pressure, and spawn-storms - for exactly the number of rounds you type (chaos max-carnage all-services 100), or until you press q. Each round picks a random subset of the live services, the shell and the supervisor included, floods each one it can and kills every one it picked. The table counts the punishment each service has taken. This capture is from July 2026, when every round still tore down fs and shell: by round 28 they had been killed 28 times each, the supervisor itself 5 times, the drivers flooded 28 times.

And the line that is the whole point:

kernel: ALIVE

Everything above the kernel dies and is respawned - even the supervisor, which the kernel itself brings back. Only the kernel is unkillable (Commandments II and V). The abort is deliberately a serial-console q, because the storm will kill the keyboard driver too.

chaos max-carnage mid-storm, kernel still alive

Contributing to GodspeedOS

Thank you for your interest in GodspeedOS. This is a deliberately small, fully-understood capability microkernel; the goal is a system one engineer can hold in their head. That goal shapes how contributions are judged, so a few minutes of reading up front will save you a rejected pull request.

Read these first

GodspeedOS is governed by a written constitution, and contributions are held to it:

  • COMMANDMENTS.md - the Ten Commandments of Godspeed, the human-readable distillation. Start here; internalize the ten and you will rarely break a rule by accident.
  • CLAUDE.md - the full constitution: the invariants, the capability model, IPC, the scheduler, the memory model, the unsafe policy, and the contribution rules (section 21). When the constitution and the code disagree, the constitution wins.
  • examples/ - the pattern primer. Every example carries its own CLAUDE.md explaining why it is built the way it is, grounded in a Commandment. Copy examples/00-hello to start a service; the others teach IPC, capabilities, composition, persistence, and drivers.
  • GLOSSARY.md - the abbreviations (TLB, DMA, IOMMU, AHCI, and the rest).

Where do I start?

Most contributions fall into a handful of shapes. Find yours and begin at the file named - each is a short, local doc that points onward. Every directory carries its own CLAUDE.md explaining what lives there and why, so when in doubt, open the one nearest the code you are editing.

I want to ...Start here
Understand the philosophy before touching anythingCOMMANDMENTS.md, then skim CLAUDE.md
Write a new userspace serviceGETTING_STARTED.md - the quickstart (copy examples/00-hello/, read its CLAUDE.md + the standard library guide; sdk/rust/CLAUDE.md is the layer underneath)
Write a service, utility, app or driver outside this repository, and install it on a machinegodspeed-cli - godspeed new, package and publish; the machine's side is utilities/61_godspeed.md
Learn one pattern (IPC, caps, composition, persistence, drivers)The matching examples/*/CLAUDE.md (index: examples/README.md)
Add or change a syscallkernel/src/syscall/CLAUDE.md, then syscall/mod.rs
Add a new CPU architecturekernel/src/arch/CLAUDE.md (the seam + the five-place checklist)
Change capability or generation logickernel/src/capability/CLAUDE.md
Touch IPC or routingkernel/src/ipc/CLAUDE.md (and bring a benchmark - the fast path may not change without one)
Add or change a testtests/CLAUDE.md, then the category under tests/qemu/
Know the full law and the instant-reject listCLAUDE.md (the constitution; section 21 is the reject list)
Check my change against the anti-patterns before I open a PRdocs/anti-patterns.md - the field guide to constitutional violations, each paired with the correct pattern
Find something to work on, or check a bug is not already knownbacklog/ - open items, each with its evidence, what is RULED OUT, and the next step

Learn from a real change

Every kind of contribution below has already been made here, by the people who wrote the rules, and passed the same gates yours will. So do not start from a description: start from the real thing. Each row names code you can read and run in the tree today, and a merged commit that made exactly that kind of change - its message says what was found, what was done and how it was verified, and its diff shows every file that kind of change has to touch. Nothing here is a toy written for the purpose.

I want to ...Read and run thisThen read this commitWhat to copy from it
Write a serviceexamples/counter (restart with state), then services/power - a whole real service on gs7dfe3fbd - every service moved onto gsUse gs and nothing under it: scripts/one_way_check.py holds every crate at zero raw SDK calls that gs covers
Write a driver that streams (a DMA ring kept fed)services/pwm-audio - the Pis' jack13be2d55, then 3ac042c2 - the Pi 4's one underrun per toneMeasure before fixing: a one-line instrument first, the fix second, each with a prediction, each tested on the board
Support new hardware in a driverservices/audio-driver - Intel HD Audio, its PLAYABLE table of verified codecs3787e6dc (a new codec, and the mixer its path needs), 533003cd (the vendor's bring-up, as Linux does it)Copy what the reference driver writes, never a value built from bit names (kernel/src/arch/CLAUDE.md); read every register back and log it
Write a driver from scratchexamples/driver-skeleton and examples/e1000-The grant, the interrupt, the re-arm, the serve loop, restart from cold
Change the kernelkernel/src/task/mod.rs pci_dev, kernel/src/arch/x86_64/pci.rs bar_len7956c174 (K1, a driver's window is its BAR), 39746569 (K2, the device the supervisor names)New unsafe only in arch/, recorded in audits/unsafe-audit.md; every other port answers the new seam member; QEMU first, then a board
Add or change a shell utilityutilities/57_audio.md (the spec) and utilities/0_conventions.md0c31c4d4 - Ctrl+Alt+Up, Down and MSpec first, then the shell, its help, its test and the docs in one change
Add a test scenarioosdev/src/shell_test.rs boot_audio, run by osdev test audio39746569 - a decoy controller placed FIRST on the bus, so the test fails if the wrong device is grantedBuild the situation that would expose the bug, not the one that passes
Fix documentationaudits/documentation-audit.mdfc95b1d7 (an audit of one area), 72188226 (a sweep of code comments)Read each claim against the code and the hardware; a dated record stays as history and gets a dated note
Improve a gate (scripts/)scripts/docs_index_check.py, scripts/commandments_redteam.pyae1b6fc4 (two new checkers, from an audit), 576e7e85 (a rule found false, the gate and the docs corrected together)A new check comes with a probe that breaks it on purpose; a gate may only get stronger (below)

Chaos finds

chaos max-carnage all-services <rounds> yes kills services at random, round after round, under memory and spawn pressure, and the system must recover every time. Run it on your board and read the log, not only the summary line - most of what it finds is in the counters. Four real bugs it found, each fixed and each commit saying how it was caught:

CommitWhat chaos exposed
c51245c6A fresh xhci on the T630 stopped completing commands. Looking for why found that every IOMMU-confined device shared one domain ID, which the hardware caches translations by; the commit says plainly that this was found, not proven to be the whole cause
bdc7adaaOn the T630 the idle path kept restarting a timer countdown, so a core could go without a tick until the liveness watchdog panicked, 6 s after a 1000-round run
6737ae52On the Pi 2 the kernel's page-table arena ran out with RAM free: after a 1000-round run every new program was refused
b2a54655The supervisor was refused a reply mailbox at every one of its 13 respawns in a chaos run: the kernel gave a dead task's mailbox back only to the services the supervisor restarts, never to the supervisor itself

Building and testing

See the README "Getting started". It is the same cargo run -p osdev -- ... flow on Linux, macOS, and Windows (there is no Makefile - the osdev CLI handles the platform differences), plus the one-time Limine setup.

The bar: tried by fire

A contribution is not considered proven until it has passed through the fire - the seven trials of the test suite (section 22): Identity, Property, Fuzz, Stress, Performance, Adversarial, and Chaos, each with a harsher "brutal" variant (see the "Tried by Fire" section of COMMANDMENTS.md). A green unit test is necessary, never sufficient. In particular, every service must survive chaos max-carnage (Commandment II): if Chaos finds a bug, the bug already existed.

Break it: an open invitation

GodspeedOS claims that the kernel is the only thing that cannot be restarted - every service, the supervisor included, dies and comes back. That is a claim about RECOVERY, and the honest way to hold it is to let anyone try to make it false. So: if you have a machine with cores and hours to spare, run chaos against GodspeedOS for as long as you like - a million rounds, a billion - and if it breaks, tell us. A break found by a stranger is worth more than a thousand rounds we ran ourselves, because it is a kill order we did not think of.

This is about recovery, not security. chaos holds the authority to kill services, so whatever it breaks - a kernel panic included - is a recovery bug and belongs here, in public. A panic that an unprivileged service or a network peer can cause, a capability bypass, a way to gain authority you were not granted, or anything else that is an attack rather than a failure to recover, is not reported here - do not post it in a public issue; report it privately as SECURITY.md describes.

What counts as a break

  • A kernel panic, or a liveness wedge (a core goes dark and the watchdog fires) - the one thing that must never happen.
  • A service that never comes back - dead at the end of the run, and still dead after the supervisor's sweep has had time to find it.
  • A recovery that comes back wrong - selfcheck fails after the soak, a file reads back different from what was written, the network answers wrongly after it has answered rightly.
  • A resource that keeps going - free memory falling run over run, restarts that start failing and keep failing. Recovery that leaks is recovery on a timer.

What does not count: a fault in the hardware itself, an image you modified, and recovery that is slow but bounded - a service that takes several seconds to return after a storm is doing its job.

How to run it

On real hardware, at the prompt:

gsh> chaos max-carnage all-services 1000000 yes

In QEMU, from a checkout, one boot running the storm repeatedly: osdev test chaos-repro:<rounds>:<iterations> (the serial lands in build/tests/chaos_repro_serial.log).

A powerful machine buys parallel runs, not a faster one. A round is a sweep that kills, floods and waits for recovery, and it takes about two seconds - a Raspberry Pi 4 ran 547 rounds in 21 minutes on 2026-10-09 - so one run of a billion rounds is not a weekend; it is decades. What a machine with many cores CAN do is run dozens of instances at once, each soaking independently, and that is genuinely valuable. Use a separate checkout for each QEMU instance: the test harness writes fixed paths under build/, so two runs in one checkout overwrite each other. QEMU's timing is not a board's - a break under QEMU still counts, and is worth saying it was QEMU.

What to send

Every all-services run prints its seed when it starts and again in its report. A report we can act on has:

  • the seed and the round it broke at;
  • the commit, from the boot banner (GodspeedOS <version> <arch> (<commit>) - kernel);
  • the machine - the board, or the QEMU command line and -smp;
  • the serial log, the whole of it. It matters more than anything else on this list: events log boot holds only the boot, and the supervisor's hardware events record restarts whenever chaos kills the supervisor.

What a seed does and does not do. chaos max-carnage all-services <n> seed <s> replays a run's random DRAWS, not the run: one draw is made per live service each round, and which services are live depends on restart timing across the cores, so two runs on one seed part ways at the first round whose timing differs. Rerunning your seed and seeing no failure does not mean the bug is gone; send it anyway. The seed narrows a break from "somewhere in a billion rounds" to one run's decision stream, which is what the person fixing it needs.

A break you find is credited the way every contribution is: in git history. The commit that fixes it carries a Reported-by: trailer with your name, so the record of who found it is permanent and sits next to the fix.

Interdependent services wait on truth, not time

When one service depends on another - fs on block-driver, the shell on fs, any client on any server - the dependent blocks on its dependency's reply, never on a fixed amount of time. This is Commandment VIII made concrete: wait on truth (the reply, or the loud fact of the peer's death), never on a timer, a yield count, or a tick.

The standard pattern is the standard library's gs::call::request_within(&ctx, peer, &msg, secs) (stdlib/rust/src/call.rs): it sends the request carrying a one-shot reply cap and blocks for the reply, bounded by secs. It waits on truth without ever hanging - it is a synchronous kernel CALL (syscall 41) in its bounded form, CallDeadline (syscall 50, CLAUDE.md section 8.2), so if the peer dies after receiving the request but before replying, the kernel wakes the caller with ReplyDead (the reply-side twin of EndpointDead, CLAUDE.md section 8.6) instead of blocking it forever. What the caller gets back says which failure it was, and they are not handled alike:

  • Error::Unreachable - the request never left (a stale cap, a peer mid-restart). Nothing happened. request_within has already reacquired the peer by name through the kernel directory (section 14.3) and sent once more for you.
  • Error::PeerDied - the request arrived and the peer died before answering. It may have happened. Reacquire (gs::cap::reacquire), and ask again only if the operation is safe to repeat.
  • Error::OutcomeUnknown - no reply before the deadline. It may have happened, exactly as above.

That is the whole discipline: block on the reply, and on failure reacquire by name - re-sending only what retry_is_safe() says, or what you know is safe to repeat.

Do not paper over a dependency that might be slow or restarting with yield a fixed number of times, a sleep, or a tick-count deadline "to give it time to come up". That is waiting on time, and it is always wrong here: too short and you give up on a peer that was about to answer; too long and you have hung the system on a peer that already died. The cautionary tale is fs <-> block-driver: fs issues every block read/write as a synchronous request and blocks for the reply. Before the reply-side death-wake, a block-driver that died mid-request left fs blocked on a reply that would never arrive - a hang that a timer would only have converted into a guess. The fix was to wait on the truth of the peer's liveness (the generation/liveness the kernel already tracks), so fs wakes the instant block-driver dies, reacquires it by name, and retries. Follow that shape; if you find yourself reaching for a sleep to coordinate two services, you are solving the wrong problem.

What gets a pull request rejected (CLAUDE.md section 21)

A pull request is rejected without further review if it:

  • Introduces ambient authority, or bypasses the capability / generation check.
  • Introduces global mutable state outside a single owning service, or a silent fallback at the kernel boundary.
  • Adds service migration, work stealing, zero-copy IPC, or live code update (all permanently rejected).
  • Breaks the restartability of a non-TCB service, or adds a syscall that does not validate a capability.
  • Adds unsafe without a // SAFETY: comment, or outside the permitted layers (section 18).
  • Changes the IPC fast path without a benchmark, or edits CLAUDE.md without a rationale in the commit.
  • Uses an em-dash or en-dash anywhere - only the plain hyphen is permitted (a house writing convention, enforced repo-wide).
  • Weakens a gate instead of the rule it enforces - un-wires a checker, widens an exemption, baselines a finding rather than fixing it, or makes a diagnostic vaguer. See the next section.

See section 21 for the full list. Reviewers ask: does this respect the constitution, leave the kernel small, present a convincing unsafe argument, include a test, and make the system more understandable?

Contribute anywhere; a gate may only get stronger

Every part of this project is open to you, scripts/ included. The enforcement layer is written in Python rather than Rust, and that makes it neither second-class nor off limits: it is the code that decides whether the next change is allowed, so improving it is among the most useful things you can do here. Close a blind spot, make a diagnostic clearer, mechanise a rule the constitution states and nothing checks, retire a baseline entry that is no longer needed. osdev conform --list names every rule and its code, and py scripts/conform.py --explain GS0403 gives the long form for one.

What you may not do is make a gate weaker. That is the whole of it, and it is an asymmetry rather than a prohibition: a rule may be improved, argued with, or repealed. It may not be quietly taught to stop noticing. Each of these is a weakening, however reasonable it looks in a diff:

  • Deleting a checker, or removing one from EXTRA_CHECKS in osdev/src/main.rs. A checker on one build path is a checker on none - eight documentation checks ran only at release once, which meant they ran too late to help anybody.
  • Widening a path exemption so a rule stops looking at the code it was written for.
  • Adding a baseline entry instead of fixing what it found. Baselines ratchet one way: a count may fall freely and may not rise without a recorded reason. Putting a finding in a baseline to get a green run is the one thing a ratchet must never be used for.
  • Using a conform-ok marker to silence a rule rather than to record a genuine exception. A marker must name its rule and carry a real reason, and every honoured suppression is counted and printed - they are built to be visible, so do not treat one as a way to go quiet.
  • Making a diagnostic vaguer. CLAUDE.md 22.7 puts it in terms: a gate that fires with an unhelpful message is a finding, not a pass. That is why tests/conformance/ui/ holds the exact text of every diagnostic and py scripts/conform.py --selftest compares the render against it. If you change a message, run it and read the diff.

Adding a user-facing command? utilities/0_conventions.md §2a lists the eight places a new verb has to be registered and the rule that decides whether its spec belongs in utilities/ or in docs/ - both are enforced by checkers, and both were written down only after a verb was implemented without them.

One question settles a hard case: after your change, does the gate still refuse what it was written to refuse? A green run is not the answer to that - a check that has stopped working is also green, and this project has caught its own instruments agreeing with it more than once. scripts/commandments_redteam.py exists to answer it properly: it breaks each rule on purpose, so a dead check is caught rather than mistaken for a clean tree. Run it deliberately and on a committed tree, because it plants violations in real files and restores them with git checkout.

And if a rule is genuinely wrong, the door is open - it is a different door. The rules are written down in CLAUDE.md and COMMANDMENTS.md precisely so they can be changed on the record: amend the text with a written rationale, and the checker follows it. That is how most rules here reached their present form, and several have been narrowed or corrected outright when the machine disagreed with the document. What the project will not take is the other version - the law still saying one thing, the gate no longer noticing, and nobody finding out until someone trusts the document.

Before you open a pull request, run osdev conform --check. It runs every gate a build runs and gives you one verdict instead of one per gate. It needs Python on your PATH as python, which is a declared dependency of this project alongside Rust and QEMU; the oldest version it runs on is FLOOR in scripts/python_floor_check.py.

A note on scope

Features are pulled into existence by a real need - an invariant, an identity test, a demonstrated operational problem - never added speculatively because another system has them (section 26.2). The default answer to "should we add this?" is to simplify, reduce scope, and preserve the invariants. A smaller coherent system is preferred over a larger impressive one.

Adding an architecture

GodspeedOS is one arch-neutral codebase behind a single seam, crate::arch::imp. A new instruction set architecture is bounded to kernel/src/arch/<isa>/ - you write that directory, and register it in the few files docs/porting.md marks + (a module line, a linker script, a target block, the SDK's syscall body and the board facts above the kernel). scripts/port_scope_check.py fails a port that edits anything else. Five ISA families (x86-64, AArch64, RISC-V, LoongArch, s390x) and both word sizes have been proven this way; the proof is docs/multi-arch.md.

If you are porting, read kernel/src/arch/CLAUDE.md - it is the map: the seam, the surface a port must expose, the exact five-place checklist, and the per-arch bring-up gotchas found by actually booting. Two rules matter most, and both are load-bearing:

  • No inline asm! and no named-arch reference (arch::x86_64::, core::arch::<isa>::) outside arch/. This is enforced by scripts/arch_boundary_check.py on every osdev build - a violation means a neutral file made an arch-specific assumption, and the fix is to add an arch::imp primitive, never to special-case an arch at the call site.
  • Never use core::sync::atomic::AtomicU64 directly - import portable_atomic::AtomicU64. That one dependency is the entire cost of 32-bit support (32-bit RISC-V has no 64-bit atomic); reaching for the core type is the one easy way to silently break word-size portability.

cargo check -p kernel --target <triple> is the boundary test: any error outside arch/<isa>/ is a leak; errors inside it are just your stub naming the surface you still owe.

Credit and attribution

Contributors are credited through git history - every commit carries its author, permanently and accurately. Please do not add personal names to source files or program output. GodspeedOS shows a single collective notice everywhere - Copyright (C) 2026 Bankole Ogundero and the GodspeedOS contributors - and the phrase "and the GodspeedOS contributors" already includes you. Under the project's licensing (the Linux model: no copyright assignment) you keep the copyright to what you write; the collective notice is the project's shared face, not a transfer of your ownership.

The year is the creation year, hardcoded on purpose - never read from the clock. A copyright year denotes when the work was authored (2026), a fixed fact; it is not the current year. The RTC is available (the date command reads it), so wiring the notice to it would be easy - and wrong: a machine booted in 2030 would then print "Copyright (C) 2030," which is false and changes with the viewer's clock. Leave it a literal. When a later year sees substantial development, bump it to a range - Copyright (C) 2026-2027 ... - as a deliberate edit (the end year is the last year of real change, a build-time authorship fact, still not a clock read). Because the notice is one shared string, change every copy together: about and version output (services/shell/src/main.rs), NOTICE, LICENSE / sdk/LICENSE, and the canonical credit line in utilities/0_conventions.md (rule 5/6); a shell test pins the exact string, so a partial bump fails loudly.

License

By contributing, you agree your contributions are licensed under the project's terms: the OS is GPL-2.0-only (root LICENSE); the SDK (sdk/rust), the standard library (stdlib/rust) and the examples are Apache-2.0 (sdk/LICENSE); the protocol crates sdk/wifi, sdk/tls and sdk/audio are GPL-2.0-only. Each file's SPDX-License-Identifier header is what decides, and a new file carries the one its directory uses; docs/licensing.md has the reasoning.

Welcome aboard. Build something that survives the fire.