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
- Purpose of This Document
- Project Identity
- Constitution: Non-Negotiable Invariants
- Architecture Overview
- Repository Structure
- Trusted Computing Base
- Capability System
- IPC
- Scheduler and SMP
- Memory Model
- Bootstrap Sequence
- Drivers and Interrupts
- Service Contracts
- Service Lifecycle
- State and Persistence
- Update Model
- Developer Workflow
- Unsafe Policy
- Debugging Model
- Performance Philosophy
- Contribution Rules
- Identity Test Suite
- First Milestone
- Glossary
- Final Principles
- Architectural Discipline
Appendices (forward-looking, mostly non-normative):
- Appendix A: Bootloader Choice (Limine)
- Appendix B: Userspace Posture
- Appendix C: Forward-Looking Vision (Non-Normative)
- Appendix D: Shell, Scripting, and Utility Ecosystem (Non-Normative, Far Future)
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
- Clarity over novelty. Every part of the system should be explainable in a paragraph.
- Correctness over cleverness. A boring, verifiable design beats a clever, fragile one.
- 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
| Capability | Status | Rationale |
|---|---|---|
| SMP | Supported (static) | Cross-core parallelism; no migration |
| Zero-copy IPC | Rejected, permanent | Violates "no shared mutable memory" |
| Live code updates | Rejected, permanent | Restart-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.md14). 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'sgs::callreportsOutcomeUnknown, 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
| System | What we borrow |
|---|---|
| seL4 | Capability model, synchronous IPC discipline |
| Hubris | Declarative task contracts, supervisor-driven restart |
| Fuchsia | Userspace drivers, capability handles in IPC messages |
| Genode | Composition through capability delegation |
| Redox | Rust 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.
- 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.
- No shared mutable memory by default. Services have isolated address spaces.
- All authority is explicit. Capabilities, not identity.
- Kernel remains tiny. Memory, scheduling, IPC, capabilities, interrupts, cross-core routing. Nothing else.
- Unsafe is isolated and audited. Permitted only in
arch/,memory/,capability/,smp/. - Services must be restartable (with stated TCB exceptions).
- Contracts are enforced, not interpreted.
- State must be explicit and owned. No anonymous singletons.
- No unowned global mutable state. Immutable globals are fine.
- System must remain understandable. 30-minute whiteboard rule.
- Identity is stable; location is not. Services have stable names; their core assignment may change across restarts.
- 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_64in 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 thearch::impseam and must never name an ISA - enforced mechanically byscripts/arch_boundary_check.py(neutral layers reach hardware only through the seam) andscripts/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 inmemory/allocator.rsandtask/scheduler.rscite 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 namesarmin 8 places,aarch64in 4 andx86_64in 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'shwclasslist - 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 figuremilestones/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-repeatedany(x86_64, aarch64, riscv64)was one question - is configuration space reachable - and is now onebuild.rsfact. 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
hwclasslist 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
armin 8 places,aarch64in 4 andx86_64in 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.pyforbids inline assembly and named arch modules outsidearch/, and a#[cfg(target_arch = "arm")]inkernel/src/task/mod.rsis 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 inservices/supervisor/build.rsandservices/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.mdhas 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 aretarget_pointer_widthon 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-driverpicks its MAC by ISA on three of four boards and needs a kernel query to stop (backlog/21),hw-enumeratorpacks a host-bridge config selector that belongs inarch/(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.pyparses 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
ResourceIdand its owning endpoint and nothing more; the owning service (fs) alone mapsResourceId → 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/ehcion x86,dwc2on 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
| Component | Trusted because |
|---|---|
| Kernel | Enforces 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/smp | Concurrent-correctness primitives |
supervisor | Holds 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-driverandfsare no longer TCB members. They were a v1 simplification (§6.3):fsowned persistent state it could not recover after a crash, so its death - and that of theblock-driverit depends on - was a panic+reboot. The filesystem robustness program closes that gap:fsnow 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;fsre-mounts (recovering via the journal) and re-registers,block-driverre-initialises the controller, and clients reacquire via the registry and retry (§14.3, §6.2).block-driverholds 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 (fssurvives its own restart). The remaining non-restartable set is now justinit+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-driverandfsfrom 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 offsalone: 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.
ahciissuesFLUSH CACHE EXTwhenfsasks 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 barriersfsalready 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,fsrecovers 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 CACHEoutright, 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-kernelarch/arm/dwc2.rs; that file was DELETED by the 2026-08-17 amendment below and the driver isservices/dwc2now - 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, andfssays 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:
fsis restartable everywhere; it is crash-recoverable on a backend that can be ordered. Where it cannot be,fsremains 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 CACHEoutright", 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 -
dwc2on the Pi 2,xhcion the Pi 4,xhcion the VisionFive 2, one physical stick - andfshas 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 answeredSYNCHRONIZE 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 inbuild/pi2a.logimmediately afterblock-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 repairedA 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-lyingflushmodels exactly that case in QEMU and it stays. The honest form of the Phase D claim -fsis 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-windowcuts 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/42carries 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):
registryis 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
registrySERVICE 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 minimalname → endpointrecovery directory inside the kernel (ipc::names+ the gatedAcquireSendCap). The supervisor wires every service from aname → capmap 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 anddocs/naming-design.mdare 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
supervisoris 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. (initremoved, Phase 5;registryretired, Phase 4;block-driver+fsrestartable, 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, andshellare 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 temporaryEndpointDead/ lookup miss, reacquire by name via the kernel directory, and retry (§14.3).fsre-mounts to a consistent state via its crash-consistency journal (Phase C;docs/persistence.md§6.8). Theshellis the user's interface - a crash or a deliberatekill shellrespawns 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).registryleft the TCB via H11 (then the service was retired, Phase 4);block-driver+fsvia 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: thekillsyscall, 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 didfsrestart?" could be answered nowhere in the system: a deliberatekilland a crash looked the same.The notice now carries it: the name, a NUL, then
Kand the name of the task that calledkill, orF, 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'sdeath). 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 answerssupcmd::DEATH; its "died, restarting" line now ends- killed by shellor- 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'sel0-fault-test);osdev test identity24/0 andadv15/0.docs/ask-design.md6 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,ehcianddwc2as 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) andnic-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:
xhciis the only confined driver in the system.ehciandblock-driverkeep a stale firmware DMA pointer that confinement would fault, so both run in deliberate passthrough (kernel/src/task/mod.rs, theconfineflag onHwClass::Pci- this cited aDeviceSpectype that has never existed, and it was introduced to replace a rotting line number on the grounds that a field name cannot drift);nic-driveris spawnedconfine=false.- AMD-Vi is x86-only. Every
iommu::entry point onarm,aarch64andriscv64is a stub andconfine_devicereturnsfalse. 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.mdanddocs/ahci.mdeach 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
xhciis 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 withamd-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
xhciandehci, 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-driverandnic-driverare still in passthrough, and on the T630 the audio driver does not yet use DMA at all (docs/audio.md, A6), so on real hardwarexhciis 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
xhciand 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 anxhcithat stopped completing commands in a chaos run,docs/wifi-usb.md51.)
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_PUSHholders 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 removedREBOOTfrom the USB drivers, so a compromised driver can no longer hard-reset the machine directly from any context - reboot lives only with the shell (itsrebootcommand). The residual (a keyboard can type) is inherent to being a keyboard, and is recorded here rather than papered over.audits/security-audit.mdSEC-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 holdsREBOOT- 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 aCONSOLE_PUSHholder could already typereboot- 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. ThexhciSERVICE drives the controller through an MMIO capability and a DMA arena granted at spawn, reaches the console throughCONSOLE_PUSH, and serves storage over the block IPC protocol thatblock-driveralready 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 andconfine_devicereturnsfalse- 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.rsis 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/dwc2registers forUSB_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 ofarch/arm/dwc2.rsis 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 callhardware_resetand 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 forservices/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.rsis deleted andservices/dwc2drives the controller from userspace onUSB_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_devicereturnsfalseon 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/smsc95xxnetworking, 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 ofarch/arm/dwc2.rsis TCB-critical (audited:audits/unsafe-audit.md,audits/security-audit.mdAudit 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 callhardware_resetdirectly), 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.mdAudit 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
GRANTright). - Generationed. Stale caps fail their generation check on use.
7.4 Rights Model
| Right | Meaning |
|---|---|
READ | Read from a resource |
WRITE | Write to a resource |
SEND | Send messages to an IPC endpoint |
RECV | Receive messages from an IPC endpoint |
GRANT | Transfer this capability via IPC |
REVOKE | Revoke 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:
| Error | Meaning |
|---|---|
CapNotHeld | Cap not in calling task's table |
CapInsufficientRights | Cap held but lacks required right |
CapNotGrantable | Send embedded a cap without GRANT |
CapWrongScope | Cap targets a different resource than the action |
CapRevoked | Authority explicitly invalidated |
EndpointDead | Endpoint/service lifecycle terminated |
ReplyDead | Replier 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 anRwLock, and this section claimed a property the code never had.GLOBAL_RESOURCESis aSpinLock<GlobalResourceTable>(kernel/src/capability/table.rs); there is noRwLocktype 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 theRwLockthis 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 freshResourceIdin its band, register it (generation 0, Alive), record the service's endpoint as its owner, and mint a cap with chosenRights(READ/WRITE/GRANT). The service hands a (narrowed) copy to a client by the existing embedded-cap transfer (§8.5).fsdoes this onOpen, recordingResourceId → 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 theResourceId- 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 returnsCapRevoked.fsrevokes on delete/close. - Minting is gated (§3.1).
resource_mintrequires aRESOURCE_MINTcapability, 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):callgains a BOUND, because the unbounded one was being worked around in a way that lost messages.callis 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 ofsend+ a plain endpointrecv. 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 asfsclients 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 machineryRecvTimeoutalready 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
callwith 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:
- Validates the cap (rights + generation).
- Looks up the target endpoint in the routing table.
- Enqueues into the target endpoint's queue (which lives on the target's core).
- 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:
- Grantable? The cap being sent must hold the
GRANTright, or the transfer is rejected withCapNotGrantable(§7.7). A copy made byderive_capcarries 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).- 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.
- Narrowed to need. Grant the least rights the receiver needs (§7.3). In particular, do not pass
GRANTonward unless the receiver is meant to re-delegate - a cap handed out withGRANTlets 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
| Event | Effect |
|---|---|
| Service dies | Endpoint generation bumped; queue drained |
send to closed endpoint | Returns EndpointDead |
recv on closed endpoint | Returns EndpointDead |
| Sender already blocked when endpoint closes | Wakes with EndpointDead (cross-core IPI) |
| Send-during-restart race | Generation check catches it; returns EndpointDead |
| Replier dies while the caller is blocked awaiting its reply | Caller 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 SDKrequest_with_reply, on whichfs'sblock_rpcrides) 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 blockedrecvhung 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 newReplyDeadcode, 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 byosdev test reply-dead(the reply-side twin of §22 Test 4).
No delivery guarantee. A successful
sendmeans 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_LEAVINGintask/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
TLBIALLwhen TTBR0 changes, RISC-V issuessfence.vmaafter writingsatp, 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_rootissues 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, untilfree_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 inbacklog/fixed/80K20.
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
supervisordirectly;initis 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 minimalname → endpointrecovery directory -ipc::names+ the gatedAcquireSendCap- is the namer now; the supervisor wires every service from itsname → capmap and clients reacquire by name through the directory). Phase 5 removesinit: the kernel's one direct spawn is the supervisor (task::spawn_supervisor, using the gatedSUPERVISOR_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 justsupervisor+ kernel.
Amendment 2026-09-12 (multi-architecture): the sequence above is the x86-64 one, and THERE IS MORE THAN ONE ENTRY PATH.
kernel_mainis 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 component | Effect |
|---|---|
| Bootloader | Hardware reset |
| Kernel BSP init | Kernel panic, halt |
| AP startup | Kernel 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 svc | Supervisor logs, may retry per policy |
| Service contracted to unavailable core | Spawn 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 theloggerservice 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 thelog_writecapability, 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 onlyipc_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 returnsEndpointDeadfrom it. So logs must NOT be re-pointed ateventsas 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
traceshipped, and has never held a log.eventsrather thanobservability:observealready 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 throughlog_write_slotand the trace path through a singleSINK_NAMEconstant, so no service names its own sink.What is NOT renamed, deliberately:
log_write,log_write_slot,ctx.log(), syscall 5 andkernel/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 renamedlog_received/log_did_not_receivefor 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.rsandbuild.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
consoleservice, 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 whichfbconwas 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.rsnow 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 asevents 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 inCOMMANDMENTS.baseline.tomlbecause 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 handwifi-driverthe 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.md45). 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_POWERat 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_ONon the firmware's GPIO expander, through the mailboxSET_GPIO_STATEtag - the pin Linux'smmc-pwrseq-simpletoggles 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 withfalse, 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 kindWIFI_SDIO, result by read-back,arch/riscv64. Every port but those two answersfalse.)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.md46); 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.md47). 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.md55) 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.md56). 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.md55), 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 ownGET_MIN_CLOCK_RATEorGET_MAX_CLOCK_RATEand returns what it reads back. The caller cannot name a rate. It is gated byCPU_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 arepower'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.md15, 26.10). Every port but the Pi 4 answers the seam withNoneand 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
DevicePowerabove - and thepowerservice is where the policy, and any future power policy, lives.Amendment 2026-10-01 (later): the result follows the pin's READ-BACK.
DevicePowerused to report success whenever the firmware accepted theSET_GPIO_STATErequest, 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 whenWL_ONreads back at the level asked for. The spawn also logsBT_ON, the same chip's Bluetooth enable: it reads 0 on this board, soWL_ONreally is the whole of the radio's power and nothing else needs cutting (docs/wifi.md53). 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
DevicePoweramendment 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-audioas "the existing practice". The operator asked whether that was normal, and it was not: it was debt, anddocs/service-ownership.mdhad 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.DevicePowerreaches 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 itsMANAGEDroster. The kernel's two lists of nineteen names are gone, and with them the drift that once lefttimeandcontroluncounted;V-managed-watchedchecks the chain that replaced them.- The display is reclaimed from, and console output delivered to, the task granted the
FRAMEBUFFERkind, not whatever is calledconsole.- 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-matchedconsole_pushmint 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.rebootresets 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\_S5package 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 throughhalt_all_coresand 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.
PowerOffturns 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.md4.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, besideREBOOT: ending the session is the operator's, and no service has a reason to end it - a failing service is restarted, never the machine.powerholds 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 typeshutdown.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
\_PTSmethod 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 setsPOWER_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.md5).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 theDevicePowerandCpuClockamendments above make. MISCIS is unchanged.Amendment 2026-10-15 (
rebooton the deadline, S6): the one deadline also holds a REBOOT. The operator asked thatreboottakeshutdown's forms -rebootcounting 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.PowerOffgains two ops, 3 (reboot insecs) and 4 (cancel it), gated byREBOOTrather thanPOWER_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 throughhardware_reset, the pathReboot(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 nowand Ctrl+Alt+Del stay the immediateReboot(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 inarch/, 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 = 2and 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 ownservice_configtable 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_stdand cannot parse TOML at spawn. That is not a new fact and not an implementation shortcut -scripts/contract_check.pyhas opened with it since it was written, and that script exists PRECISELY because the.tomlis 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 bycontract_check.py(memory limit, placement, send peers) and byIV-contract-authorityinscripts/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
recorderrestart a service it sees stop responding", with no mention of any checker. It addedservice_control = truetorecorder'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 validatepassed it, and all twelve checkers stayed silent. It does not work:recorder's privilege word is0, every call is denied, and the denial is discarded twice (the SDK'srestartswallows its ownkillerror and the caller wrotelet _ = ...) 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.mdflagged 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
- 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).
- Determines core placement (round-robin or contract override per §9.2; rejected if contracted core unavailable).
- 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.
- Kernel refuses any privilege the supervisor does not itself hold (
SUPERVISOR_DELEGATABLE), so a spawner passes on what it has and never mints. - Kernel mints capabilities per the spawn request (each tagged with current resource generation).
- Kernel maps service binary into new address space.
- Service registers any owned endpoints in the kernel name directory (
ipc::names). - 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 issupervisoralone. So a spawner can no longer ask for "the image you already have" - it must SUPPLY one, andSpawnImage(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.
SPAWNand image-spawn were the same right until step C, which meant everySPAWNholder - 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.SpawnImagetherefore requiresIMAGE_SPAWNon top ofSPAWN, and the kernel refuses the call when a caller holds only the latter.
IMAGE_SPAWNis deliberately NOT delegatable. It is absent fromSUPERVISOR_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 ownspawn_by_imageandspawn_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_pagesover 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, anddocs/service-ownership.mdhas 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 dozenSPAWNholders, 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.md4b) 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 codegodspeed verifyruns - 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, throughSpawnImageas 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 everyfsholder 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_SPAWNstays the supervisor's alone and undelegated;godspeed-pmasks 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
appand only with the operator's answer. An installed package of kindappis a utility that owns the screen and the keyboard while it runs (gs::app,docs/developer-services-design.md13.2, approved by the operator the same day). The supervisor starts it withSPAWN_FLAG_REQ_CONSOLE- the console read capabilitychaosand the shell already hold - when its grant namesconsole, whichgodspeed installasks 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
driveris 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.md13.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, andehcisince 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, aswifi-usbalways 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_SPAWNstays 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_overrideis provided, the supervisor requires that core (subject to the strict rules of §9.2; rejected if the core is unavailable). - If
placement_overrideis omitted, the placement decision is re-evaluated from scratch per §9.2 - contract-specified placement re-applies (and may fail withPlacementInvalidif 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.
- If
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 thekillsyscall or a fault, so every program was a service that never returns, and a program that does one job - autility- could not exist outside the shell (docs/stdlib-design.md1, 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 carriesEand the status, besideK(killed by) andF(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.
Exitreaches nothing: the one task it can end is the caller, asYieldandParkact 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
MANAGEDthat 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 inCOMMANDMENTS.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.rsis 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 throughunsafe. 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 inadversarial.rs. The install loader is the first production case: the supervisor reads a package's image fromfs, checks its digest and signature, and hands the kernel those same bytes, and an image is larger than any stack.Regionis two blocks (bytes,bytes_mut) over exactly the pages a successfulAllocMemmapped, and the supervisor staysunsafe-free behind it, as a driver does behindMmio.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
unsafelives 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/andosdev/carries#![deny(unsafe_code)], so rustc refuses the crate rather than a script noticing afterwards. That closes what a text scan cannot see -unsafeproduced by a macro expansion, or spelled across lines - and it fails at the author's keyboard instead of at the next CI run.
denyrather thanforbid, for exactly one reason, stated so nobody "tightens" it and breaks the build: a#[no_mangle]declaration is itself covered by theunsafe_codelint (an exported symbol can collide, which is a soundness hole), and every service needs#[no_mangle] service_mainbecausebuild.rslinks with--entry=service_main.forbidcannot be relaxed even for that. So the crates carrydenyplus ONE#[allow(unsafe_code)]on the entry symbol, andscripts/unsafe_check.pyasserts 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) andipc.rs(8) are recorded as grandfathered floors, and this list gainsloader.rsandmain.rs, which it had always omitted. Neither is newunsafe- both are the state of the tree being written down for the first time.scripts/unsafe_check.pyscanned onlykernel/src/andservices/, so the SDK's ~125unsafelines were audited by nothing at all while §18.4 claimed CI checks the audit against source (backlog/fixed/18). The scan now coverssdk/, 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_syscallis anunsafe fnbecause 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 nounsafeof 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_syscallwrapper - 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.rs79 -> 80. One call site,exit: oneunsafe { raw_syscall(57, ..) }behind the safeServiceContext::exitandgs::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.rs77 -> 79. Two call sites behind the §12.3 amendment of the same date:power_off_call, oneunsafe { raw_syscall(56, ..) }behind the three safe wrapperspower_off,power_off_inandpower_off_cancel; andpower_off_pending, oneunsafe { raw_syscall(13, 28, ..) }. The same single design consequence as theCpuClockamendment 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.rs83 -> 84. One moreunsafe { raw_syscall(13, 27, ..) }call site,boot_record_query, behind the safeboot_record_sizeandboot_record_readof 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.rs83 -> 84. One moreunsafe { raw_syscall(55, ..) }call site, the wrappercpu_clockbehind the §12.3 amendment of the same date - the same single design consequence as theDevicePoweramendment 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.rs82 -> 83. One moreunsafe { raw_syscall(54, ..) }call site, the wrapperdevice_powerbehind 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 saferaw_syscallthat 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 aconform-okmarker 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.mdcarries 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.
| Category | Purpose | Bar (what failure means) | Status |
|---|---|---|---|
| Identity | Pin constitutional decisions; existence proof | Constitutional invariant violated | §22 (Test 11 added, H11) |
| Property | Universal invariants under random inputs | A claim the spec makes does not hold | §22 (P1-P10, 10/10) |
| Fuzz | Crash resistance under adversarial inputs | Kernel panics on user-controllable input | Active |
| Stress | Survival under sustained load | Drift, leaks, or corruption appear over time | Active |
| Performance | Latency / throughput benchmarks | A measured number regressed | §22 (10/10) |
| Adversarial | Capability isolation under attack | An attack succeeds where the spec says it fails | Active |
| Chaos | Graceful degradation under partial failures | A defined failure mode is not handled cleanly | Active |
┌─────────────────────────────────────────────────────────────────────┐
│ 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.
| ID | Property | Pins |
|---|---|---|
| P1 | Random bytes → CapNotHeld or CapInvalid; never accepted as a cap | §7.3 (unforgeable) |
| P2 | Generation per service is strictly monotonic across its lifetime | §7.5 |
| P3 | Cap rights never widen during transfer | §7.3 (non-escalating) |
| P4 | ∑ current_task_alloc_bytes ≡ pages mapped, after any sequence of alloc/free | §10.3 |
| P5 | Every live endpoint has exactly one owning task | §8.3 |
| P6 | Queue head ≤ tail ≤ head + 16; count consistent with both | §8.5 |
| P7 | After unmap + TLB shootdown, the page is unreadable from every core | §10.5 |
| P8 | After restart, name resolves to a task with the same name and higher generation | §14.2 |
| P9 | Generation bump invalidates ALL holders, not just some | §7.5 |
| P10 | Every 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.
| ID | Surface | Generator |
|---|---|---|
| F1 | Syscall args (each × 1M iters) | Random u64 in a0/a1/a2; including kernel addresses, unmapped pointers, misaligned values |
| F2 | Syscall numbers | Random u64 as nr; must return UnknownSyscall, never crash |
| F3 | ELF binaries | Bit-flip mutations of known-good ELFs handed to the spawner |
| F4 | Service contracts | Malformed TOML; JSON Schema-invalid structures |
| F5 | IPC message bodies | Random bytes, random sizes up to 4 KiB |
| F6 | Embedded caps in messages | Messages claiming to carry caps with random structure |
| F7 | Cap generation field | Random u64 as generation in cap usage; must return CapRevoked or EndpointDead |
| F8 | Memory request values | Random 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.
| ID | Scenario | Duration |
|---|---|---|
| S1 | IPC saturation: sustained try_send on a full queue | 1 hour |
| S2 | Restart storm: 100k kill/respawn cycles of one service | until complete |
| S3 | Cross-core thrash: 4 cores × all-to-all IPC | 10 min |
| S4 | Cap table churn: 100k random create/destroy | until complete |
| S5 | Generation overflow: force counter to wrap, observe wraparound semantics | until wrap + 1k operations |
| S6 | Long-running stability: ping/pong + introspection | 24 hours |
| S7 | Memory pressure: alloc-to-limit + free, 10k cycles | until complete |
| S8 | Idle stability: boot, no workload, observe | 24 hours |
| S9 | Interrupt storm: high-frequency timer + IPI cross-fire | 1 hour |
| S10 | Cascading revocation: kill a service held by many; observe propagation | until 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.
| ID | Metric |
|---|---|
| B1 | IPC same-core round-trip latency: p50, p99, p99.9 |
| B2 | IPC cross-core round-trip latency: p50, p99, p99.9 |
| B3 | Syscall floor: yield round trip |
| B4 | Cap validation cost: one cap + generation check |
| B5 | Spawn cost: supervisor.spawn → service "ready" |
| B6 | Restart cost: kill + spawn |
| B7 | Cap table contention: throughput at 1, 2, 4 cores |
| B8 | Allocator throughput: pages/sec under contention |
| B9 | Message copy cost: 4 KiB upper-bound copy |
| B10 | Scheduler 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.
| ID | Attack |
|---|---|
| A1 | Service crafts random u64 values and tries to use them as caps |
| A2 | Service brute-forces endpoint IDs across the u32 space |
| A3 | Service attempts to allocate beyond its contract memory limit through every syscall path |
| A4 | Service receives a cap with limited rights and attempts to use rights it lacks |
| A5 | TOCTOU: service races a syscall with revocation of the cap it is about to use |
| A6 | Service tries to fill the cap table to denial-of-service the kernel |
| A7 | Service tries to detect IPC partner identity via timing |
| A8 | Service tries to monopolize a core via a tight loop without yielding |
| A9 | Service tries to spawn another service directly, bypassing the supervisor |
| A10 | Service passes kernel addresses as syscall arguments |
| A11 | Service invokes InspectKernel system queries without an INTROSPECT cap |
| A12 | Service invokes Reboot or PowerOff without the capability |
| A13 | Service invokes AcquireSendCap without the capability |
| A14 | Ring-3 CPU exception (null read, non-canonical read, divide-by-zero) kills the task, not the kernel |
| A15 | A 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.
| ID | Failure injected |
|---|---|
| C1 | One or more APs fail to come up during boot |
| C2 | A service in the boot manifest has a corrupted ELF (non-TCB) |
| C3 | Allocator forced to return AllocFailed at random syscall entry points |
| C4 | Bootloader provides degraded environment (minimal RAM, no framebuffer) |
| C5 | Kernel stack approaches exhaustion under deeply nested syscall |
| C6 | One core's timer interrupt is dropped for an extended period |
| C7 | TLB 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 4minimum 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 aname → capmap 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-driveris 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
| Test | Spec sections pinned | Constitutional invariant |
|---|---|---|
| 1. Bootstrap | §11, §6 | TCB integrity |
| 2. Capability enforcement | §3.1, §7 | No ambient authority |
| 3. IPC send/receive (same core) | §8, §7.4 | Authority is explicit |
| 4. Endpoint death | §8.5, §8.6, §14.2 | Restartability |
| 5. Capability transfer | §7.6, §7.4 | Authority is explicit |
| 6. Supervisor restart | §14.2, §14.3 | Restartability |
| 7. Memory limits | §10.3, §10.4 | Isolation |
| 8. Preemption | §3.6, §9.1, §9.3 | No service monopoly |
| 9. Cross-core IPC | §8.3, §8.4, §9 | Identity over location |
| 10. Restart with core change | §9.2, §14.2, §14.4, §11 | Identity over location |
| 11. Name resolves after restart (directory) | §6.1, §6.2, §14, §3.11, §4.4 | Restartability / naming-out-of-kernel |
| 12. Confined driver DMA faults | §3.1, §6.4, §12 | No ambient authority (DMA) |
| 13. fs survives own restart | §6.1, §6.2, §6.3, §15, §14 | Restartability / TCB shrink |
| 14. File is a capability | §7.3, §7.10, §3.1, §3.3 | Authority is explicit (files) |
| 15. Supervisor survives own restart | §6.2, §6.3, §3.7, §14, §3.11 | Restartability / 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 ✅
osdev run --smp 4boots the OS with 4 cores; supervisor, events, ping, and pong reach steady state. ✅ (As recorded in 2026-05 this read "init, supervisor, registry, logger";initwas removed in Phase 5, theregistryservice retired in Phase 4, andloggerrenamedeventsin 2026-09.)- ping placed on core 0; pong placed on core 1. ✅
osdev logs pingshows ping sending a message every second. ✅osdev logs pongshows pong receiving each message (cross-core IPC). ✅osdev restart pong --core 2kills pong on core 1 and respawns it on core 2. ✅- ping observes
EndpointDeadand reacquires by name; the new cap routes to core 2. ✅ - After reacquisition, ping and pong continue communicating across the new core boundary. ✅
- The kernel does not panic on any core. ✅
- 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 imageproduces a UEFI GPT disk image atbuild/os-usb.img. (This saidbuild/os.img, which is the QEMU/BIOS image -osdev/src/disk_image.rsnotes the two are named apart on purpose, and §17 has it right.)- Image written to USB with Cygwin
dd; boots viaBOOTX64.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-metalsupervisor 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):
| Benchmark | Result |
|---|---|
| BP1 IPC same-core p50 | 55,320 cycles (~18.4 µs) |
| BP3 yield floor | 39,903 cycles (~13.3 µs) |
| BP4 cap validation | 495 cycles (~165 ns) |
| BP5 spawn cost | 8,121,378 cycles (~2.7 ms) |
| BP6 restart cost | 14,462,309 cycles (~4.8 ms) |
| BP7 cap table | 1,168 cycles (~389 ns) |
| BP8 allocator | 616 cycles/4KiB page (~205 ns) |
| BP9 message copy 4KiB | 20,073 cycles (~6.7 µs) |
| BP10 scheduler decision | 2,323 cycles (~774 ns) |
| BP2 IPC cross-core | Not 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:
| Benchmark | T630 isolated | J5005 (above, perf-brutal) |
|---|---|---|
| BP1 IPC same-core p50 | ~102,600 cycles (~51 µs) † | 55,320 |
| BP2 IPC cross-core p50 | 1,433,087 cycles (~0.72 ms); p99/p999 16,409,799 | not measured |
| BP3 yield floor | 19,281 cycles (~9.6 µs) | 39,903 |
| BP4 cap validation | ~1,258 cycles (~0.63 µs) † | 495 |
| BP5 spawn cost | 45,406,292 cycles (~22.7 ms) | 8,121,378 |
| BP6 restart cost | 54,231,139 cycles (~27.1 ms) | 14,462,309 |
| BP7 cap table | 2,932 cycles (~1.5 µs) | 1,168 |
| BP8 allocator | ~1,472 cycles/4KiB (~0.74 µs) † | 616 |
| BP9 message copy 4KiB | 21,796 cycles (~10.9 µs) | 20,073 |
| BP10 scheduler decision | 20,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
ReplyDeadif the replier dies first, so it never hangs. Mechanism, not policy - the kernel learns a reply cap, not "RPC". The primitive behind the SDKrequest_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 aRESOURCE_MINTcap); the kernel validates and routes it as for any cap, badging a send with the opaqueResourceIdso 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
Callrequest 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 withReplyDead. 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
Callawaiting its reply (value -12; §7.7, §8.6). The reply-side twin ofEndpointDead, 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.)registryleft 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+fsleft via the Phase D amendment (§6.1, oncefsgained crash-consistent recovery). DMA drivers (xhci/ehci, and ARM'sdwc2) 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;initwas 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+fsare restartable storage services.) - Name directory - the kernel's minimal
name → EndpointIdmap (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 aname → capmap and clients reacquire names through the directory. (The retiredregistryservice'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/smpandarch/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
| Reason | Detail |
|---|---|
| Tight Rust integration | The 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 supplied | APIC IDs come pre-discovered. arch/x86_64/ap_boot.rs does not need an ACPI parser to enumerate cores. |
| BIOS + UEFI from one bootloader | Limine supports both firmware modes with the same protocol. The kernel does not need to care which firmware booted it. |
| Good QEMU support | Critical for osdev run (§17). The whole identity test suite runs under QEMU; the bootloader must work there reliably. |
| Active maintenance | Limine 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.EFIon the EFI System Partition. - The kernel binary is placed where
limine.confreferences it. - Firmware →
BOOTX64.EFI→ Limine → kernel.
BIOS systems:
- The installer runs
limine bios-installto 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
| Option | Why Not |
|---|---|
| GRUB / Multiboot2 | Older protocol, clunkier Rust integration, no built-in SMP topology surface, much larger surface area to depend on. |
| Custom bootloader | A 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.mdwas 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.rsare typed Rust structures. C or other-language equivalents would need parallel SDKs. - Contract validation in
osdevis 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
consoleservice 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 indocs/console-service.md§5a: there is one input ring and one reader slot, so a service that owned the keyboard would block inConsoleReadand 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:lsdoes not run, it answerstry \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.pyreads that same table to make sure no document in this repository shows one of those words being used as a command, which is how thels->dirrename was found to have missed ten worked examples.Note 2026-10-13: two Unix words are commands,
cdandpwd, and each has a reason.cdwas kept from the start because it is universal (utilities/17_cd.md).pwdjoined it with the filesystem layout (docs/filesystem-layout.md6): the prompt is a plaingsh>, so something has to say where you are, andpwdiscd's other half - anyone who knows one knows the other. It leftFOREIGN_HINTS, where its hint had beencd ., 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, sopoweroffwould claim what does not happen (docs/shutdown.md). It leftFOREIGN_HINTS, where its hint had beenreboot- wrong advice, sincerebootturns nothing off - andhaltandpoweroffstay hints that answershutdown.
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
traceutility, which shipped with ZERO kernel growth - the ring lives in theloggerservice 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, andTaskStat, 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.mdhas 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 → CoreIdbecomesEndpointId → (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 primitive | GodspeedOS mapping |
|---|---|
Sequential (cmd1; cmd2) | Shell calls supervisor.spawn(cmd1), waits, then spawns cmd2. Unchanged. |
Variables, if, while | Entirely 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:
- Shell creates a fresh IPC endpoint.
- Shell grants
cmd1aSENDcap to that endpoint, via its spawn contract. - Shell grants
cmd2aRECVcap to the same endpoint, via its spawn contract. cmd1's "stdout" is "send to the cap I was granted."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:
| Direction | Trade-offs |
|---|---|
| POSIX-flavored | Familiar 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 language | Shell 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,SIGTERMhandlers). - 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 about | GodspeedOS equivalent |
|---|---|
errno translation | Typed Result<> from the SDK |
| Signal handlers | None needed |
| Permission checks | Either you have the cap or you don't |
| Symlinks, special files | FS service handles; utility doesn't see them |
| getopt edge cases | Rust argument-parsing crate |
| Cross-platform abstraction layers | Single platform |
| Memory safety | Free 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.spawnis 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
editpiece table holds one window of an arbitrarily large file, never the file;read/write/copystream inIO_CHUNKpieces); - refer to data by
(offset, len)spans instead of copying it (piece spans; interned string ids in the recordsTable); - 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
sendmeans queued, not processed; and failure is explicit - a caller blocked awaiting a reply wakes withReplyDeadwhen 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:
- Identity - Does it remain itself?
- Property - Does it preserve its invariants?
- Fuzz - Does unexpected input reveal hidden assumptions?
- Stress - Does pressure change correctness?
- Performance - Does it remain efficient under sustained load?
- Adversarial - Can deliberate attack break its guarantees?
- 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
| Abbreviation | Expansion | Meaning in GodspeedOS |
|---|---|---|
| ABI | Application Binary Interface | The 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). |
| ACPI | Advanced Configuration and Power Interface | Firmware 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. |
| AHCI | Advanced Host Controller Interface | The 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-Vi | AMD 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. |
| AP | Application Processor | Any 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. |
| API | Application Programming Interface | General term for a service or supervisor call surface (e.g. the supervisor restart API, section 14.4). |
| APIC | Advanced Programmable Interrupt Controller | x86 interrupt controller; Limine supplies APIC IDs of all cores so the kernel needs no MADT probe. |
| ATA | AT Attachment | Legacy disk interface; the ATA PIO (no-DMA, least-privilege) block backend works under QEMU's legacy IDE, superseded by AHCI for real hardware. |
| BAR | Base Address Register | PCI register locating a device's MMIO region; read during controller enumeration. |
| BDF | Bus / Device / Function | A PCI device's address; the IOMMU confines a specific BDF to its DMA arena (boot prints confined BDF ..., section 6.4). |
| BIOS | Basic Input/Output System | Legacy 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). |
| BSP | Bootstrap Processor | The first core to execute kernel code; performs kernel init and brings the APs online. |
| CAS | Compare-And-Swap | Scheduler Running-to-Ready transitions use CAS so a cross-core kill's Dead state is preserved (not clobbered by a plain store). |
| CLI | Command-Line Interface | osdev, the host-side CLI that builds, runs, publishes, and controls the OS in QEMU (section 17). |
| CMOS | Complementary Metal-Oxide-Semiconductor | The MC146818 CMOS RTC chip the kernel reads for wall-clock date/time (arch/x86_64/rtc.rs). |
| COM1 / COM2 | Serial communication port 1 / 2 | COM1 carries the interactive shell console; COM2 is the control/harness channel used by osdev tests. |
| CRC32 | 32-bit Cyclic Redundancy Check | Checksum used for disk-image and GPT integrity (osdev/src/crc32.rs). |
| DMA | Direct Memory Access | Device-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). |
| DoS | Denial of Service | A 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). |
| EHCI | Enhanced Host Controller Interface | The USB 2.0 controller driver; runs in transparent IOMMU passthrough (it legitimately reaches firmware/hub regions, section 6.4). |
| ELF | Executable and Linkable Format | Service binary format; the spawner maps an ELF into a new address space; bit-flip-mutated ELFs are a fuzz surface (F3). |
| EOT | End Of Transmission | The 0x04 marker that ends one stream in a capability-mediated pipe (docs/pipes.md, Appendix D.3). |
| ESP | EFI System Partition | The FAT boot partition holding BOOTX64.EFI and the kernel; the boot region of a bootable GodspeedOS drive. |
| FAT / FAT32 | File 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. |
| FIS | Frame Information Structure | An AHCI command/data frame in the SATA transport. |
| FSF | Free Software Foundation | Steward of the GPL; the kernel is GPL-2.0-only, "the same license as the Linux kernel" (LICENSE, docs/licensing.md). |
| GDT | Global Descriptor Table | x86 segment descriptor table set up during BSP init. |
| GPL | GNU General Public License | The kernel license (GPL-2.0-only copyleft); the SDK is permissive Apache-2.0, with the capability/IPC boundary as the license boundary. |
| GPT | GUID Partition Table | The partitioning scheme of build/os.img (a UEFI GPT disk image). |
| GSFS | Godspeed File System | The 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). |
| gsh | Godspeed shell (language) | The .gsh shell scripting language (design sketch, docs/scripting.md); no_std, no heap, fixed/bounded storage by design. |
| HHDM | Higher-Half Direct Map | Limine pre-maps physical memory at a known high virtual address; the kernel uses the HHDM to read ACPI tables and physical frames. |
| HID | Human Interface Device | The USB keyboard/mouse device class; the xHCI and EHCI drivers share sdk/rust/src/hid.rs. |
| IDT | Interrupt Descriptor Table | x86 interrupt-vector table installed at BSP init. |
| IF | Interrupt Flag | x86 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. |
| IOMMU | Input/Output Memory Management Unit | Translates 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). |
| IPC | Inter-Process Communication | Synchronous, bounded-queue message passing between services; the core kernel primitive (section 8). |
| IPI | Inter-Processor Interrupt | One core signaling another - used to wake a blocked cross-core recv and to drive TLB shootdowns. |
| IRQ | Interrupt Request | A hardware interrupt line; the kernel routes IRQs to userspace driver endpoints via IPC (section 12). |
| ISR | Interrupt Service Routine | A kernel interrupt handler (e.g. the per-core timer ISR that enforces the quantum). |
| IVRS | I/O Virtualization Reporting Structure | The 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). |
| JSON | JavaScript Object Notation | The schema format (contracts/schema/service.schema.json) osdev uses to validate service contracts. |
| KVM | Kernel-based Virtual Machine | QEMU hardware acceleration, enabled by the harness when /dev/kvm is available. |
| LAPIC | Local APIC | The per-core APIC; its periodic timer drives the 10 ms preemption quantum. |
| LBA | Logical Block Address | A disk block index; the block-IPC LBA field is u64 (docs/persistence.md). |
| MADT | Multiple APIC Description Table | The ACPI table that would enumerate cores; not parsed in v1 because Limine supplies APIC IDs directly. |
| MBR | Master Boot Record | BIOS boot sector; limine bios-install writes Limine's stage-1 there. |
| MISCIS | Memory isolation, IPC, Scheduling, Capabilities, Interrupts, SMP routing | The 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. |
| MMIO | Memory-Mapped I/O | Device 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). |
| MSI | Message-Signaled Interrupt | PCI interrupt delivery via a memory write; the current USB drivers use pin-based IRQs, with MSI the basis of the interrupt-driven driver branch. |
| NIC | Network Interface Controller | The network device in the (design-only) networking stack; an IOMMU-confined e1000 driver for QEMU/Intel (docs/networking.md). |
| PCI | Peripheral Component Interconnect | The device bus enumerated to find controllers (arch/x86_64/pci.rs). |
| PF | Page Fault | A protection violation; an access outside a mapped region kills the service (section 10.4), and a guard-page PF catches stack overflow. |
| PIO | Programmed 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. |
| PRDT | Physical Region Descriptor Table | The AHCI scatter-gather list describing DMA buffers for a command. |
| QEMU | Quick Emulator | The virtual machine osdev run / osdev test boots the OS in. |
| RCU | Read-Copy-Update | A candidate v2 replacement for the v1 global capability-table RwLock (requires benchmarks first, section 7.8). |
| RTC | Real-Time Clock | The CMOS battery-backed clock read for uptime and the date command; uptime uses rtc::now_epoch_monotonic to deglitch. |
| RwLock | Read-Write Lock | The single global lock guarding the capability table in v1 (a known, benchmarked perf cost; section 7.8, benchmark B7). |
| RX / TX | Receive / Transmit | Serial (and network) data directions (e.g. polling UART RX in the timer ISR). |
| SATA | Serial ATA | The disk interface driven by the AHCI block backend. |
| SDK | Software Development Kit | sdk/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). |
| SemVer | Semantic Versioning | The versioning scheme for contracts and the contract schema (breaking changes need a major bump). |
| SMP | Symmetric Multi-Processing | Multi-core support with static placement and no migration - cores discovered at boot, services pinned for life (section 9). |
| SPDX | Software Package Data Exchange | Per-file SPDX-License-Identifier tags mark each license zone (kernel GPL-2.0-only vs SDK Apache-2.0). |
| TCB | Trusted Computing Base | The 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. |
| TCG | Tiny Code Generator | QEMU's pure-software (non-KVM) JIT; cross-core wake timing differs under TCG, so some tests poll rather than block-wait. |
| TLB | Translation Lookaside Buffer | On 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). |
| TOCTOU | Time-Of-Check-To-Time-Of-Use | The send-during-restart race; caught atomically by the generation check inside the send syscall (section 8.7, adversarial test A5). |
| TOML | Tom's Obvious Minimal Language | The format of a service contract (contracts/<name>.toml), validated structurally against the JSON Schema. |
| TSC | Time-Stamp Counter | The CPU cycle counter (read_tsc) used for the performance benchmarks; note CORE_TOTAL_TICKS counts scheduler quanta, not time. |
| UART | Universal Asynchronous Receiver/Transmitter | The serial-port hardware carrying the shell (COM1, 115200 8N1). |
| UB | Undefined Behavior | What unsound unsafe would produce; the spec's rule is "undefined behavior in spec becomes bugs in system" (section 25). |
| UAF | Use-After-Free | A class of memory bug guarded against in the kill-path reclaim walk (a defensive PTE-validity guard skips and logs corrupt entries). |
| UEFI | Unified Extensible Firmware Interface | The primary firmware boot path: firmware loads BOOTX64.EFI, then Limine, then the kernel. |
| USB | Universal Serial Bus | Keyboard and mouse input, driven by the userspace xHCI and EHCI services. |
| W^X | Write XOR Execute | A page-table hardening invariant - no page is both writable and executable (the H4 kstack-guard / W^X work, section 18.5). |
| xHCI | eXtensible Host Controller Interface | The 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.
| Code | Meaning | Where |
|---|---|---|
| H1 | IOMMU 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 |
| H11 | The 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 |
| P2 | File-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 C | fs 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 D | block-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 C | Naming 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 4 | The registry service was retired into the kernel's name -> EndpointId directory. | Path C |
| naming Phase 5 | init was removed; the kernel spawns the supervisor directly. | Path C |
| naming Phase 6 | The 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, readCOMMANDMENTS.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
- 2026-05-09 - The day the nouns became verbs
- 2026-05-10 - The day two services talked across a boundary
- 2026-05-12 - The day a service died and came back
- 2026-05-21 - The day the model met a real machine
- ~2026-05-30 - The day the thin client taught me about real concurrency
- 2026-06-09 - The day a trusted service stopped being trusted (H11)
- 2026-06-12 - The day I found kernel-equivalent power hiding in a driver (H1)
- 2026-06-17 - The day a crash stopped meaning a reboot (Phase D)
- 2026-06-18 - The day a file became a real capability (P2)
- 2026-06-20 to 2026-06-21 - The day I deleted the registry and learned what is truly irreducible
- 2026-06-21 - The day nothing was allowed to be unkillable but the kernel
- 2026-06-21 - The day Maximum Carnage was born
- 2026-06-22 to 2026-06-28 - The days Carnage found the wedges no one would write by hand
- 2026-06-25 - The day I wrote the constitution down as ten lines
- 2026-06-29 - The day the examples had to teach the truth
- 2026-06-30 to 2026-07-01 - The day "wait on truth" turned out to be half a truth
- 2026-07-03 - The day the shell became a language, and a 10 MB script was a non-event
- 2026-07-04 - The day the system survived a million acts of violence
- 2026-07-04 - The day gsh was finished, and never once reached for a heap
- 2026-07-04 - The day a private discipline became a public one
- 2026-07-04 - The day it shipped, and called itself 0.1
- 2026-07-07 to 2026-07-08 - The day the wire became location
- 2026-07-09 - The day half a truth came back for the mount loop
- 2026-07-09 - The day recovery became a fixed point
- 2026-07-10 - The day the kernel met a second architecture, and fit the machine it found
- 2026-07-11 to 2026-07-13 - The days the whole system stood for audit
- 2026-07-14 - The day the boundary became something a machine enforced
- 2026-07-14 - The day one kernel booted three instruction sets in QEMU virt
- 2026-07-25 - The day the second architecture stopped being a demonstration
- 2026-07-27 - The day a stick swap ended the hunt, and the recovery earned its keep
- 2026-07-31 - The day I learned what my instruments could not see
- 2026-08-09 to 2026-08-12 - The day the kernel gave up the USB stack, and then gave up the choice
- 2026-08-17 to 2026-08-29 - The day the terminal left the kernel, and I learned what earns ring 0
- 2026-09-03 to 2026-09-04 - The day the kernel stopped interpreting the bus
- 2026-09-05 to 2026-09-06 - The day five bugs stood in a line, each hiding the one behind it
- 2026-09-06 - The day the second core stopped being an answer
- 2026-09-12 to 2026-09-13 - The day "it boots" stopped being the standard
- 2026-09-13 to 2026-09-14 - The day the enforcement layer was pointed at itself
- 2026-09-26 to 2026-09-27 - The day a rule nobody could read stopped counting as enforced
- 2026-09-27 to 2026-09-28 - The day the radio scanned the room
- 2026-09-29 to 2026-09-30 - The day the radio joined, and the cable learned to step aside
- 2026-09-30 to 2026-10-02 - The day the warm chip turned out to be a slow host
- 2026-10-15 to 2026-10-19 - The day the machine learned to trust what it fetched
- The Days I Was Wrong
- The Named Bugs - the teachers
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.
fshad 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:
- A single DWC2 channel shared by the keyboard poll and storage, where a keyboard split abandoned mid-flight corrupted the next block transfer.
resource_invokepacking itsrightfield at bit 32 of a syscall argument, where arm32's register is 32 bits wide, so the right arrived as zero.- 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.
- 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.
- The sink was reacquired on send failure - and the sample that triggered the reacquire was dropped.
- A missed resolve latched forever, because
u32::MAXwas 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. - The kernel dropped the peer DECLARATION when a declared peer was down at spawn. Not the
capability - the declaration.
AcquireSendCapauthorises 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 costfsits disk and produced 113 failures in one run, with storage reported unavailable whileblock-driversat idle beside it having received exactly one message. - 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.
- 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.
| before | after | |
|---|---|---|
arch-conditional sites outside arch/ | 143 | 46 |
| of which, in the NEUTRAL KERNEL | 14 | 2 |
services/supervisor/src/main.rs | 49 | 4 |
services/nic-driver/src/main.rs | 26 | 10 |
services/block-driver/ (3 files) | 21 | 0 |
services/shell/src/main.rs | 7 | 0 |
arch::imp seam members, all answered by all ports | 122 | 131 |
checker scripts (scripts/*check*.py) | 11 | 13 |
| a test that BUILDS a fresh ISA | none | scaffold_check.py |
unsafe in services | 0, by grep | 0, 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.
| before | after | |
|---|---|---|
| Commandments mechanised | 5 of 10 | 10 of 10 |
| checker scripts | 11 | 13 |
| self-test probes ("prove the guard fires") | 61 | 73 |
| ratcheted units | 1 (arch NAMES) | 2 (arch NAMES + cfg SITES) |
| shipped binaries changed | none |
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.mdnames "two rules the boundary is built on", andarch_boundary_check.pysat 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-tablepinned which services the kernel knows; nothing pinned what those services are given. Adding an IRQ line, flippinghas_console_read, or adding a privilege bit changed no name, no cfg, nounsafe, 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 = trueto a service's.toml, wrote the restart logic, and reported success. It compiled,osdev validatepassed 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_sendwith 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\bis the BACKSPACE character. That branch matched nothing; everycfg!site counted zero.print()of the pattern looked correct, because a backspace is invisible. Onlyrepr()showed it; - a site scan that counted
#[cfg]attributes and silently ignoredcfg!()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_invokepacked itsrightfield 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,fsreceived 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
recvon 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
fsa 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
TPPolladvanced its own head independently of the ring index the driver tracked; the two drifted,OWNbits stranded, and TX wedged with a telltaledesc=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:
| Kind | What it is | Written with |
|---|---|---|
service | Runs until it is stopped; the supervisor keeps it running and restarts it if it dies | gs::ipc, gs::call, gs::fs, gs::net, ... |
utility | One 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's | gs::program |
app | A utility that holds the screen and the keyboard while it runs, and gives them back when it ends | gs::app |
driver | Drives one device, named in its contract by PCI class code and register BAR, or a USB device by vendor and product | gs::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: draintake_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_noticepolls for an operator pressingq, 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.
| Module | What it is for |
|---|---|
registers | Your device's register window, every access bounds-checked: gs::driver::registers(&ctx) |
arena, Layout | Your DMA arena, laid out in named parts, each aligned on its PHYSICAL address and refused by name if it does not fit |
wait | Waiting for the device, bounded by TIME (Budget), not by a count of tries: wait::until, Deadline, Since |
delay | Holding still for a minimum the device needs and nothing can report the end of: hold, hold_parked |
irq | Waiting 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 |
input | A keyboard driver's keystrokes, pushed to the console: push, and ready when setup is done |
power | Cutting and restoring your device's power where the machine can: power::set |
device | What 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 |
bus | A 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::newcarries nothing of its own: every call rides the capability your contract was already granted, and a service that never asked for the filesystem getsError::Unreachablefrom 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:
| Option | What it costs | What it buys |
|---|---|---|
| A. Ship the stdlib for SERVICES only | Nothing 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) syscall | One 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 userspace | A "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.
| Area | What exists | Quality |
|---|---|---|
| Entry + runtime | service_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 |
| Authority | ServiceContext is the single door. The contract declares, the SPAWN REQUEST grants (§13.6) | Excellent. Nothing to fix |
| IPC | send/try_send/recv/call/call_deadline_into | Good primitives |
| Request/reply | 8 request_with_reply* variants, 3 outcome enums | See §3.2. The problem area |
| Console | console_write, console_writeln, console_write_fmt, console_read | Clean. A stdlib io module is mostly a rename |
| Filesystem | An opcode/byte wire protocol over IPC | See §3.1. The other problem area |
| Capabilities | reacquire_cap, reacquire_cap_detail | Correct, and clients must use them (§14.3) |
| Records | record.rs (677 lines), a typed row/table model the shell pipes through | Already 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 onOk(None)(the deadline passed)... a slowfsis alive, and re-sending a write to it is how one copy becomes two.request_with_reply_deadlinecannot 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.
| Module | Wraps | Removes | Notes |
|---|---|---|---|
gs::fs | The filesystem wire protocol | §3.1, the whole of it | The big win. Typed ops, one owner for the byte layout |
gs::io | console_write* | Little plumbing, but it is where print lives | Thin by design. Mostly naming |
gs::call | request_with_reply* + reacquire | §3.2 and §3.3 | ONE honest primitive, not eight |
gs::Error | DeadlineOutcome + fs status bytes | Per-crate error mapping | Derived 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 inspectnet-stack's socket surface properly before claiming a shape. Deferred to a follow-up, not dismissed.task,time,cap- no demonstrated repeated plumbing found.timeis one syscall,capis alreadyServiceContext, andtaskneeds 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
-
The stdlib must not become a second SDK.
ServiceContextis 4,059 lines and already the single door. Ifgs::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-exportsServiceContextuntouched for everything else. -
Authority must stay visible.
gs::fs::read_intoneeds the caller's existingfssend capability. It must take theServiceContext(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. -
Portability. Nothing proposed needs
#[cfg(target_arch)]. The wire protocols are little-endian by contract and already proven to cross architectures (cross_isa.py12/0, one volume carried x86-64 to riscv64 and back). The stdlib must not become the 45th arch-conditional site. -
Where does it live?
sdk/rustis the services' SDK. A separatestdlib/rustcrate 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):
gs::fsover the wire protocol, with the byte layout owned once.gs::call- one honest request primitive returning the existingDeadlineOutcomeshape, with reacquire-and-retry built in and correct (retry onSendFailed, never onTimeout).gs::io- a thin, documented console surface.- Migrate
services/recorder(filesystem) and one console path, and report what still needs raw plumbing.
Three questions for you:
- 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.
- Crate boundary: new
stdlib/rustdepending onsdk/rust, or agsmodule inside the SDK? - Is
netin the first slice? The brief asks for a network migration; I would rather inspectnet-stackfirst 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:
reacquire_apinow creditsgs::call, so a service routing through it gets the path it has.IX-stdlib-delegatesis a new check asserting thatstdlib/rust/src/call.rsreally does reacquire, and does it onSendFailedrather 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 constants | 4 (fs, shell, copier, recorder) | 0 |
| Real clients | 3 | 2 - 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::netis written and correct against the protocol, but no utility has been moved onto it yet: the shell's net client lives behindShellCtx, whoseServiceContextis 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:
| before | after | |
|---|---|---|
| lines, comments included | 61 | 75 |
| lines, comments excluded | 50 | 57 |
sites naming the wire format (payload[..], opcode 21, >> 8, ReqOutcome) | 10 | 0 |
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_CAPACITYandOP_FLUSHare not file operations. They areblock-driveropcodes thatfsuses as a CLIENT. Counting them as candidates forgs::fswas reading a constant list without reading what serves it.send_res!already sends a REASON. OnFS_ERRthe service appends its own words - "name already exists", "cannot move root", "source not found" - andfrom_fs_statusmapped the status byte and dropped the sentence. A loud failure was being made quieter in transit (26.7).Fs::reasonkeeps it in a bounded buffer the handle owns, documented as diagnostic-only: branch on theError, 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:
- A page cap. My loop followed
nextfor as long as the service saidmore- an unbounded execution scope (26.6) resting entirely on the service behaving.DIR_PAGE_MAXis now the same 512 the shell uses. - A
cutflag. 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". MyResult<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. HenceListing { 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
CallDeadlineform ofresource_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:
unsafe_check.py'sDENY_ROOTSwas("services", "examples", "osdev")- the stdlib was not scanned at all. The same gap class asdoc_symbols_check.pynot scanningstdlib/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.- The presence test was satisfiable by a COMMENT. It asked
DENY_ATTR not in textagainst the raw file, andstdlib/rust/src/lib.rsexplains 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
recvhas 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 godspeed7/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, sosockstill relies on draining.examples/holderstill does a barectx.recv()and hangs forever if its owner dies - hole 2, the missingReplyDead, 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 aresource_invokeis bounded byrecv_abortable_deadline, so a dead owner costs a deadline, not a hang. It still hand-rolls the invoke rather than usinggs::resource::invoke, which ispub(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 godspeed7/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:
send_towaitedNET_SECS(10s) whilenet-stackretries 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.cmd_tcplost half its patience in the migration, silently. It waitedNET_TXN_SECS(20s) before moving ontogs::net, and the move put it onNET_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 aTCP_SECSnow, 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 - thecap::Filerefactor ontoresourceis 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 godspeed7/7. Still nokernel/orsdk/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.
| before | after | |
|---|---|---|
| lines | 937 | 870 |
| wire-format sites | 47 | 1 (its own control protocol, which is correct) |
| hand-copied fs opcodes | 11 | 0 |
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_intoreads 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 insideread_intoand simply was not reachable.- Paths accept BYTES. Every path parameter took
&str, and copier stores paths as[u8; PATH_MAX]- as it must, becauseservices/fsaccepts any byte above 0x1f except/and 0x7f. A&str-only API forces such a caller throughfrom_utf8(..).unwrap_or(""), which turns an unreadable name into a request for a DIFFERENT file, silently. I had already reasoned this out forDirEntry::name- raw bytes, precisely so a hostile name stays reachable bydelete- and then took&stron every path anyway.impl AsRef<[u8]>accepts both, so the documentedfs.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 ongsnow; 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 togs::ipc,gs::capandgs::tasklike 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 nogs::fsorgs::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 nextaccept()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
| Commit | Sites | What it removed |
|---|---|---|
| dogfood 17 | five STAT sites, churn reset | five independent decodings of the same eleven bytes |
| dogfood 18 | mkdir, delete | two commands whose whole job is a mutation, reporting it as a status byte |
| dogfood 19 | five directory walks | five copies of the page loop and the 15-byte entry stride |
| dogfood 20 | seven deadline-bounded sites | fs_request_bounded and the shell's OP_READ_FILE decoder |
| dogfood 21 | churn verify | the 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:
selfcheck516/0, all nine parts, one tally, zero skips, zero detail-cap linesnetlease, gateway and ping;tcpreal SYN/RST with on-link ARPserve3/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:
- 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.
net-stack's wire format changed. The badged path strips two header bytes instead of one andDisplaced::notereads patience frompl.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/48andbacklog/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-driverread 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
| board | ISA | NIC | selfcheck | sock | serve |
|---|---|---|---|---|---|
| HP T630 | x86-64 | RTL8168 | 516 / 0 | 94 B | 3/3 |
| Dell Wyse 5070 | x86-64 | RTL8168 | 518 / 0 | 94 B | 3/3 |
| Raspberry Pi 2 | ARMv7 | smsc95xx | 509 / 0 | 94 B | 3/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
| board | ISA | NIC | bus | selfcheck | sock | serve |
|---|---|---|---|---|---|---|
| HP T630 | x86-64 | RTL8168 | PCIe | 516 / 0 | 94 B | 3/3 |
| Dell Wyse 5070 | x86-64 | RTL8168 | PCIe | 518 / 0 | 94 B | 3/3 |
| Raspberry Pi 2 | ARMv7 | smsc95xx | USB | 509 / 0 (1 skip) | 94 B | 3/3 |
| Raspberry Pi 4 | AArch64 | GENET | on-SoC | 516 / 0 | 94 B | 3/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
| board | ISA | NIC | bus | selfcheck | sock | serve |
|---|---|---|---|---|---|---|
| HP T630 | x86-64 | RTL8168 | PCIe | 516 / 0 | 94 B | 3/3 |
| Dell Wyse 5070 | x86-64 | RTL8168 | PCIe | 518 / 0 | 94 B | 3/3 |
| Raspberry Pi 2 | ARMv7 | smsc95xx | USB | 509 / 0 (1 skip) | 94 B | 3/3 |
| Raspberry Pi 4 | AArch64 | GENET | on-SoC | 516 / 0 | 94 B | 3/3 |
| StarFive VisionFive 2 | RISC-V 64 | dwmac | on-SoC | 516 / 0 | 94 B | 3/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
| Module | Items | Why it exists |
|---|---|---|
gs::ipc | 14 | Messages. This was the gap. Nothing in gs could send one, so nine of fifteen examples had no choice but the SDK |
gs::cap | 12 | acquire, duplicate, remove, self_grant - what the name always promised |
gs::file | 9 | what the module actually was |
gs::task | 7 | yield, sleep, uptime, the wall clock |
gs::resource | 4 | mint and revoke. It already existed as mod resource; - PRIVATE - which is also why the public count read 93 rather than 94 |
gs::record | 1 | a 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::traceandgs::task::clock_sourcelanded the next day, so the current surface is 149 items across 13 modules, which is whatREADME.mdpublishes.And the two numbers are not comparable, which is the more useful thing to record. This table counts
gs::recordas 1 - onepub usestatement - 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::drivertier'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 ofErrorandServiceContextand itspreludeare not counted a second time. The 13 EXCLUDEdriver, a 14th public module: itswait,delayandirqare counted separately becausebacklog/71then 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-callipc::exactmodule and its two error-type re-exports,IpcErrorandCapError).
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 andunsafestill lives only there (CLAUDE.md 18.1) - that half stands exactly as written. What changed is the other half:gsnow has adrivertier 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 wheregs::driverhas 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-MmioandDma. Correct, and the reason the SDK exists.holder- its hand-rolledresource_invokeonly. The standard library has that and does it BETTER:resource::invokealso stashes replies that are not ours, which is what makes it safe in a service that also serves clients (8.2). It ispub(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:
derive_captakes NO rights argument. It duplicates a capability that already holds GRANT, with the same rights. The wrapper wasderive(ctx, cap, rights)with a doc explaining that rights can only be dropped - describing something the syscall does not do. It isduplicatenow.Error::Denieddoes not exist; the variant isPermissionDenied.epoch_secsis onDatetime, not onServiceContext.name_stris onTaskStat. A service cannot ask its own name -ctx.name()istodo!()- 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
| Addition | Pulled in by |
|---|---|
task::sleep_quantum, sleep_us, sleep_ticks | sleep(1) everywhere; dwc2's measured split-transaction timing |
driver::wait::ticks, ticks_per_10ms; Since::elapsed_ms, elapsed_ticks | tick-domain code: key repeat, console's adaptive paint, TCP timers, benchmarks |
driver::irq::Irq::vector | xhci logs and compares its MSI vector |
call::request_once | block-driver and nic-driver, which own their retry and log whether the reacquire worked |
Error::PeerDied | the request arrived and the peer died: may have happened, reacquire before asking again |
ipc::exact | the conformance probes and chaos's flood, which must tell EndpointDead from a stale capability |
impl From<CapHandle> for Cap | handles 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 receives | a 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):
- Hardware:
Mmio,Dma,device_power,cpu_clock.gs::drivergaps (docs/driver-library.md). (Note 2026-10-22: no longer true of the first three.MmioandDmacome throughgs::driver::registersandarena(step 3) anddevice_powerthroughgs::driver::power(step 5), and the one-way ratchet counts the SDK calls.cpu_clockis thepowerservice's, not a driver's, and is still the SDK's.) - Spawning and the supervisor's name map, which flows into
spawn_with_caps. - The delegated-resource invoke (
resource_invoke+recv_abortable_deadline) in the shell'sfcapandexamples/holder.gs::resource::invokeexists and is better, butpub(crate). - Request forms with no
gsshape: the sifted and millisecond-bounded requestsnet-stackuses while it must keep client requests that arrive mid-wait, and the_intoformsfsand the supervisor use to keep a second 4 KiB frame off their stacks. Section 20's gap, from the other side. - Two drivers' wait loops (
xhci,dwc2): call sites moved, structure did not.Irq::waitis the destination, one hardware card per board. (Note 2026-10-25: reached. Since 2026-10-22 the wait ofxhci,dwc2andehciisIrq::wait,docs/driver-library.mdstep 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/rustis a crate inside this repository, depending onsdk/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 atservice_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 isKill- gated behindservice_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 anExit(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 ongs::program.)read_to_stringcannot return an allocatedString: there is no heap, deliberately (§26.6.1). The shipped shape isread_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_OKdeclared independently in four; the shell re-declares 21 of the filesystem's 27 opcodes). The honest failure model ALREADY EXISTS asDeadlineOutcome, but sits behind eightrequest_with_reply*variants whose shortest names returnOptionand 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 privateresource";resourceis public now, andfile/ipc/task/trace/recordall arrived after it was written.) 164 public items across those 13 modules, 112 of thempub 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 ofErrorandServiceContextand itspreludeare 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, theipc::exactmodule and two error-type re-exports). The 13 EXCLUDES a 14th public module,driver: thegs::drivertier (wait,delay,irq, 33 items) is counted separately becausebacklog/71puts it outside the first covered surface. (2026-10-25: four more public modules have since arrived,memory,program,appandhandover- 17 besidedriver; the item counts above are the 2026-10-09 ones and were not recounted.)capwas 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.taskSHIPPED, 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:
coreonly.allocis 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 lacksipc_send = ["fs"]gets the same handle and every call fails withUnreachable.examples/stdlib-hello's contract exists to make that visible. There is no global and noprint!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, asOk,Unreachable,BusyandOutcomeUnknown, plus one outcome it folds away: a request that ARRIVED and whose replier then died (the kernel'sReplyDead) isError::PeerDiedsince 2026-10-09 (stdlib-design.mdsection 25 - before thatgs::callre-sent it).Error::retry_is_safe()is the answer in one call, and it returnsfalseforOutcomeUnknownandPeerDiedon 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_checkgreen.
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
unsafein 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/recordermigrated (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, frombooltoResult- it used to discard the difference between a write that timed out and one that never left. The migration also DROVE the API:recorderneededcreate_sized,write_atandrename, 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'stcp(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), andsock(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 agsequivalent, in 28 crates, to zero, held there byscripts/one_way_check.py(stdlib-design.mdsection 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_safeis false forOutcomeUnknown. Host coverage stops there becausegodspeed_sdkowns thepanic_handlerand so doesstd, so a dependent crate's host test build hitsduplicate lang item; the pure/SDK split follows the patternkernel/src/clock.rsdocuments. The target-side tests exist now.osdev test fs-reuseholds ags::file::File(thengs::cap::File) across a realfskill 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 filesexercisesgs::fsacross a realchaos kill-storm fs 2and now requires a real listing rather than merely the absence of an error string - the guard it replaced passed on silence.osdev test file-capcoversgs::capend 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:: Revokedexists 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
- Inspect. 2. Design report. 3. Identify repeated boilerplate. 4. Propose minimal structure.
- 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::callmade it invisible to theIX-peer-reacquireCommandment check, which looks for the API in the service's own source - so it correctly reportedrecorderas having no recovery path. Baselining an exemption would have been the wrong answer. Insteadreacquire_apinow credits the library, AND a newIX-stdlib-delegatescheck asserts the library really does reacquire, onSendFailedand 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
fsis a different task on a possibly different core with a new endpoint generation. Clients seeEndpointDead, 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_ANYlifts 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
REBOOTlives 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 isxhcion x86 alone:ehciruns in IOMMU passthrough even where there is one, and the Pis and the VisionFive have no IOMMU at all, so theredwc2andxhcican 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.
askanswers 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:INTROSPECTfor 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 arenet-stackand the radio drivers, because no one row may hold more than six.godspeed-pmis the machine's package manager, started by the shell for onegodspeedcommand. It sends to the shell for the answer,fsfor its settings and trust roots,net-stackfor the registry's connection,timefor the date a certificate is checked against, and the supervisor, which starts and stops an installed package for it. It holds no privilege.copierruns 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 tofs, and to the board's audio driver for the "done" sound a finished job makes.chaosischaos max-carnage's loop. It cannot live in the shell, because the shell is one of its targets. It holdsSPAWN,INTROSPECT,SERVICE_CONTROLandACQUIRE_ANY, and the console's input so thatqreaches it, and it never kills itself. Its spawn-storm ammunition, and the victim ofchaos mem-pressure, ismem-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.observeis the live task view: every task's core, state, memory, restarts, queue depth and CPU use. It holdsINTROSPECTand nothing else. It runs under three rows,observe,observe-nowandobserve-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, notwrite -a. A flag is a character to memorise; a word is readable a year later. (0_conventions.md§1.) - Every utility answers
versionandhelp. 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
dir | list a directory |
cd | change current location |
pwd | print the folder you are in |
home | go to your home folder |
read | print a file's contents |
write | create, overwrite, append, or prepend a file |
mkdir | create a directory |
copy | copy a file or a whole subtree |
move | relocate a file |
rename | rename a file or directory in place |
delete | remove a file, directory, or whole subtree |
seal | freeze a file's content, permanently (no unseal) |
churn | hammer the filesystem for N seconds so a power cut lands somewhere |
find | search the tree for a name |
tree | print the directory hierarchy |
edit | the full-screen editor (a bounded piece table - any file size) |
fcap | open a file as a real kernel capability |
drives | manage attached disks |
Text and records
echo | write text |
docs | the manual: what the system is, and what you can rely on |
paginate | read long output a screenful at a time (pipe stage) |
scrollback | read back what has scrolled off the screen (also: PgUp) |
match | keep the lines that match (the grep-equivalent) |
count | how many lines, words, and bytes |
sort | order the lines |
first / last | keep the first or last N lines |
where / select / to / from | typed record pipelines |
The system
status | what is running, and where |
observe | the live full-screen view |
caps | what authority a service actually holds |
whatis | what a name is: built-in, script, pipe stage, or service |
trace | live kernel state: what is stuck, who can reach whom |
events | what was recorded: IPC ring, metrics, logs, capture to disk |
mem | memory |
cores | the cores that came up |
hardware | this machine's devices, and what drives each |
ask | plain questions about this machine, answered from what its services know |
godspeed | the native package manager: its settings, and the registry reached over verified TLS |
download | a file from a web server, saved here: https:// checked, http:// said to be unchecked |
spawncap | diagnostic: spawn a service and prove the capability to it routes |
uptime | how long since boot |
date | the wall clock |
audio | sound: what is playing, the volume, a test tone |
about | what this system is |
version | version, of anything |
clear | clear the screen |
Lifecycle
spawn | start a service |
kill | stop one |
restart | stop and start, possibly on another core |
chaos | kill things deliberately, and see what survives |
reboot | restart the machine, now or after a delay |
poweroff | not provided - and the page says why |
shutdown | turn the machine off, now or after a delay (hardware-verified on all five boards) |
background | run long work as a job, so the prompt stays usable |
Networking
net | am I on the network? |
wifi | what wireless networks are there, and join one |
sock | a UDP socket as a capability |
ping | continuous ICMP echo |
tcp | one TCP transaction: connect, send, read, close |
serve | accept one TCP connection and echo what arrives |
Scripting
run | execute a script of commands |
assert | verify a result or output (the test verb) |
result | the previous command's result (Ok / Err) |
wait | wait |
repeat | run a command again and again |
fmt | format a script to the GodspeedOS standard |
The shared contract
| Conventions | the 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
-
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. -
Every subcommand has
help.<util> <subcommand> helpdescribes 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 helpprintswifi debug's block, which namestracewith 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 helpreadhelpas a service name and refused it with the list) and nothing at depth three. The answers live in one place,sub_helpinservices/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> helphas exactly one answer whichever way the words arrive.scripts/subcmd_help_check.pyreadsSUBCMD_FIRSTandsub_helpand fails on any word Tab offers that answers nothing; it runs on every build. -
helpis the word - the only form. No flags, no synonyms. There is exactly one way to ask for help: the wordhelp. 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).-his simplyunknown:, and that response teaches the real word.help <word>anddocs <word>are not an exception to this, and the distinction is worth stating because it looks like one.<util> helpgives one command's detail and remains the only way to ask for that.help dirdoes something else: it opens the browsable list already scrolled todir, which is whatmanactually means - find this in the manual. It exists because the browser LISTS the commands, so readingdirthere and typinghelp diris 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. -
Subcommands are words, never single-letter flags.
observe now, notobserve -n. A word means the same thing across every utility; flag letters collide and drift (-n= "now" here, "number" there). This is thels -Sslahwall-of-letters problem GodspeedOS rejects. Typing economy is a shell-ergonomics concern (completion, history - future), not a reason to abbreviate vocabulary. -
Every utility has a version, reported by
<util> version. Per utility, not per subcommand - subcommands evolve with their parent and inherit its version. -
helpoutput carries the version header. Line 1 of any help/usage output is<util> <version>, so the version is always one keystroke away. -
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. -
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"), notdate unix; there is notimeclock (in Unixtimemeasures command duration). Pick words that say what the thing is in this system. -
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 toNO_PATH_CMDSin the shell (services/shell/src/main.rs, besidecomplete_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 fromNO_PATH_CMDSand keeps its file completion. So when you add a utility: if its args are paths, do nothing; if they are not, add it toNO_PATH_CMDS.versionandhelpcomplete for free - do not list them. Every utility answers<util> versionand<util> help(rules 5-6), socomplete_keywordappends them to every command's first-argument completion automatically. Do not addversion/helpto aSUBCMD_FIRSTentry (they are deduped in). A utility that takes no positional first argument (an info command:about,version,mem,uptime,status, ...) has the universalversion/helpas its only first-arg subcommands - add it toINFO_CMDS(besideNO_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/helpcome 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 - takeqandEscto leave, because nothing there is typing and a letter is free to mean something. - Typing surfaces -
edit- cannot. A bareqmust insert the letterq, so leaving and saving need a modifier:^Qand^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_codepointpasses ASCII through and maps eleven box-drawing characters; everything else becomes a literal?(deliberately - visible, never silently dropped). So\u2191and\u2193would 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.editalready matcheshelp,docs,paginateand the scrollback view key for key on all five. Somebody "fixing"editto quit onqwould 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. - Read-only views -
-
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 - andq/Q/ESC brings the prompt back within 3 s, saying truthfully what happened: stopped, still running underneath, or outcome unknown.[b] backgroundstands 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]. Socopy <file> <dst>, `deleterecursive`, `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- ags::fshandle lent.watching(ctx)(the shell'sctx.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, andqstops the command at its next request. A script, awaitandselfcheckstop on it too. (tcp,serveandsockuse ags::nethandle 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 readsq, after aq, or between commands - say so beside them (// quit-ok:), andscripts/quit_wait_check.pyrefuses any other.osdev test quitmeasures every kind of wait on anfsbuilt to answer slowly (slow-replies) and writes the table tobuild/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 - thenq/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 isServiceContext::request_with_reply_abortable(send once, pollqwhile waiting); never block an interactive command on a barerequest_with_replyto a peer that can be slow. The fs-backed interactive commands (dir/cd/read/find/treeand the file-reading filtersmatch/count/sort) use the delayed-hint variant: ags::fs::Fshandle lent a notice (Fs::with_notice/Fs::noticing), which waits throughgs::call::request_within_notice. Silent on a fast reply, it advertises[q] quitonly once the wait lingers pastgs::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,treeand the file-reading filters go throughgs::fs::Fshandles built with no notice (fs_stat_r,fs_read_at,filter_read, the walks incmd_find/cmd_tree), so they are bounded atgs::call::DEFAULT_SECSbut cannot be ended withq- 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, whichaskalso goes through) and a file-cap reply wait throughrecv_quittableinservices/shell, which prints[q] quitonce nothing has come forgs::call::NOTICE_AFTER_SECS, once per wait. The SDK gives the timing (ServiceContext::recv_abortable_deadline_noticing, the receive-side twin of the request'srequest_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 plainrequest_with_reply_abortable(fs_request,fs_raw-drives,drives flash,reset,label- andns_abortable) go throughask_with_quit_notice, and the re-waits after an overtaken reply (fs_take_tagged,ns_take_tagged) throughrecv_quittable(backlog/84S6). 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_askandaudio_ask- everywifiandaudioverb,wifi hardwareamong them, which asks each radio twice and could sit for 12 s silent - wait throughask_sifted_quittable(the SDK'srequest_with_reply_keyhint_sifted_keeping), andwifi_ask's wait for answers the driver still owes says it too;qthere printsstopped by qrather than "not answering".observe now's wait for its frame anddate syncsay[q] quitas well. Still silent, and why: the waits on a kernelCallrecorderandcopier(8 to 12 s),timeand thens_deadlinepath tonet-stack(each at its caller's deadline), and the 2 s spawn request to the supervisor. A task blocked in aCallcannot read the console, and the noticing form that can takes whatever reply lands next, which is what theCallis 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- whattcp,sockandserve's listen all go through - used a barerequest_with_reply, which parks the shell INSIDE the syscall where it cannot poll the console. On a Dell Wyse atcpthat net-stack never answered froze the prompt: no echo, noq, 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 pollingqwhile waiting returns the machine to its operator. Theeventschannel (trace_ask) had the same shape and was worse for it, sinceevents failuresis 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 throughask_sifted_quittable, say[q] quitpast 2 s and stop the wait onq. Kept as the record of why they once could not.) Recorded exception: thewifiradio power requests.wifi radio on,wifi radio off,wifi radio off hardand the power request at the start ofwifi radio powercycleblock in a kernelCallwith noq, because a shell blocked in one cannot read the console. Each is BOUNDED at 15 s (MAX_SECSinwifi_radioandwifi_radio_hard_off,OP_MAX_MSinwifi_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.mdsection 2 records this per verb.
-
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
qends the loop and the peer is only ever mid-ONE step. Worked example:net scanbegan as one batchnet-stackop (sweep all 254 hosts); pressingqunblocked the shell, but net-stack kept sweeping, so a secondnet scanhung 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. -
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 jsoncompose) or plain labelled lines (so| match/| countcompose). 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
UTILSand its three producer lists,is_producer_builtin,is_record_producerandis_pipe_producer_service). Of the shell's utilities (itsUTILSlist), 28 start a pipe (two,wifiandchurn, for some verbs only): the record sources (status,dir,find,caps,drives,observe now,uptime,events,trace,jobs, andwifi 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 therosterservice; plusrandomandtcp(made sources that day) and thegreetexample, which are not inUTILS. 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- andresult/assertrefuse 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,serveandgpioare not inUTILSand are listed with them):Not a pipe source Why 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>insteadhealth,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 -busiestISstatus | sort reverse memdocs,scrollback,edit, bareobserveFull-screen: a pipe cannot carry a screen you scroll. scrollback saveandobserve noware the capturable formsclear,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; joinalso reads a passphrase from the console, and piped its prompt would vanish. Refused with a sentence naming the reportschurn <seconds>,churn tear,churn resetActions; churn verifyis the report and pipeschaos,fcapExercisers whose value is what they prove while running; chaoswrites its report withsaveserve,gpioserveruns until stopped;gpiodrives pins - the one read it offers is a single line on the Pi 2 only(Note 2026-10-09: the count above is dated.
audioandhardwarehave since joinedUTILSand both are inis_producer_builtin, soUTILSnow 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.
UTILSholds 82 names and 34 of them start a pipe -ask,godspeed(its reports only),audio(its reports only) andhardwarejoined, andrandomandtcpare inUTILSnow, as areserve,gpio,spawncapandspawnwired. The 17 stages are unchanged; the other 31 are not sources,shutdownanddownloadamong the actions.) -
If it does not fit the common pipes,
writestill captures it. Any producer's output snapshots to a file with| write <path>(redirection is| write; there is no>, see19_write.md). So even a utility that is not a record source is never trapped on screen - its bytes always have somewhere to go. -
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 keywordall-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.
observeis 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). See1_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.
| Site | What it gives you | What fails if you miss it |
|---|---|---|
SUBCMD_FIRST | tab completion of the first argument | rule 9; Tab falls through to a directory listing |
SUBCMD_SECOND | completion one level deeper, where the surface has one | rule 9, silently |
NO_PATH_CMDS | Tab offers keywords, not paths, where no argument is a path | a path menu for a keyword position |
UTILS | the <util> help / <util> version intercept | rules 1 and 5 |
| the command dispatch | the verb runs at all | nothing runs |
| the producer dispatch | <util> | write <path> captures it | rules 12 and 13 |
a help_block arm | <util> help prints something | caught by util_help_coverage_problems |
a Row in the help listing | anybody can DISCOVER the verb | caught 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.mdis 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
| Command | Meaning |
|---|---|
observe | Continuous live-refreshing view. Repaints in place until you press q. |
observe now | Static one-shot frame. Prints once, returns to gsh>. |
observe help | Usage for the utility (modes, subcommands, version header). |
observe now help | Usage for the static subcommand (one row: print a single metrics frame and return). |
observe version | Prints 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, bareobserveprintslive 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
| Column | Source (TaskStat) | Notes |
|---|---|---|
| TASK | the slot index 0..223 | scheduler slot, stable for task lifetime |
| NAME | name / name_len | 15 wide; a longer name is cut and marked + |
| CORE | core | pinned core (§9.1), shown C<n> |
| STATE | state | Ready / Running / BlockRecv / BlockSend / Dead |
| MEM_USED/LIMIT/% | mem_used / mem_limit | KiB below 1 MiB, else MiB; % of limit |
| RESTARTS | restart_count | deaths recovered (§7.5, §14.2) |
| QUEUE | queue_depth | inbound IPC queue depth, 0-16, shown as N/16; ! when full |
| CPU% | run_ticks delta | this task's share of elapsed time; 0 with no baseline (one-shot) |
| UPTIME | uptime_secs | since 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
| Field | Source | Notes |
|---|---|---|
| live | count of valid slots | in the system state (N live) rule |
| UPTIME | gs::task::uptime_secs | wall clock since boot |
| CPU: CN% | inspect_core_active_ticks(N) delta over elapsed time | live view only; a one-shot has no baseline and says so (see §5.3) |
| RAM used / total | inspect_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, andgs::driver::wait::ticks/ticks_per_10msfor 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/restartauthority; it is one of the most dangerous userspace authorities.observeneeds 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,observeholds 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
observeis the shell doing its job; beingobserveis the shell stepping out of its role. - Isolation & restartability (§2.4, §3.6):
observecrashing cannot take down the shell or its authority. Matches the utilities-as-services vision (Appendix D) and reuses the existingobserveservice 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
INTROSPECTin the privilege word of theobserve,observe-nowandobserve-liverows, 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 theinspect_*/task_statsurface; log_writeto render its frame (printing islog_write, notconsole_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 forqand 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 thehealthlibrary script answer is-everything-OK;statusis the task list (7_status.md). - Filtering/sorting/search (top-style
P/Msort keys). Not v1. - Per-task drill-down (cap list, held resources). A later
observe <name>subcommand could add this; not now. - Historical/graphing.
observeis 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
- Live-view refresh cadence: fixed 1 s, or
observe <interval>to tune it later? (Deferred -nowvs bare covers the static/live split; interval tuning is a possible future addition, not v1.)
Resolved
- Help form (2026-06-03): the word
helpis the only form - no-h, no--help, no tolerated synonyms. An undocumented synonym would be a hidden, unsaid rule, which the system forbids;-his simplyunknown: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
- Console: clear+home control - only needed for the live view; static does not need it. (Gates step 3, not step 2.)
observe nowstatic frame - as the brokered standalone service (§7), §4. The shell spawnsobserveinnowmode; it prints one frame via its introspection + console caps and exits. Wireobserve now,observe version,observe help,observe now help, and bareobserve→ "live view coming soon- try 'observe now'". QEMU-verify (screendump).
observelive 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
| Command | Meaning |
|---|---|
date | Full timestamp with weekday and its source, e.g. Sat 2026-06-06 23:45:08 (rtc, scale unknown). |
date epoch | Seconds since 1970-01-01, the number only, e.g. 1780789511. |
date sync | Ask 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
timeservice, over IPC:OP_NOWreturns the epoch seconds, the source, the sync age, and whether the NTP setting is refused (byte 18);OP_SYNC(fordate sync) asks it to query the network. Each request is bounded at 2 s (time_rpc). timeitself reads the RTC throughInspectKernelquery 11 (packed date/time, ungated - wall-clock time is task-neutral hardware info, like the TSC clock, query 3).- SDK:
Datetime::from_epoch_secsandDatetime::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 bydateitself. log_writeto print the line (printing islog_write, notconsole_push).
6. Non-goals (deliberate - §26.2 minimal surface)
- No clock-setting.
datereads; 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 epochis 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
| Command | Meaning |
|---|---|
mem | Print 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 (InspectKernel4/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) andobserve.memis 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
| Command | Meaning |
|---|---|
cores | Print the ready core count and return. |
cores ticks | Sample 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.coresis 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
| Command | Meaning |
|---|---|
about | Print 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.
aboutis an identity card, not a build manifest. Per-utility versions are reported by each utility's ownversion(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
| Command | Meaning |
|---|---|
caps | List 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.
capsreads 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.
statusis a raw task list, not a health verdict. "Is everything OK?" is answered byask(60_ask.md) and thehealthlibrary script, so the two stay distinct (raw facts vs verdict).
2. Invocation
| Command | Meaning |
|---|---|
status | Print 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_statdiscloses 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
| Command | Meaning |
|---|---|
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.
echoreads 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.$nameexpansion is done by the shell beforeechoruns, not byechoitself; the shell refuses to echo a variable holding asecretvalue (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
| Command | Meaning |
|---|---|
clear | Clear 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;
clearerases in place. (This is the same reasonobserveleaves 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, soclearneither 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) andrestart(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
| Command | Meaning |
|---|---|
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 printsspawn failed (unknown service?): <name>and is anErr, never a silent no-op (invariant 12); success printsspawned: <name>. - Singleton guard: spawning a service that is already live is refused by the
shell (
already running: <name>) - there is one instance per name. supervisoris refused (Not applicable. The supervisor is the restart authority ...,Err(Denied)), and so are theobservevariants, which run from theobservecommand 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 isIMAGE_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
spawnstarts 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) andrestart(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
| Command | Meaning |
|---|---|
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-services | Stop 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_controlcapability required.kill(syscall 8) validates theSERVICE_CONTROLresource before doing anything; without it the call returnsCapNotHeld. 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,controland the test probes. Seedocs/service-control-cap.md. -
Results are one line each:
killed: <name>,not running: <name>(anErr), orkill failed: <name>(anErr). Theobservevariants are refused with a pointer to theobservecommand. -
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).fsandblock-driverare freely killable and the chaos suite kills them by the thousand; killing thesupervisoris answered by the KERNEL respawning it, which the shell says out loud.initandregistrydo not exist to protect -initwas removed in Phase 5 and theregistryservice retired in Phase 4. -
shellis 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).xhciandehciare 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 / fsand a shell guard refusingxhci / ehci / shell. Neither exists.CORE_SERVICESholds one name -"supervisor"- and the guard readsis_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.
killis 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) andkill(11_kill.md).restart=killthenspawn.
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
| Command | Meaning |
|---|---|
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
PlacementInvalidif 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'splacement_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
restartis the SDK'sServiceContext::restart: akill, then a spawn through the supervisor. Akillthat finds nothing to kill is tolerated, so a service that was not running is simply spawned; any other refusal of thekillends the restart as a failure. Results printrestarted: <name>orrestart failed: <name>(anErr). - Same guards as
kill(it is the kill half): requiresSERVICE_CONTROL. There is NO trusted-root refusal, and the shell refuses onlysupervisor,shelland theobservevariants -xhciandehcipass straight through. See11_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/CapRevokedon its next call and must reacquire by name through the kernel name directory (AcquireSendCap) - theregistryservice was retired in naming Phase 4. The kernel does not rebind for it.
4. Capabilities
SERVICE_CONTROL(WRITE) for the kill half andSPAWN(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).
restartdoes 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
| Command | Meaning |
|---|---|
reboot | Reboot in 3 s, counting down. |
reboot <delay> | Reboot after <delay>: 5, 5s, 5m, 5h; a bare number is seconds; at most 24h. |
reboot now | Print rebooting... and reset at once: no countdown, no tidy-up. |
reboot status | Whether one is pending, and how long is left. |
reboot cancel | Stop 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, ofpower, 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 statusandshutdown cancelname a pending reboot rather than pretending there is nothing. - Earliest wins. A second
rebootfor 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] quitand[b] background.qcancels the reboot (rule 11);bgives 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 andfsflushes and stops taking writes. Then the kernel resets the machine (reboot: the pending reboot fell due). jobslists a pending reboot above the jobs, and in its last minutebackgroundrefuses to start a job.reboot nowprints a finalrebooting...line, then invokes theRebootsyscall (18). Nothing goes between the command and the reset. A refused reboot (a caller without theREBOOTcapability) 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 theRebootsyscall (18) and the reboot operations ofPowerOff(56) to anyone without it.- Console output, the keys for the countdown, and a send to
powerto cue it - a cue that carries no authority, sincepoweronly 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;
rebootdoes not negotiate with it.) - No clock time (
reboot at 03:00), for the reasondocs/shutdown.md7 gives forshutdown.
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.
rebootis 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.poweroffactually 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 the0xCF9reset control register first (the Goldmont+ Wyse has no 8042, so0x64 <- 0xFEalone 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:
- The
SLP_TYPavalue lives in the DSDT's\_S5object (we can byte-scan it). - The machine must be in ACPI mode (
SCI_EN=1) - an SMI handshake. - 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
\_S5parse 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_ENwent0 → 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
poweroffthat 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.
shutdownsets the machine's one pending power-off in the kernel (PowerOff, syscall 56), so it survives the death of this shell, ofpower, 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
shutdownfor 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 pendingshutdown <delay>is refused and names it, andshutdown statusandshutdown cancelsay 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] quitand[b] background.qcancels the shutdown (rule 11).bgives 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 restartedpowerreads the kernel's deadline and carries on. jobslists a pending shutdown above the jobs.- In the last minute no new job starts:
backgroundrefuses and names the shutdown. shutdown nowwarns 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 offwhere the board cannot cut its own power (docs/shutdown.md5).
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, besideREBOOT: ending the session is the operator's. A second holder - a power button - is a decision recorded inCOMMANDMENTS.baseline.toml, never a door.- Console output, the keys for the countdown, and a send to
powerto cue it - a cue that carries no authority, sincepoweronly re-reads the kernel's deadline. It also says whethercopieris 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
| Command | Meaning |
|---|---|
background copy <src> <dst> | start the copy detached; print its job id; return to the prompt |
background delete <path> recursive | remove a whole subtree detached |
background drives check | check the volume detached; foreground replays the verdict |
background drives scrub | read-only CRC sweep, detached |
background churn <seconds> | sustained write traffic, detached - the prompt stays yours |
jobs | the 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 attached | stop the job, not just the view (conventions rule 11) |
b, while attached | detach 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:
| command | detached? | why |
|---|---|---|
copy <src> <dst> | yes | the effect is the file; progress is a count, not a screen |
delete <path> recursive | yes | fs does the whole walk in ONE operation, so the job is one request and a wait |
drives check | yes | one fs request, and its report lives in the transcript (§4a) until asked for |
drives scrub | yes | the same shape as check - one request, one verdict - which is what made it nearly free |
churn <seconds> | yes | the 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, run | no, permanently | not 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 |
find | not yet | the shell walks directories itself, so the service would need that walk. The transcript already solves its other half |
copy <src> <dst> recursive | no | an interrupted WALK leaves a prefix of a tree - a different permitted-outcome question, and one nothing here answers |
delete <path> (no recursive) | no | one 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:
| state | means |
|---|---|
running | in progress |
done | the job finished - for a copy, the whole file was copied |
failed | it stopped on an error; the reason is printed with the row |
stopped | q cancelled it |
lost | the 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
backgroundis 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 copywhile 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 borrowingbg/fgwould borrow a model this system does not implement. - No nesting.
background background xis refused, not defined. - No job control over services the shell did not start.
killandrestartaddress 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
runorselfcheck. Not an output problem: a.gshscript 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,killandrestartmanage it.backgroundexists only to give shell BUILT-INS, which have no service of their own, somewhere to run. The real gap is thatspawntakes 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 -fsperforms 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
fsrequest; 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 flashmakes 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 installmakes 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 than0is refused (drives: no drive N - only drive 0 is attached), and a word selector must be drive 0's own label, read fromfs:drives flash typo dataanswersdrives: 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:
| Default | Verb | Layer | Means |
|---|---|---|---|
| Boot default | drives godspeed default <drive> | boot | which 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
| Command | Effect | Persists? | Step |
|---|---|---|---|
drives | list 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 label | data: yes | 3 |
drives label <drive> <name> | name / rename a drive - rewrites the superblock (duplicates allowed, §3) | data: yes | 3 |
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 path | data: yes | 3 |
drives check [drive] | verify (fsck): rebuild the bitmap/free count, report CRC failures | data: yes | 3 |
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) | yes | 6 |
drives godspeed update <drive> | A/B kernel update of an installed drive | yes | 6 |
drives godspeed default <drive> | which installed GodspeedOS the machine boots | yes | 6 |
drives godspeed | list installed GodspeedOS drives / help | - | 6 |
drives version | print the version | - | 3 |
drives help | print 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, anddrivesships 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:
fsis the enforcing authority, not the shell. ASENDcap tofsis not a dangerous capability - it can only askfs, which validates every request. Adding it to the shell creates no new dangerous combination; the shell'sspawn/kill/restartcaps are unchanged. (Contrastobserve, 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 +fscaps + a way to receive its arguments - ten services where one narrow send-cap on the shell suffices. The least-authority win is illusory oncefsis 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
filesbroker the shell forwards command lines to - not one service per verb. Not needed now (fsis 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)
- Hierarchical GSFS - done (inodes + directories + path walking;
docs/persistence.md). drivesdata primitives (step 3, this cut):flash/label/ list, on the single attached drive - proves the format-over-IPC + raw-tolerant-fsloop, and lets the OS format its own SSD (which unblocks on-hardware persistence verification). Needs:block-drivercapacity request (IDENTIFY sectors → size the filesystem);fsraw-tolerant (serve the drives API even with no filesystem) + in-OSformat().- File commands -
dir/read/write/cd/mkdiron the current drive;cdis the single current-location pointer (drive + dir). - Multi-drive - enumerate all SATA disks; per-drive block IPC;
cd [index:]label/pathcross-drive addressing read on demand; duplicate labels disambiguated by index; (deferred) a persistent working-location default. - 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).drivesis being built spec-first against the conventions, so it implements its ownhelp/versionand per-subcommand help from the start (likeobserve, 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 calleddir raw, which fails the same testlsfailed: 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 ondir.)
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)
cdwith no argument is done: it goes home,/users/default/(changed 2026-10-13 from/, with the layout,docs/filesystem-layout.md6). There is no~: in a script, home is the reserved word$home, which cannot collide with a folder namedhome. At the prompt$homeis not expanded - variables are a script feature, and a typed line is not a script - socdalone is the way home there...goes up one folder, incdand in every path (read ../notes.txt). It is kept as it is, not renamed (cd upwas considered, 2026-10-13): Unix, DOS and Windows all spell it.., so it is already known, and a word likeupcould be the name of a real folder where..cannot. Tab-completion of path components is done too (complete_path,0_conventions.mdrule 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
| Command | Meaning |
|---|---|
pwd | Print the current folder's whole path. |
pwd | write here.txt | It 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
| Command | Meaning |
|---|---|
home | Go 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), butfssets 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 justwrite <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/prependlead instead of trailing. Every other modifier sits at the end (mkdir <path> parents,delete <path> recursive).writecan'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 - sowrite appendix.txt …is still a plain write to a file namedappendix.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, per0_conventions.md§4);fswalks the path component by component, creating what's missing. Idempotent; errors only if a component is in the way as a file. Plainmkdir <path>stays strict (parent must exist) -parentsis 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), andread <src> | write <dst>already copies through one; acopypipe 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 happened | what you see |
|---|---|
| the source does not exist | move: failed - source not found |
| the destination already exists | move: failed - dest exists |
| the destination's parent does not exist | move: failed - dest path not found |
| the destination's parent is a file | move: failed - dest not a directory |
| moving a directory into itself or its subtree | move: cannot move into itself (the shell's own check, before the round trip) |
| storage is not available | storage 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, mirroringflash's destructive-confirm (15_drives.md), if wanted. delete+moveare 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 reportmatchesreport.txt,2026-report, etc.;find .txtlists every.txt. The friendly default. - Contains
*or?→ glob (anchored to the whole name):*matches any run of characters (including none),?matches exactly one.find *.txtmatches names ending in.txt;find f?matchesf1…f9but notf10.
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 reportmatchesreport.txt). Glob patterns - done (find *.txt,find f?): a pattern with*/?switches to anchored glob matching, no-nameflag - 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 - theconsoleservice 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). Seeservices/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
diralready 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 mirrorsfind <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 acrossfindandmatch, no new rules. (Shares thecontains/glob_matchhelpers.) - The inverse is
except, a leading keyword.match except errorkeeps every line that does not match - no crypticgrep -v.exceptleads the pattern because trailing would read wrong ("match error except…?"); same shape aswrite 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,matchkeeps 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 explicitregexword so a layperson never trips over^$.*; only someone who asks for regex gets it. Default stays friendly (substring + glob). Needs ano_stdregex engine, so it is a clear future opt-in, not the first cut.- Case-insensitive matching (an
ignore-casekeyword), 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).
firststreams the first N lines in one pass - no buffer, any N.lastkeeps the most recentTAKE_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, seedocs/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
followmode forlast(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.
sortis documented in29_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);
fromis the explicit, symmetric partner ofto. 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 (chunkedencode). The SDK record API and the bounded wire codec (encode/decode, emphatically not JSON on the wire,docs/records.md) are done -examples/rosteris a record-producing service (roster | where role=core, nofrom 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):
Okis 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. Unknownis the catch-all. A failure not yet given its own variant isErr(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
resultreports the immediately preceding command. It is itself a command that succeeds, so runningresulttwice shows the real result, thenOk(the firstresult'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 formmatch/count/sort/first/last:Okon success,Err(FileNotFound)for a missing path,Err(Unknown)otherwise. - Service-control -
spawn/kill/restart:Err(Denied)for a protected core / session-critical service (soassert fails spawn supervisorholds),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→Erron no disk, a bad selector, an unknown subcommand, or a declined[y/N].- Pipelines -
pipe_runreturns the pipeline'sResult(a| assertsink sets it; a stage error isErr). - An unknown command is
Err(soassert fails typoholds; a typo in a script counts). - The info commands (
echo/about/mem/cores/date/status/clear/help) genuinely can't fail, so they returnOk- uniformly on the model, not specially wrapped. (A reboot that happens never returns;reboot cancel,reboot 10mand the other deadline forms returnOkorErrlike any command.) The exception isdate sync, which returnsErr(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, aggregatingresultinto "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, orcmd ; cmd ; cmdon one line. On-device,edit(36_edit.md) writes real multi-line scripts;osdev script-diskbakes 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.
.gshis a naming convention (GodspeedOS shell;.gsis reserved for the future general-purpose Godspeed language), not a mechanism -rundoes not care about the extension (extension-driven behaviour is the hidden magic §26.5 forbids; cf.readnot 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
fsfile, streamed in and minified as it loads (comments, blank lines and indentation stripped), soSCRIPT_MAX(7112 bytes = 2 xIO_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
runinside a script is refused (runat depth > 0). This is a hard rule, not a nicety: unboundedrun-calls-runrecursion would overflow the bounded user stack (execute/pipe_runare#[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>andErr(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);ddit to the data drive andrun /suite.gshon hardware, no on-device authoring.osdev test scriptproves 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 returnedOk.assert fails <command>- holds iff it returnedErr(any failure).assert fails-with <Variant> <command>- holds iff it returnedErr(<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.
| Check | Holds when |
|---|---|
contains <text> | the output contains <text> |
lacks <text> | the output does not contain <text> |
empty | the 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-devicewrite(the shell pipes thewriteline 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.gshproduces a GSFS data disk with the suite baked in;ddit to the data drive, boot, andrun /my_suite.gsh.osdev test scriptruns 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 withwrite … cmd ; cmd.
5. Bounds & safety (§26.6 / §3.12)
- A failed assert is loud (
assert: FAILED (contains 'Matthew')) and returnsErr(AssertFailed)- never a silent miss.
- The content sink materialises the stream into one bounded
Cap(held offpipe_run's frame -assert_streamis#[inline(never)], the same user-stack discipline pipes/runneed). - The result form runs the command one nesting level deeper (
executeis#[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 thanUnknown.- 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
| Command | Meaning |
|---|---|
fcap | Run the self-check against an internal throwaway file. |
fcap help | Print usage. |
fcap version | Print the version (uniform across utilities). |
fcap reuse | Mint 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 gsreuse | The 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:
| Check | Property pinned |
|---|---|
| read / write through the cap | A 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 kernel | Non-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 badge | Non-escalation at the fs layer - op ≤ right; even a correctly-validated read cap can't smuggle a write (FS_DENIED). |
| fabricated handle rejected | Unforgeable - only the kernel mints valid caps; a made-up handle is not one. |
| cap stale after close | Revocable - closing the file revokes the resource (generation bump); the next use is stale. |
| cap stale after rename | Revocable 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 denied | An 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::cap | The 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
SENDcap tofs(the shell already holds it forread/write/drives) -fcapmints and invokes file caps throughfs;fsenforces all disk authority. RESOURCE_MINTis not held by the shell - onlyfsmints delegated resource caps (§3.1, §7.10).fcapreceives the minted file cap fromfson open; it cannot mint its own.- Console output to print the per-check lines.
6. Non-goals
- Not a file editor or viewer.
fcapnever reads, writes, or deletes a file of yours - useread/write/deletefor that. It only touches its own throwaway file. - No options or paths. No flags (§
0_conventions.md); the self-check takes no argument, andreuse/gsreuseare 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:
sealasserts 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.
sealcommits thero_compatbit 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:
-
fswill not mint a writable capability to a sealed file. Refused atopen, 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. -
Every write path refuses:
write, the streamingwrite_at, the journaled variant, andwrite_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
Entryeach write route walks to, so a new route "cannot forget to ask". Butwrite_newdoes 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.copyiswrite_newfollowed by streamingwrite_at, each in its own transaction, sowrite_newtruncated and committed beforewrite_atrefused - the command reported failure and the file was already empty. Found by adding seal coverage toselfcheck, and pinned byosdev test fs-time, which fails on exactly that check if the guard is removed. -
The volume records a
ro_compatfeature 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
| Command | Meaning |
|---|---|
edit <path> | Open <path> for editing. A missing file starts empty and is created on first save. |
edit help | Print usage. |
edit version | Print 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 & continueprompt. 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
| Key | Action |
|---|---|
| printable characters | insert at the cursor |
| Enter | split the line (insert a newline) |
| Backspace | delete the character before the cursor |
| Delete | delete the character at the cursor |
| Tab | insert 4 spaces (a fixed soft tab, EDIT_TAB) |
| ← → | move by one character |
| ↑ ↓ | move by one line (keeps the column where it can) |
| Home / End | jump to start / end of the line |
| PageUp / PageDown | move up / down a screen |
| Ctrl-S | save to the file (creating it if new) |
| Ctrl-Q / Esc | quit - 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/nanomodal commands or:wq; the two keys you need are on the bar (§0_conventions.mdrule 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
| Command | Meaning |
|---|---|
uptime | Render a one-row grid: uptime (Nd HH:MM:SS) + seconds. |
uptime | to json / to yaml | The same row rendered as JSON / YAML. |
uptime | select seconds | Just the total-seconds column (records pipeline). |
uptime help / uptime version | Self-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()→InspectKernelquery 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; andboot_datetime()→InspectKernelquery 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
INTROSPECTcap required. - Console output to print the grid.
6. Non-goals
- No load averages. Unix
uptimealso prints 1/5/15-minute load; this OS has no such metric (per-core CPU% lives inobserve).uptimeis elapsed time only (§26.2). - No wall-clock date. That is
date(the hardware RTC).uptimeis 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
| Command | Meaning |
|---|---|
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 version | Self-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:
| Target | Recovered by |
|---|---|
supervisor | the 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-driver | the supervisor (Phase D, §6.1) - re-inits the controller on respawn. |
fs | the supervisor (Phase D) - re-mounts to a consistent state via its crash-consistency journal (docs/persistence.md §6.8). |
xhci / ehci / dwc2 | the supervisor - a fresh driver is granted its device again and re-enumerates. |
events, nic-driver, net-stack, time, control | the 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}).chaoscan 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) - sofs,events,block-driver, evensupervisorare 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 (noEndpointDead) 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
AcquireSendCapwas ungated. It is gated now: the kernel mints a SEND cap by name only to a caller holdingACQUIRE_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 holdsACQUIRE_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 arechaositself and themem-pressuretasks 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 momentmax-carnagekills 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 30killed the supervisor 6× and every service was alive again at the end (observe: xhci/ehci/events allReady, 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,xhciandehciare watched now - every service in the supervisor'sMANAGEDset 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-stormclamps rounds to1..=100; its per-round generation detail lives in fixed stack arrays (no heap).max-carnageis uncapped - its per-service aggregate is constant-size for any count, so there is nothing to bound but the loop counter;qaborts.- Each kill and each recovery is logged; a
FAILverdict 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), andchaos 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 fsstorms + the directory-reacquire regression (a client reacquiresfsby 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 (observeshowedRESTARTS 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)
- Indent 4 spaces per block depth. No tabs.
- Braces, K&R.
header {on one line, body indented,}at the block's own indent.} else {and} else if <cond> {stay on one line. - One statement per line. A
;statement separator becomes a newline (the biggest de-jarring win).cmd ; cmd ; cmdbecomes three lines. - One space between tokens. Runs of whitespace between tokens collapse to a single space; leading/trailing whitespace per line is stripped.
#comments keep one space after the#; a full-line comment sits at the current indent, a trailing comment is one space after the statement.- Blank lines collapse (2+ become 1); no leading or trailing blank lines; the file ends in exactly one newline.
- Strings are sacred. Content inside
'…'/"…"is never touched - not its spacing, not its case, nothing. - Idempotent.
fmtof already-formatted text is a no-op.fmt(fmt(x)) == fmt(x)is an acceptance test - met, proven from 8 KB to 10 MB byosdev test fmt-idem(format a chunk-spanning file twice and diff = IDENTICAL).
Scope note (honest).
fmtcanonicalizes 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. ReturnsErr(via theResultmodel,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
fmtprints 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.
fmtnever 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 changesfmtwould make without writing (needs a bounded diff).fmt <dir>- format every.gshunder a directory in one call.- Host
osdev fmt+ VS Code format-on-save. The single definition of "GodspeedOS standard" is factored into ano_stdcore crate so the shell built-in and a hostosdev fmtshare 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
| Command | Meaning |
|---|---|
net | Print 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 stats | Dump 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 scan | ARP-sweep the local /24 and list the hosts that answer. |
net renew | Re-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 lease | One word for scripts: is there a DHCP lease (ok when there is no link to lease on). |
net version | Print the version. |
net help | Print 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 PCIwith its register window (the VisionFive's dwmac), orno PCI network cardwhere the kernel granted none (the Pis, whose driver reaches its controller another way). - link - which link is up:
up via the cableordown - no cableon 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)ordown - 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;TIMEOUThere 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/RxBcastclimb between twonets, the receiver is alive; if they stay flat, the NIC is not receiving. - ip - the address
net-stackholds (learned by DHCP, or the fallback if there was no offer). - gateway - the gateway IP and the MAC ARP resolved for it, or
unresolvedif ARP got no answer. - ping -
okif the gateway answered an ICMP echo;nootherwise. - lease -
ok (DHCP)when the address was granted,NONEwhen the link is up but the stack is on its fallback address (the one genuinely wrong case), andn/awhen there is no link to lease on.net leaseanswers the same question in one word for scripts, and saysokfor no link. - dns - the DNS server
net-stackwill 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.txtsnapshots the status to disk (redirection is| write; there is no>, see19_write.md). - to a filter -
net | match gatewaykeeps just the gateway line;net | countcounts the lines. - from -
netreads from thenet-stackservice (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 intonet; piping into it is a loudunknown-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)]), andnet renew(byte 0 = 8 -> re-runs the boot dancerun_dancein place and replies the fresh status).net scanreuses 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 onnet 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-driveranswers two diagnostic queries directly (the shell holdsACQUIRE_ANYand 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 fornet stats. - Shell: the
netbuilt-in acquiresnet-stack(andnic-driver) by name (the kernel name directory, §14.3), sends each query bounded (net_query, abortable withq), 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 acquirenic-driverby name for the two diagnostic queries.- A SEND cap to
net-stack, acquired by name - the same brokering the file commands use to reachfs.netgains no network authority of its own; it can only asknet-stack, which is the sole holder of the frame interface tonic-driver.
7. Non-goals (deliberate - §26.2 minimal surface)
- No configuration.
netreads; it never sets an IP, route, or DNS server. Those arenet-stack's job, and a futurenet 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 anetsubcommand -netreports 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).netstays 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 underscanning [q] quit [b] backgroundand the header; 4 networks in 3 s; the picker took a number, echoed the row, and joined through the same path asconnect.qmid-sweep andbwere not exercised on this boot. -
wifi list- verified, withsavedin NOTE for the network whose key was held. -
wifi status,wifi info- verified in the not-associated state (network none, the scan facts, theaddressingline). 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,transportalone were not typed; their rows appeared under baredebug. -
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 secondconnectof the same name asked nothing and joined with the held key.wifi storednamed it. -
wifi leave,wifi radio on|off,wifi forget- built as sections 2 and 6 say;radio offandontyped 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 inhelp. -
Absence is told apart from a wedge (section 5): no live radio service (
wifi-driverorwifi-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,
pingover the radio (36 of 38, the two lost being the switch itself, then 0 lost); cable in, the address change, a new lease,pingover the cable; out again, over the radio with no newwifi join; twice round.netsaidlink up via wifi (the cable is out)andlink 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.md41).
Things this file specifies that are NOT met yet, said here rather than discovered:
-
Both rekeys are answered (built 2026-09-30,
docs/wifi.md42) 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 andwifi joinrecovers the link (backlog/64). -
The WPA2 handshake joins - hardware-verified 2026-09-29, 20:01 (
joinedfirst try,already joinedtwice, 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.md40). 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 throughwsec_key- every step from OpenBSD's net80211 and bwfm, named at the step injoin.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 thenjoined <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.md40); 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
ASSOCfor 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, andctrl::subframeswalks by it. The boot at 15:58 showedjoin event 16 (LINK) flags 0x0001andASSOCIATEDfor the first time in this port's life,rx_glom_sub 40against 24 superframes, and nodoes not validateline; the trace showsRX GLOM len=384carryingASSOCandLINK, thenRX GLOM len=256carryingJOINandSET_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^Qends the entry, zeroes what was typed, and printspassphrase entry abandoned - nothing was sent. -
The BSSID and band in the
joinedsentence (section 4b) are not yet read from the association event; the sentence names the network only.wifi inforeads 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 statusorwifi inforeading 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 watchLINKevents 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 saysjoining the network last joined, after the access point dropped it. One attempt per drop: if it fails,wifi joinbrings 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-usbreports 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 joinbrings 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
| Subcommand | What it does |
|---|---|
wifi | with no args: usage, as every utility (0_conventions.md rule 1). It used to alias status, a second way to say one thing |
wifi scan | ask 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 list | print 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 status | the human answer: radio, network and band, signal, security, time joined, last scan. Read live. Section 4f |
wifi info | the 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 stored | the networks a key is held for, one per line. Names, never secrets. Sixty-four at most - section 6 |
wifi radio on / wifi radio off | the 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 hard | the 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 powercycle | the 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 hardware | BUILT (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 help | usage, with one real example per row |
wifi version | version 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 cache | What wifi list prints |
|---|---|
| a completed scan | its records, as above |
| no scan since boot | no scan yet - run wifi scan (an error, not an empty list) |
| a scan is running | scanning - 9 heard so far; wifi list when it finishes (an error, not the OLD cache) |
| the radio is down | one 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.
4f. Three views of one link: status, info, debug
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.
| View | What it prints |
|---|---|
wifi debug stats | the 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 transport | the 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 events | how many of each firmware event this driver names has arrived, and the last one's code and status |
wifi debug firmware | the 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 trace | the 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":
| Situation | What 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 answering | wifi: 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 start | wifi: 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 bus | 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 |
| Driver running, bring-up stopped early | 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 |
| 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 hard | radio off (hard - the chip is powered down; wifi radio on powers it up) |
Radio switched off by wifi radio off | radio off (soft - the firmware's switch; the chip stays powered; wifi radio on turns it back on) |
| SSID not found in a scan | not joined - no network named <ssid> in range - naming what was searched for |
| Wrong passphrase | not joined - incorrect passphrase - never "connection failed", which hides it |
| Associated, no lease | association 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 endsnot joinedkeeps nothing, whatever the reason, and the nextwifi 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 bywifi forgetbefore 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, aforget); wherefsis 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 awifi joinprints; if that network is out of range or refuses,not joinedand the prompt is yours.wifi radio onafterradio offdoes 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 storedprints 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.
10. Which link carries the frames: the cable always wins
Decided by the operator on 2026-09-29, after the first joined boot: "cable always wins. unplug the cable, switch to wifi automatically."
wifi joinmakes 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 leaveandradio offtake the radio out of that choice; with the cable out too, the link is down andnetsays so.netnames the carrier:link up via the cable,link up via wifi (the cable is out), orlink down - no cable, and the radio is not joined.wifi statussays 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.md41). Apingin 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.md2) - one comparison,Carrierinnic-driver'sradio.rs, shared by every backend that bridges to a radio.net-stacknever learns there are two links, and thewifiutility 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):
onboardfor the board's own radio.usbfor 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, inbus, 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 onenic-driver's bridge talks to (section 10), so a choice held by the shell alone would letwifi statusdescribe one radio while the frames went through the other.usetellsnic-driver; the shell asksnic-driverwhich radio is in use and follows its answer. One fact, one owner. - The cable still always wins (section 10).
usechooses 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
usbwhen there are two dongles:there are two USB radios - usb-1a0d or usb-77e2.
- a name that is not in the report:
- 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-driverholds the choice, sonic-driverkeeps it, in/system/shared/wifi/radio, aswifi-driverkeeps/system/shared/wifi/keys, and reads it back at boot and on every respawn. That givesnic-driverfsas a peer, a grant pinned with this reason. The shell saving it and tellingnic-driverat 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 plainusbbecause 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 hardwaresays 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.
- The dongle is already up before anyone types
use:wifi-usbruns 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).useon a radio still coming up waits for it, bounded, and says so. use usbrecords the choice innic-driverand/system/shared/wifi/radio.nic-driverasks 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.- Only once the chosen radio reports JOINED do the frames move to it - so a join that fails loses
nothing:
usesays why, and the other radio keeps the link. - 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).
- The other radio then leaves the network and idles, powered, so
useback 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/keysat boot; with two, both would join the same access point. A radio auto-joins only whennic-driversays it is the one in use. /system/shared/wifi/keysgets 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-driverasks 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 tonic-driver, but the choice decides which radio thewifiverbs address as well, on boards wherenic-driverbridges 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/radioat 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 onuse. The shell asks the radios the same op to find the one in use; with one running, it asks nothing. nic-driverholds no choice and needs nofs. 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 gainwifi-usb, the second radio. A respawnednic-driverfinds the choice again the same way.- The join is the shell's, before anything moves.
useasks 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 saysJOINEDis 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/radiosays is not the one skips the boot rejoin, with a line saying why. With no file, both rejoin as before. /system/shared/wifi/keysis merged on save (keyfile::save): the file's entries this radio's table lacks are kept after its own. Awifi forgetpasses 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 ofusbwith the dongle unplugged is kept in the file and the onboard radio carries the link, butwifi hardwaredoes 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, fromusbfn::INFO_WHERE_AT;xhciandehcifill them in,dwc2not 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
| Verb | Kind | What it does |
|---|---|---|
audio | help | usage (rule 1). Never an alias for status |
audio status | report, pipes | on or off, volume, muted, output, what is playing and how far through, underruns |
audio info | report, pipes | the 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 outputs | report, pipes as records | BUILT: 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> | action | BUILT: 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 hardware | report, pipes as records | BUILT 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, pipes | BUILT 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> | action | set the volume. Reading it is audio status - one way to ask (rule 3) |
audio mute | action | silence the output, keeping the volume |
audio unmute | action | restore the volume set before mute |
audio on | action | bring the codec back to full power, then re-apply the volume and the mute |
audio off | action | stop anything playing and put the codec in its lowest power state; the controller stays up |
audio off hard | action | stop 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] | action | play 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> | action | play 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, pipes | BUILT: 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 / off | action | BUILT: 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+Down | shortcut | BUILT: 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+M | shortcut | BUILT: mute, or unmute if muted; held, it does not repeat |
audio help | usage, one real example per row | |
audio version | the 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.
| Asked | Answer |
|---|---|
audio volume 60 | volume 60 - verified |
audio volume 0 | volume 0 - silent - verified |
audio volume 60 while off | volume 60 - kept; audio is off, so it is set at `audio on` |
audio mute | muted - verified; if already: already muted (nothing sent) |
audio unmute | unmuted - volume 60 - verified; at volume 0: unmuted - volume 0, silent - verified; if not muted: not muted |
audio off | audio off - the codec is powered down; audio on brings it back - verified |
audio off hard | audio off (hard - the controller is held in reset; audio on brings it back) - verified |
audio on | audio on - volume 60, unmuted, output line out - verified; if on: already on |
audio output headphone | output headphone - verified; already in use: already playing through headphone (nothing sent); nothing plugged in: says so on the next line |
audio output speakers | audio: no output called 'speakers' - this device has: and the list |
audio tone 440 2 | playing 440 Hz for 2.0 s, then played 440 Hz for 2.0 s or stopped after 1.2 s |
audio tone while off | audio is off - `audio on` first |
audio tone muted, or at volume 0 | plays, and says first: muted - nothing will be heard / volume is 0 - nothing will be heard |
audio play /song.wav | playing /song.wav (48000 Hz, 16-bit, stereo, 0:02), then played 0:02, or stopped after 1.4 s |
audio play that ran dry | played 0:02, 180 ms of silence where the samples did not arrive in time - never hidden |
audio play /x.wav, 24-bit | audio: /x.wav is 24-bit - this plays 16-bit PCM |
audio play /x.wav, 8000 Hz | audio: /x.wav is 8000 Hz - this codec plays 44100 or 48000 Hz |
audio play /x.mp3 | audio: /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
| Situation | What 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 answer | audio: the audio driver is not answering |
| No HD Audio controller | audio: no audio hardware on this machine |
| The controller did not leave reset | audio: the controller is there and did not come out of reset - the serial log says more |
| No codec on the link | audio: the controller is up but no codec answered |
| No usable output path | audio: 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 ended | stopped, 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.
onandoffare 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
fsis 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 offis written when they are switched off; no line means on, the default.outputis written only once one has been chosen withaudio 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, sopwm-audiokeeps 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 savedhardware report(orhardwaretext) 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
| Verb | Kind | What it does |
|---|---|---|
hardware | report, pipes | a line for the machine, then every section this machine has |
hardware <section>[,<section>...] | report, pipes | those sections; one this machine lacks says so |
hardware <device> | report, pipes as lines | one device in full, named as hardware names it: its live registers, interrupt and authority |
hardware <device> debug | report, pipes as lines | the device as the hardware sees it, read now: configuration space decoded, its capability list, and the raw 256 bytes |
hardware <section> debug | report, pipes as lines | debug for every device in the section; cpu debug is each core's scheduler counts |
hardware interrupts | report, pipes | each 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 lines | who drives it, why that service, and the reason the supervisor records beside its spawn row |
hardware report | report, pipes as lines | everything, for a bug report: the overview, problems, interrupts, firmware, events, every driven device in full, the cores |
hardware problems | report, pipes | what is wrong now; records severity, device, problem, detail |
hardware tree | report, pipes | every device by how it connects; records device, parent, kind, driver |
hardware firmware | report, pipes | each radio's chip and firmware, as its driver reports them; records device, driver, chip, firmware |
hardware compare <report> | report, pipes | what changed since a saved report; records change, device, was, now |
hardware events | report, pipes | what happened to the devices, as the supervisor saw it; records time, device, event, detail |
hardware help / hardware version | the 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:
usbwith 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
| Question | What 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: onlypipe: 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, orask: the answer arrived incomplete - nothing more came for ~25s, so the shell ended askwhen part of it came; askitself, ending its stream:ask: ask ended without an answer, orask: 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
useandremovecall 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.
| key | default | what 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-secs | 30 | how 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-mib | 64 | the largest package that will be downloaded, 1 to 4096 |
offline-max-age-days | 30 | how 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:
| item | value | note |
|---|---|---|
registry | the address of the registry in use | its name, and (in use) |
clock | the date a certificate is checked against | its source: hardware clock, network time, or a floor from the last boot. No clock, no check: date sync first |
roots | how many | the folder they were read from |
address | the registry's IPv4 address | its name |
tls | verified | TLS 1.3; the certificate chain reaches a trusted root and names the server |
http | the status line | how many bytes came back |
says | the 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):
- 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. - Timestamp, snapshot, namespaces, each signed by its role's keys, unexpired, and not older than what this machine already accepted (a rollback is refused).
- The package's index, exactly the bytes the snapshot names for it.
- 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).
- 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.
| command | rows |
|---|---|
search, list available | a 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 |
info | a 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 |
fetch | progress 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 fetched | a 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 installed | a list, package version state (section 4b); the count in the status note, or, with none, the command that installs one |
list updates | a 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 |
verify | a list, package version status detail, status intact or DAMAGED; the count in the status note. A damaged copy fails the command |
trust | a 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 reset | forgotten, or nothing to forget |
remove | removed 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 rootsgodspeeduses - 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 saysNOT 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). Anhttps://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 tohttp://is refused: a link that looked checked never quietly becomes one that is not. Typing thehttp://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:
| key | value | note |
|---|---|---|
saved | the file | its size |
from | the link it came from | as typed, or how many redirects from the one typed |
checked | https, or NOT checked | what that means |
speed | KiB/s or MiB/s | the bytes and the time, the connection included |
time | where the time went: network, decrypt, disk and screen, in ms | how 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).fssizes 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.
readthe 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
| Verb | Kind | What it does |
|---|---|---|
spawncap <svc> | action | ask 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 help | usage | |
spawncap version | the version and the collective copyright line |
What it answers
| Situation | Answer |
|---|---|
| The capability routes | spawncap: pong - endpoint cap acquired; send Ok |
| The send through it fails | spawncap: pong - cap acquired but send failed, and an error |
| The service has no endpoint to hand back | spawncap: pong - spawned, but it has no recv endpoint to hand back, and an error |
| The supervisor refused or the spawn failed | spawncap: could not acquire endpoint cap for pong (...), and an error |
| A core service | refused, 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
| Command | Meaning |
|---|---|
sock | Open a UDP socket cap, send a datagram through it, report the round-trip. |
sock version | Print the version. |
sock help | Print 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:
- The address.
10.0.2.3is 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. - The wait.
udp_roundtripRE-TRANSMITTED the query on every retry and read whatever came back from the send.nic-driverno 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 in5716da17by 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)
net-stackholdsRESOURCE_MINT, from the privilege word in the supervisor's spawn request for it, exactly likefs(the kernel keys no grant on a service name, CLAUDE.md §13.6).socksendsnet-stackan "open socket" request;net-stackmints a socket cap (resource_mint, READ|WRITE) and grants it back (send_with_cap_by_handle).sockinvokes the cap (resource_invokewithRIGHT_WRITEand a payload ofdest_ip, dest_port, data); the kernel validates rights + generation, badges the message with the socket's ResourceId, and routes it tonet-stack.net-stackreads the badge (last_recv_badge), finds the socket, builds a UDP datagram, sends it throughnic-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 holdsACQUIRE_ANY). - The socket cap itself, minted by
net-stackper 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 size | advertised 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 control | RFC 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 timer | a 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 happened | net-stack logs |
|---|---|
| the peer refused, or reset an established connection | peer reset the connection |
| six retransmissions went unacknowledged | no acknowledgement after 6 retransmissions |
| no answer to the opening SYN | no answer to our SYN |
| the connection worked but returned nothing before the budget | no data and no fault (budget expired) |
| the network is not configured yet | tcp 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):
- The listener.
net-stackmints it when the port is granted. Closing it stops new connections and leaves connections already accepted from it running. - The connection.
acceptmints a second capability and grants it to the caller. From that point the connection is a thing the client HOLDS - reading needsREAD, sending and closing needWRITE, and a stale handle getsCapRevokedfrom the kernel rather than a wrong answer from net-stack. - 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 happened | what you see |
|---|---|
| the stack has no address yet | net-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 use | the same line; net-stack's log distinguishes them |
| nobody connected in time | serve: <n>s elapsed, 0 connection(s) served, when a duration was given |
you pressed q while it waited | serve: stopped after N connection(s), and the port is released |
you pressed q while the port was still being opened | serve: aborted; nothing was granted, so nothing is released |
| the peer connected and sent nothing | the 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
| Command | Meaning |
|---|---|
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 version | Print the version. |
ping help | Print 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<1usfor zero) and whole milliseconds above. The summary uses one unit for all three figures, chosen by the average.net-stacktimes 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 printstime>=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 readstime<1us, and the summary reports0usfor 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 - ...meansnet-stackcould not confirm a link, andNo reply from <ip>: net-stack not respondingmeans the request itself got no answer.- The statistics summary prints whether you stopped with
qor acountrun 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
- The shell parses the options and
<ip>(parse_ipv4, no allocation) and, per echo, sendsnet-stacka[3, a, b, c, d, bytes_lo, bytes_hi]request. net-stack's serve loop builds an ICMP echo request ofbytesdata 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 throughnic-driver, and waits for the echo reply - matching the reply's source IP so a gateway ping and an internet ping cannot be confused.net-stackreplies[alive, rtt_us(le u16), reply_ttl](alive2 = no link confirmed); the shell formats theReply fromline, accumulates min/max/average, and paces ~1 s to the next echo while polling forq.
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 holdsACQUIRE_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 withnet dnsfirst. - 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
| Command | Meaning |
|---|---|
version | Print 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 rootCargo.toml(the shell'sCargo.tomlhasversion.workspace = true; seeCONTRIBUTING.md). - Architecture:
env!("GODSPEED_ARCH"), derived by the shell'sbuild.rsfromCARGO_CFG_TARGET_ARCH. - Build SHA:
env!("GODSPEED_GIT_SHA"), stamped by the shell'sbuild.rsfromgit rev-parse --short=8 HEADat compile time.build.rswatches.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.versionis the version fact, not the identity card. Keep them separate (conventions rule 7 - a utility reports one raw fact). - No per-utility versions.
versionreports 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
| Command | Meaning |
|---|---|
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
uptimeandpingpace by) - never the TSC, whose wall-time calibration is wrong on some hardware (the T630).wait 1ends at the next second boundary, so it can return up to a second early, never late. qaborts, and the abort is anErr. That is what makes it compose:if !wait 2 { break }ends a loop the instant the user quits. At the prompt, an abortedwaitshows asErrinresult.- Sleeps between checks. The pause sleeps 50 ms between its clock and key checks
(
pause_quittable), so the core is free for the wait andqstill answers at once. It used to callyield_now, which kept an otherwise idle core busy (backlog/fixed/80H21). - Other keys are kept. A key that is not
q,Qor Esc is kept for the prompt, so a command typed during the wait is still there when it ends. They were dropped untilbacklog/84S3.
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/uptimealready use). - Console input (the
qpoll) + 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
| Command | Meaning |
|---|---|
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 version | As 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, orrepeat 2: statuswith no count. The command's own output follows it, as typed at the prompt.[q] quitis the shell's, once, 2 s in, not repeated on every run's line. qstops 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 ownq(rule 10).- The end is a verdict:
repeat: 4 run(s), 1 failed, with- stopped by qwhen it was. It isErrwhen 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
watchcould neither serve a script nor read a count. - Each run is a script line. The command runs one level down (
executeat depth + 1), sorun,selfcheckand the library scripts are refused inside it, and the refusal namesrepeat(it named only scripts untilbacklog/84S1). 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/84S2). A refusal of its own options still goes to the screen.$(repeat ...)itself is refused, since a capture takes a producer, andrun ... save <path>saves only the run's report, forrepeatas for every command. - Keys typed while it runs are kept. Between runs, and during its pause, a key that is not
qis kept for the prompt rather than dropped, so a command typed ahead is still there when it ends (backlog/84S3). - No screen clearing. Clearing was there to make
watcha 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
| Command | Meaning |
|---|---|
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
wherebare 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. unknownreturnsErr(assert fails whatis bananaholds).- "Shell built-in" means the shell's
UTILSlist, which holds every command the shell dispatches (fmt,tcp,serve,randomandgpiowere missing from it, and calledunknown, untilbacklog/fixed/80H8). A service that is not running is recognised only if it is in the shell'sKNOWN_SERVICESlist, 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_statvia 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.
whatissays what a name is;helpsays what it does. - Not a path. If a user-editable
/libever exists, the origin distinction (baked into the imagevs/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:
| Fact | Where it already lives |
|---|---|
| Which endpoint a task awaits a reply from | ipc::routing::CALL_AWAIT_EP[slot] - one entry per task, "bounded ... it never grows" |
| Which task is blocked on a full queue | RoutingEntry::blocked_sender |
| Whether a task is blocked, and how | TaskState::BlockedOnRecv / BlockedOnSend |
| Endpoint core, generation, liveness | the routing table (§8.3) |
| Endpoint -> name | the kernel name directory (ipc::names) |
| Task name, core, state | task_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:
| Mechanism | Cost when unused | New kernel state | |
|---|---|---|---|
A. Blocked-chain (trace blocked, trace chain) | a state query | zero - nothing runs until asked | one u64 per task (blocked-since), for the FOR column |
B. Event history (events ipc, events failures) | a bounded ring written at the IPC routing point | one relaxed atomic load + predicted branch | a 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
| column | what it holds |
|---|---|
slot | the kernel scheduler slot of the blocked task (the same number status shows) |
name | that task's service name |
blocked | HOW it is waiting: call (a synchronous request awaiting its reply) or recv |
awaiting | the ENDPOINT ID it is waiting on - an opaque kernel id, resolvable with trace endpoint |
held_by | the 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 line | means |
|---|---|
awaits no task - blocked on its own endpoint, waiting for work | idle. The chain ends here because this service is fine |
awaits no task - it is runnable, so the chain is not stuck here | running 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 see | what it means |
|---|---|
27 calls | how 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 FAILED | of 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#119 | a 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 edges | the walk hit its bound; the graph is larger than the view |
As records (| to grid), one row per EDGE:
| column | what it holds |
|---|---|
depth | 1 for a direct peer, 2 for a peer of that peer, and so on |
parent | the service that holds the capability |
peer | the service it points at |
grant | grantable 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 / failed | as above |
ops | space-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.
| column | what it holds |
|---|---|
slot | the task's scheduler slot, as status shows it |
name | the service |
endpoint | the id to pass to trace endpoint <id> |
state | the task state - BlockRecv here means idle, waiting for work |
queue | messages 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 line | means |
|---|---|
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 task | ids 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 OWNER | either 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 |
| column | what it holds |
|---|---|
holder | a live service holding a capability to this resource |
slot | that holder's scheduler slot |
rights | the 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
- Mechanism A -
trace blocked,trace chain. New kernel surface: twoInspectKernelqueries (awaited endpoint per slot; blocked-since per slot) plus oneu64per task. No ring, no switch, no cost. Answers the question. - Use it. Find out whether B is needed. §26.2 - features are pulled into existence.
- 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:
| Layer | Change |
|---|---|
ipc/routing.rs | one safe reader, call_await_endpoint - no unsafe, ipc/ stays unsafe-free |
task/scheduler.rs | TaskStatRaw.endpoint, populated from a value task_stat already loaded for queue_depth |
syscall/dispatch.rs | two match arms (queries 24, 25), INTROSPECT-gated by falling outside the ungated list |
sdk | two wrappers |
shell | cmd_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:
- A global "seen" set flattened it.
block-driveris a direct peer of the shell AND a peer offs, so once drawn it was never drawn again -fs 24 callshad nothing beneath it. Distinctness of children and absence of cycles are DIFFERENT problems: dedupe the edge, guard the path. - Edges were duplicated.
timeis reached twice (directly and vianet-stack) and each visit added its owntime -> fsrow; the renderer, which finds children by parent NAME, then drew both under every occurrence. The edge itself must be unique. - 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. - Filtering
SEND|GRANTdeleted 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-stackshowed nonic-driverdependency 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
- 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. 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.- 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. FOR(blocked duration) needs a per-task blocked-since stamp. Oneu64per 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
eventsstarted 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 isevents 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
eventsloses 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
| column | meaning |
|---|---|
owner | the 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. |
metric | the name the publisher chose, up to 20 bytes. Longer is truncated, and the SDK says so once. |
value | the LAST value published. A metric is a SET, not an increment - the owning service holds the counter and publishes what it currently reads. |
age_s | seconds 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.
| column | what the NUMBER or word actually is |
|---|---|
seq | the 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 |
sec | when 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 |
caller | who made the call, as that service declared itself (ctx.trace_as). ? means a service traced without declaring a name |
peer | who was called, by name. The emitter knew it, so no lookup is involved |
op | the OPERATION, by name (read, write, capacity). A bare number is an opcode this shell has no table for - net-stack's protocol, for instance |
outcome | how it ended - see below |
Outcomes, and what each one tells you to do:
| outcome | means |
|---|---|
REPLY | the peer answered |
TIMEOUT | the peer did not answer within the caller's deadline. It is alive as far as the kernel knows |
PEER_LOST | the send failed, or the peer died while the call was outstanding (ReplyDead) |
QUEUE_FULL | the 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 |
ABORTED | the 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.
| number | what it counts |
|---|---|
ring N events | capacity. Fixed, no heap |
N recorded | events ever ACCEPTED by the sink since it last started. An events restart resets it |
N DROPPED | events 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:
| Field | Width | Note |
|---|---|---|
timestamp | u64 | the monotonic clock observe already uses (portable across arches) |
sender | u16 | task slot |
receiver | u16 | task slot, or 0 for none |
endpoint | u32 | endpoint id |
generation | u32 | the restart-identity the requirement asks for - nvme:9 vs nvme:15 falls out |
message_id | u32 | kernel-assigned, monotonic, wraps loudly |
event | u8 | SEND / RECEIVE / REPLY / BLOCKED / ENDPOINT_DEAD |
op | u8 | byte 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 -> fsandfs -> block-drivereach PREPEND a one-byte correlation tag (added so replies are not matched by arrival order, afterfsaccepted one block's data as another's). So a trace recording byte 0 showed REQUEST IDS in a column labelled "op" -183,177,212againstblock-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 .bss | a fixed array in the events service |
sender/receiver task slots | the peer's NAME, because the emitter knows it |
endpoint + generation | not recorded - the name is what a reader wanted the endpoint to resolve to |
kernel-assigned message_id | not assigned; see trace tree, still out of scope |
a switch + AtomicBool on the IPC fast path | nothing on the IPC path at all |
| a control syscall to arm/read it | ordinary 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:
| Layer | Change |
|---|---|
sdk/rust/src/trace.rs | wire 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.rs | trace_emit; three emission points in request_with_reply |
services/events/src/main.rs | the 192-event ring, dump + status replies |
services/shell/src/main.rs | events ipc, events failures, events status |
| kernel | nothing |
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-stackreacquiring a capability that was never stale, backing DHCP off 34 s on a healthy link), so the ring keeps them apart.ABORTED- the user pressedq. 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:
| Experiment | Result | Ruled out |
|---|---|---|
| wrappers + tracing | 213/9 | - |
wrappers marked #[inline] | 212/10 | the extra frame and the 4 KiB Message move |
| wrappers with the trace calls removed | 222/0 | the wrapper structure itself |
| the clock read removed from emitter AND sink | 213/9 | the CMOS RTC read |
events moved off core 0 | 222/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:
- The sink does not live on the interactive core. ARM had already moved
eventsoff 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. - 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 blockedreads it from the kernel, live (mechanism A). - The sink stamps the time, from a cached clock.
epoch_secs_monotonicis a CMOS RTC read on x86 -wait_update_clearcan spin ~1 ms before seven port-I/O reads - so per-event it would cap the sink near a thousand events a second.eventsreads 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 anypeerand anyoutcomeit likes, because the whole event is its testimony.calleris 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/Gandj/kare gone, and so isb-[b] backgroundclaims that letter. The pager washelp-shaped - it calledhelp_render_linedirectly - 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_sendand the result is discarded: a full sink costs the emitting service nothing and loses one event, which the ring counts andevents statusreports. - A service that holds no
eventssend cap emits nothing, at the cost of one relaxed load. Tracing is authority, visible incaps <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 does | what it answers | |
|---|---|---|
write /cutme.txt ... (the crash-window test build) | holds ONE known window open for ten seconds | proves the recovery path works |
churn <seconds> | runs thousands of transactions of every shape for as long as you ask | searches 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 writein 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
| key | does |
|---|---|
| arrows | scroll a line |
| PgUp / PgDn, space | a page |
| Home / End | the ends |
t | contents - then a digit jumps to that section |
a | about: the boot banner, and this machine's live architecture |
/ | find; n for the next match |
q, Esc | leave |
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.txthas a reader, but the reader is the shell.run /nightly.gshhas no reader at all, which is often why it was started.selfcheckruns 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,assertorselfcheck(depth 0), and - the output is going to the console - not
$( )capture, notsave <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 singleESC [ 2Jinside 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.
| scrollback | paginate | |
|---|---|---|
| when you decide | after the output has gone past | before you run the command |
| what it needs | nothing | the word in the pipeline |
| covers | every command, including ones that already ran | the pipeline you asked about |
| column headers | scroll away with everything else | stay pinned |
| bound | the ring, oldest lines age out | the 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
| how | does |
|---|---|
| PgUp at the prompt | opens the view, one page back |
scrollback | opens the view at the newest line |
scrollback save <path> | writes the whole history to a file |
| PgDn at the prompt | nothing 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:
- a per-keypress request to move the console's view, waiting on a full 4K repaint;
- then a per-frame fetch, queued behind the painting the previous frame had asked for;
- 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] quitmatched 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, whichosdev test big-script(6/6) pinned; the refusal replaced that in914b4329,backlog/fixed/80H10-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.gshextension (GodspeedOS shell;.gsis reserved for a future general-purpose Godspeed language). Built on therun/run_linesinterpreter and the command Result model (executereturnsOk/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/elsewith comparisons (== != < > <= >=),in,!, andresultas a comparable value;switchwith multiple values per arm, a_default, andswitch result. Pinned byosdev 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.txtthenlet 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) andfrom <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 (asresolves it). -
Loops (§5) -
for <var> in <words | range N | range A B | $args | (producer)> { … }, unboundedloop { … }with a 100k-iteration backstop, andbreak/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 aletinside 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'sreturn, or the whole script), LIFO, even onfail. Each defer records only an (offset, len, scope-depth) into the resident script. -
Record aggregators (§5) -
countis 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"andinput secret "prompt": invisible entry plus an echo-taint guard rail (sealedreserved).
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
- Spirit
- Lexical
- Variables, parameters, arithmetic
- Conditionals
- Loops
- Switch
- Functions
- Builtins
- Bounds
- Out of scope
- Tiers and effort
- Worked example
- 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.
Okis true,Erris false. The thing that makesifcheap is that we already produce aResultfor every command. - Errors are checked, not hidden. No
set -e-style invisible mode - you inspectresultright where the error can happen. See §4. - Explicit and loud (the Godspeed part). Referencing an undefined
$xis 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,$varexpands),'single quoted'(literal, no expansion). {and}are structural - they delimit blocks (if/else, and laterfor/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
$varslike"…". It is for a command argument (bounded byMAX_FILE_BYTES); storing one in aletvar 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). $xexpands; undefined$xis a loud error (never a silent empty string).$arg1 $arg2 …positional,$argcountcount,$argsall,$selfscript name.$homeis the session's home,/users/default(docs/filesystem-layout.md6), so a script can writecd $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/subis a path,$n / 2is 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, nofi.- 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$xequals 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" }
}
resultis a first-class value: after every statement it holds that statement's outcome. It is theResultthe shell already threads between commands - soresult == Erris justprev.is_err(), nearly free to implement.- Compare
resultwith==/!=against one of:Ok,Err(any failure), or a specific kind -FileNotFound,Denied,AssertFailed,Unknown(the setassert fails-withuses). - Flat, no wrapper. It is
result == FileNotFound, neverresult == Err(FileNotFound)- the kind names are self-evidently errors, andErralone already means "any failure". One spelling per check. fail <msg>prints<msg>loudly and ends the script withErr.return <cond>ends a function (§7) with a result.- Optional one-liner sugar, addable later (desugars to the
resultcheck):a and b(runbiffawasOk),a or b(runbiffawasErr). 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 … inis command position - the live stream goes in directly (the parens are optional readability).$( )would flatten the command to text and lose$row.col;for … inkeeps the stream so it can iterate rows. ($( )= "the text";for … in= "the stream".) for x in a b citerates literal words;for x in $argsiterates the script's params.for i in range N/range A Bcounts.loop { … }repeats untilbreak. There is nowhile- a conditional loop isloop { if !cond { break } … }, which keeps the exit explicit and visible.- Two loops, one job each - and the keyword tells you if it terminates.
foris bounded: it walks something finite.loopis the unbounded one: it runs untilbreak, 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$namein the body ($arg1 … $args $argcountalso available).- Output is the value, result is the control.
$(f …)captures what the function printed; itsOk/Errworks 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 anassertreturns 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). Thelet/let mutdistinction 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
fnblock, 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 withx = …).result- the previous statement's outcome, as a value (compare== Ok/== Err/== <Variant>).fail <msg>- print loudly and end the script withErr;return <cond>ends a function (§7).defer <command>- run a command when the current scope (function, or the whole script) exits, including onfail; deferreds run LIFO.Ok/Errand the variant names (FileNotFound,Denied,AssertFailed,Unknown).range N/range A B- the counting iterator forfor.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, likesudo). 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:)
| Thing | Bound (loud on overflow) |
|---|---|
| variables | fixed count × bounded value size |
| block nesting | fixed |
| loop iterations | hard cap (no runaway; configurable per loop later) |
| function call depth | fixed frames (each = its params + locals) |
| deferred actions | fixed per scope |
| script size | embedded: 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,
spawnwith 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 alet) - bigger storage stories; iterate withforfor now. (2026-10-09: function libraries are the exception -import <path>andfrom <path> import <name>are built, see "Libraries" under What is built. A generalsourceof 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/.gssplit 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 aCaptureCallframe, 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).)
- scratch space, filled then dropped. (
-
Function-valued conditions - BUILT.
if myfn args { … } [else { … }]andif !myfn { … }branch on the function's RESULT directly (Ok -> then, Err -> else), instead of calling it as a statement then checkingresult. The function runs via the executor's Call machinery under anIfCallframe; a comparisonelse ifafter 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):
sealedenforcement -input secret sealedwould 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 makesealedworth 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)
- Indent 4 spaces per block depth. No tabs.
- Braces, K&R.
header {on one line, body indented,}at the block's own indent.} else {and} else if <cond> {stay on one line. - One statement per line. A
;statement separator becomes a newline (the biggest de-jarring win).cmd ; cmd ; cmdbecomes three lines. - One space between tokens. Runs of whitespace between tokens collapse to a single space; leading/trailing whitespace per line is stripped.
#comments keep one space after the#; a full-line comment sits at the current indent, a trailing comment is one space after the statement.- Blank lines collapse (2+ become 1); no leading or trailing blank lines; the file ends in exactly one newline.
- Strings are sacred. Content inside
'…'/"…"is never touched - not its spacing, not its case, nothing. - Idempotent.
fmtof already-formatted text is a no-op.fmt(fmt(x)) == fmt(x)is an acceptance test - met, proven from 8 KB to 10 MB byosdev test fmt-idem(format a chunk-spanning file twice and diff = IDENTICAL).
Scope note (honest).
fmtcanonicalizes 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. ReturnsErr(via theResultmodel,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
fmtprints 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.
fmtnever 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 changesfmtwould make without writing (needs a bounded diff).fmt <dir>- format every.gshunder a directory in one call.- Host
osdev fmt+ VS Code format-on-save. The single definition of "GodspeedOS standard" is factored into ano_stdcore crate so the shell built-in and a hostosdev fmtshare 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 recordedCLAUDE.mdamendment 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
| Decision | Choice | One-line reason |
|---|---|---|
| Filesystem | Our own, from first principles | ext4/btrfs are POSIX + enormous; both fights the constitution (§2.4, §3.3, §26.11). No interop requirement to justify the cost. |
| Authority model | File = 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 device | ATA PIO (legacy IDE) | Simplest correct device; no DMA → least-privilege by construction; works in QEMU and has a hardware path; stepping-stone to AHCI. |
| Namespace | Hierarchical (directories + paths) - adopted; Phase 1 shipped flat | Flat 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). |
| Bounds | Fixed, bounded | Like 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 loss | A 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:
- 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. - 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).
- "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:
- "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. - 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
drivesDEFAULTflag + drivelabel), our evolution path (file-as-capability, §7), no legacy, no exFAT patents. - 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:
| Filesystem | Max file | Max volume |
|---|---|---|
| FAT32 | 4 GiB | 2 TiB |
| exFAT | 16 EiB | 128 PiB |
| NTFS | 8 PiB | 8 PiB |
| ext4 | 16 TiB | 1 EiB |
| APFS | 8 EiB | 8 EiB |
| ZFS | 16 EiB | 256 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-driveris AHCI on x86 (MMIO + a DMA arena,services/block-driver/src/ahci.rs,docs/ahci.md) and a USB stick through thedwc2orxhciservice on every other board (usbdisk.rs,xhciblk.rs). There is nohw_piocapability, noPortRead/PortWritesyscall, nocapability/hw_pio.rsand no SDKpio.rsin 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-driverleave 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_piocapability in the contract: a port range the driver may touch, validated and granted at spawn exactly likehw_mmio. - SDK port-I/O wrappers (
Pio::inb/outb/inw/outw/insw/outsw) in an auditedsdk/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 staysunsafe-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 = 512andNAME_MAX = 38(services/fs/src/main.rs), andMAX_FILESdoes 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
fsandblock-drivermay 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/fsis the hierarchical format below (inode table + per-directory blocks + path walking);osdev mkfsformats it; the block-IPC LBA is u64 (§6.3).osdev test blockdevpins it ([GSFS.E]mkdir + nested/etc/motdpath-walk) andblockdev-rebootproves the tree survives a reboot (root →/etc→/etc/motd+/greetingwalked 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
u64is baked into the new format at design time rather than churned into throwaway code.u64is the convention from here on.
- Path walking: resolve
/a/b/cby reading the root inode (a directory), findinga→ its inode, reading that directory, findingb, … 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 whatdrivesuses 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:
- Superblock.
- 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.
- 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:
fskeeps 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 (neverinode- "index node" smuggled impl-jargon into a user word;file_recordsays what it is).fs_index- a global index built over allfile_records, for fast whole-filesystem enumeration. This is the aggregate. (fs_index, notfs_record: "index" is the honest word for an aggregate lookup structure and doesn't collide with the per-filefile_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.
fsserves one IPC request at a time; "operations in different places" are just different clients, but they queue atfsand 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.
fsupdates 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_indexis 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 (afind),fscompares 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.
fsis the sole mutation point, so "the index changed" is just "fsdid 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-
findwalk 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):
- 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.) - 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. Thefile_recordlayout 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.) - 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-drivermoves 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/truncatepathto a file sizedtotalbytes, allocating the whole contiguous extent up front (record size =total; data filled next).WriteAt(path, offset, chunk)(op 25) - writechunkat 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 tolenbytes fromoffset(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:
- writes the staged blocks into the journal region;
- 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; - checkpoints each staged block to its home LBA;
- 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),fsandblock-driverno longer need to be trusted root - their death becomes a supervisor restart, not a panic+reboot. The non-restartable set shrinks to justinit+supervisor+ kernel.
Mechanism (mirrors the kernel name-directory recovery path).
- The kernel's death path notifies the supervisor for
fsandblock-driver(sending the dead service's name); nothing on that path treats them as unkillable, so killingfs/block-drivernever panics. (2026-10-09: no longer by name - the kernel notifies for any task the supervisor spawned withSPAWN_FLAG_WATCHED, which it sets from itsMANAGEDroster; CLAUDE.md 12.3, 2026-10-03.) - The supervisor's death-notification loop respawns the named service.
block-driverrespawns beforefs(fs's send-peer cap to it wires from the kernel name table at spawn). fsandblock-driverare recorded in the kernel name directory at spawn, so clients can reacquire a fresh cap by name after a restart.- On restart,
fsre-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
EndpointDeadorReplyDead(§14.3), and re-send only what is safe to repeat.fs's block I/O (block_rpc) reacquiresblock-driver: it is a synchronous request/reply bounded at 30 s (request_with_reply_deadline_outcome_into), so ifblock-driverdies after receiving the request but before replying, the kernel wakesfswithReplyDead(the reply-side twin ofEndpointDead, §8.6) instead of hanging it on a reply that will never come -fsthen reacquiresblock-driverby 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'sfs_requestreacquiresfsand 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 = 3556bytes (MAX_FILE_BYTES, and the shell'sIO_CHUNK) - 7 whole data-block payloads, so a streamingWRITE_AToffset 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 (
osdevgsfs_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) letsmountfall 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).formatwrites both;persist_superkeeps them in sync (staged in the same transaction, so they commit atomically);drives resetwipes both. Removes the single most catastrophic point of failure for ~no cost. Reformat-only bump 0005→0006. Verified byosdev 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 viapersist_super. Turns "detected, dead" into "detected, repaired." A newdrives checkshell command (opOP_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 byosdev test fs-check(5/0): drift the free count host-side (both copies, CRC re-stamped), boot,drives checkrebuilds the correct value, 0 bad, the file survives. the selfcheck suite also runsassert ok drives checkon 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-driverissues every read/write/zero throughissue_io, a bounded retry (MAX_IO_ATTEMPTS = 3) with port recovery between attempts (recover_portclears 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.fsalso logs the failing LBA on a block error. Bounded (§26.6) - never an infinite retry. No format change. Verified byosdev test fs-ioretry(5/0): aio-error-testbuild 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): itsfirst_blockpoints to a single CRC'd extent block (block_count= 1) that lists up toEXT_MAX = 31scattered(start, len)data runs. So a file is stored across whatever free space exists. Full §6.12. Reformat-only bump 0006→0007. Verified byosdev 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_atwrote 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 journaledwrite_atvariant (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; defaultWRITE_ATstays 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). (WriteFileand 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 byosdev test fs-djournal(7/0): a journaledwrite_athalts 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 fromcheck, 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 (rundrives scrubon a schedule), not a hidden timer (§26.4: no silent complexity). Full §6.14. No format change, no amendment. Verified byosdev 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 runsassert ok drives scrubon 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'sfirst_block/block_countare the data extent; the n-th data block isfirst_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_runreturns "no space"). The record'sfirst_blockpoints to a single extent block andblock_countis 1. The extent block is a normal 512-byte block:n_extents:u32 @0, then up toEXT_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_atpath - 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:
WriteFileallocates 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 journaledwrite_at. It stages the chunk's data blocks in a transaction (data_stage: stamp the CRC,tb_writeinto the staged set) and commits through the journal, so the chunk's data rides the same atomic mechanism as metadata:commit_txnwrites 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'srecoverreplays it home. The chunk is applied whole or not at all - never torn.- Default
WRITE_ATstays 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 (@124compat,@128ro_compat,@132incompat, 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:
| Mask | A bit this build doesn't recognise means… |
|---|---|
compat | mount read-write normally - ignoring the feature is safe |
ro_compat | mount READ-ONLY - reading is safe, but writing could corrupt the feature (every mutating op is refused loudly; reads pass) |
incompat | REFUSE 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:
- AHCI self-heals a CI-stuck controller.
recover_portnow escalates its COMRESET onPxTFD.BSY != 0 || PxCI != 0, not onBSYalone. A stuckPxCIis hardware-owned - a soft recovery (clearingPxSERR/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. fsrejects a mis-shaped capacity reply.block_capacitynow 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 beBLK_OK- is rejected (returns "unknown"), never misread as a capacity;read_superblockthen bounds-checks the capacity before deriving the backup-superblock LBA from it. The false success that fed the garbage read is gone:fswaits 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.)- The mount loop has a failure-truth exit. Both mount waits - the capacity fetch and the read/
E_IOretry - 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 atMOUNT_MAX_ATTEMPTSreal block-driver round-trips, after whichfscomes 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_MAGICis stillb"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 aro_compatbit.
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. u32epoch 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:
fswill not mint a writable capability to a sealed file - refused atopen, 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.- Every write path (
write, the streamingwrite_at, the journaled variant) refuses, because the seal is carried on theEntryeach of them already walks. A write route added later cannot forget to ask. - The volume sets
ro_compatbit 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. Notincompat: 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:
fsis single-threaded and serves one request to completion before dequeuing the next. The serve loop isloop { 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.- 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:
| Option | What it is | Verdict |
|---|---|---|
| Bearer token | fs returns a 128-bit unguessable handle, presented per call | A 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-file | fs creates a kernel endpoint per open file; client holds a real SEND cap | Genuinely unforgeable, but every endpoint costs ~64 KiB (our own efficiency measurement) - heavy with many open files. Rejected for cost. |
| Kernel-delegated resource caps | Extend the kernel's ResourceId+Rights+Generation model so a service can ask the kernel to mint a cap for a service-defined resource it owns | Chosen. 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
fsclean revocation (delete a file → bump → all its caps go stale, surfacing as the usualCapRevoked/EndpointDead-class error); - never learns what the resource means.
fsmapsResourceId → 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.mdamendment (§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. Holdingipc_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-cap9/9; on-hardware shellselfcheck=ran 163, failed 0, which includes thefcapself-check -CLAUDE.md§23.3).fsOpen(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 unforgeableMessagefield, andfsresolves it through its open-file table, enforcingop ≤ 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
allocatere-registers a reused id atget_resource_generation(id).bump()(viaregister_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 newRESOURCE_MINTstable resource (ResourceId(7), gen 0 forever, granted only tofs+ a test probe; validated byholds_resource, likeSPAWN). Allocates a delegated id owned by the caller's endpoint, mints a cap withrights_bitsinto the caller's table, writes the id to*out_id_ptr, returns the slot.fscalls this onOpen, recordingResourceId → 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 overloadingsend. It validates the cap carriesright_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, reusingrouting::enqueuewith 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 asSendWithCapdoes today, sofsreplies on the normal path. (2026-10-09: as built, the badge is NOT prepended to the payload - it rides in kernel-setMessagefields and the owner reads it withLastRecvBadge(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 byfs,net-stackand theresource-serverexample 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 needsWRITE. 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 doingResourceInvoke(.., WRITE, ..)failsCapInsufficientRightsat the kernel (this is the non-escalation enforcement, §7.3). The kernel then badges the message with the validated right, andfsrefuses any op whose required right exceeds the badged right. Sofsnever has to trust the client, and the kernel never learns the op.
ResourceRevoke(resource_id) -> i64- owner-gated (ownership is the capability check:revoke_ownedonly proceeds if the calling task's endpoint owns the id). Bumps the generation (Revoked) so every outstanding file cap to it goes stale → nextResourceInvokereturnsCapRevoked(§7.5). Frees the owner slot.fscalls 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 1OP_READ_BLOCK, 2OP_WRITE_BLOCK, 3CAPACITY, 4OP_WRITE_ZEROS, 5FLUSH- one block per request, not a count - and since 2026-10-20 6OP_WRITE_BLOCKS, a run of 1 to 7 consecutive blocks (count:u8). Client <->fs: ops 10-31 (OP_WRITE_FILE10,OP_READ_FILE11,OP_STAT_FILE12, directories, rename/move/delete, drives, streamingOP_WRITE_NEW/OP_WRITE_AT/OP_READ_AT24-26, check, scrub,OP_OPEN30,OP_SEAL31). The constants are the definition:services/fs/src/main.rsandservices/block-driver/src/main.rs. (2026-10-25: the client range now runs to 32,OP_QUIESCE, whichpowersends before a shutdown,docs/shutdown.md.)
Client ↔ fs (Phase 1, name-addressed):
| Request | Args | Reply |
|---|---|---|
WriteFile | name, data (chunked) | Ok / NoSpace / IoError |
ReadFile | name | data (chunked) / NotFound / IoError |
StatFile | name | {exists, size} |
ListFiles | - | names (chunked) |
DeleteFile | name | Ok / NotFound |
fs ↔ block-driver:
| Request | Args | Reply |
|---|---|---|
ReadBlocks | lba, count (message-bounded) | sector data / IoError |
WriteBlocks | lba, 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-driverandfsremain 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
fsatomic-commit semantics so a restart can recover to a consistent state, then dropfsandblock-driverfrom the TCB (a recordedCLAUDE.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-drivercan leave the TCB without depending on IOMMU presence". The ATA-PIO backend is retired:block-driveris 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, soblock-driveris IN the TCB on every machine, IOMMU or not - which is whatdocs/ahci.mdand 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.)
- Block driver read path. ✅ done (
osdev test blockdev). Added thehw_piogrant (kernel-mediatedPortRead/PortWritesyscalls validated per access, grant store incapability/hw_pio.rs), SDKpio.rs(Pio), andblock-driverATA-PIO reads sector 0 of a QEMU secondary-channelif=idedisk and logs it - verified by reading back a host-written magic. Port I/O is kernel-mediated because ring-3 drivers cannot runin/out(granting IOPL would be ambient authority). - 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 existingPiowrapper; no new kernel surface. - Filesystem mount + format. ✅ done (
osdev test blockdev, case P1.3). Host-sideosdev mkfs <image>writes the superblock (magicGSFS0001, version, block_size, total_blocks) into LBA 0;block-drivergained an IPC server loop (ReadBlock/WriteBlock, the per-request reply-cap pattern);fsmounts by reading LBA 0 over that IPC (SDKrequest_with_reply), validating the magic loudly, and logging the geometry. The entry table + free bitmap are written bymkfsin a later step (no files exist yet). Forwardfs → block-drivercap wires viasend_peers(block-driver spawns first, kernel auto-registers its endpoint name); the reply rides a per-request cap fs embeds. - 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).fsstores/retrieves named files:write_pathallocates an extent, writes the data blocks + entry table + superblock through block-driverWriteBlock;read_filewalks the entry's extent viaReadBlock. Verified by a mount-time round-trip (greeting).fsalso serves the client API (WriteFile/ReadFile/StatFile, ops 10-12) over IPC via the reply-cap pattern. - Reboot survival (the headline). ✅ done (
osdev test blockdev-reboot, case P1.5). Format once; boot 1 -fscreatesgreeting; 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. - Phase 2: file-as-capability. Kernel-delegated resource caps (after the §7 amendment
is signed off);
fsreturns/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_piocapability syntax in the contract schema (range form, likehw_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 forblock-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) intoblock-driverand grants it a small physically-contiguous DMA arena (task/mod.rs, the same path as xhci/ehci). The driver takes the window withgs::driver::registersand the arena withgs::driver::arena, laid out in four named parts (docs/driver-library.mdstep 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: modelQEMU 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) viaissue()into the PRDT data buffer; block IPCReadBlockrestored. Verified:fsmounts 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;
fsspawns 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 onich9-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
ahcicargo feature is gone; ATA PIO + thehw_piocapability + 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 spawnedhwclass::pci(0x01_06_01, 5, false)(theblock-driverrow 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 blockdev3/3 + identity 23/23; unsafe audit back to 413/27 (thehw_pioarch wrappers +capability/hw_pio.rs+ SDKpio.rsremoved). - Step F - port recovery (COMRESET). ✅ done (
f069c85, hardware-found by the million-round soak). A soak that killsblock-drivermid-command can leave the port stuckBSY(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 clearsPxTFD.BSY, AHCI 10.1.2), pulsePxSCTL.DET1 -> hold -> 0, wait for the PHY (PxSSTS.DET == 3), clearPxSERR/PxIS, restart the engine.init_portruns 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 letsfs's mount-on-truth get a truthful answer:block-driver's reply reflects a settled controller, never a wedged-BSYfalse 0 (Commandment VIII). - Step G - CI-stuck escalation. ✅ done (
2026-07-09, hardware-found on the T630 by a rapid doublekill all-services). Step F escalated to COMRESET only on a stuckPxTFD.BSY. But a command against a nonsense LBA (anfsmount that derived a backup-superblock LBA from a garbage capacity - seedocs/persistence.md§6.16) can wedge the controller with the command-issue bitPxCIstuck set andBSYclear. A soft recovery (clearingPxSERR/PxIS) cannot clear an ownedPxCI- only a port COMRESET does - so the BSY-only gate never fired and the driver retried into the same stuck state forever.recover_portnow escalates onPxTFD.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) isdocs/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 onmain.Two scope limits this document did not state, both load-bearing:
- AMD-Vi is x86-only. Every
iommu::entry point onarm,aarch64andriscv64is a stub andconfine_devicereturnsfalsethere. 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.xhciis the only confined driver, even on x86.ehciandblock-driverkeep a stale firmware DMA pointer that confinement would fault, so both run in passthrough by design (theconfineflag onHwClass::Pci, inkernel/src/task/mod.rs), andnic-driveris spawnedconfine=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-driveris confined too, verified in QEMU only; on the T630 it does not use DMA yet, so therexhciis 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 showsxhciand 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 is00: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
xhciwas 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 saydomain 1are from before the fix. Found under anxhcithat stopped completing commands mid-chaos on the T630 (docs/wifi-usb.md51).
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-03audio-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:
- 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.
- Switch the device's DTE from passthrough to that domain (
V|TV|mode=4|root). - 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):
- 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 raisedILLEGAL_DEV_TAB_ENTRY. - 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). - xHCI scratchpad never allocated. This controller reports
MaxScratchpadBufs=256- it DMAs into 1 MiB of scratchpad it finds viaDCBAA[0]. The driver leftDCBAA[0]=0and 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. - 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 encodingV=1, TV=1, mode=0, root=0, IR=1, IW=1, exactly Linuxamd_iommu'sPAGE_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
0xffffffc0accesses), 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 -
detectalready 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_FAULTevent 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:
-
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_xxxxinterrupt region is also a DMA,iommu.rscontains no interrupt-remapping table and maps no interrupt region, and yet the confined xHCI runs fault-free. Why?Because
write_dtewrites 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_CFGis 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 invalidatedSo 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=0on 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 onqemu-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.
-
-
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.
-
block-driver / fs. CORRECTED 2026-09-26: both LEFT the TCB on 2026-06-17 (§6.1's Phase D amendment) once
fsgained 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-driveris 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 84quanta/s, and on the Wysexhcifell from 100% to ~0%. Validation: identity 24/24;chaos max-carnage882 deaths on the T630 and 66 on the Wyse, all recovered, zero panics / page faults / liveness wedges;selfcheck349 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:
| Approach | CPU idle | Latency | Notes |
|---|---|---|---|
| Busy-poll (current) | 100% | ~0 | loop { check_event_ring(); }, never blocks. Core never sleeps. |
| Sleep-poll (rejected) | lower | = sleep interval | loop { check(); sleep(N); }. Shorten N to fix lag and CPU climbs back. No free lunch. |
| Interrupt-driven (goal) | ~0% | interrupt latency + bInterval | loop { 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:
- 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. - 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.
- 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:
observebefore/after:xhci/ehcishould 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_haltaround; 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:
e767956ehci: block on the interrupt instead of busy-polling9d1c43c/b0e40e2usb: per-core / BSP shared-tickrecv_timeoutwake (fighting input lag)e68a1aaehci: revert to busy-poll - "EHCI interrupt is dead"efff4d1xhci: busy-poll only while a key is held (auto-repeat), block otherwisefafcd0eusb: 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 - theefff4d1"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
recvnever 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)
-
Phase 1a - xHCI interrupt-driven, with IRQ-core co-location. DONE + HW-VERIFIED (Wyse 5070, 2026-07-19:
observeshows xhciBlockRecvat ~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 onrecv_timeoutwhen 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 byobserve(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. -
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()withctx.sleep()at the three idle sites droppedehcifrom 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 becausesleep'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.
-
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.
-
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).
- Allocate a periodic frame list (1024 dwords, 4 KiB-aligned) in the DMA arena.
- Program
PERIODICLISTBASE, enableUSBCMD.PSE, waitUSBSTS.PSS. - 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.
- Link the QH into the periodic tree at the polling interval, instead of the async reclamation ring.
- Keep IOC on the qTD (already done) so periodic completions raise
USBINTautonomously; 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; hltwhereIDLE_CAN_HALTis true, elsesti-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 fortpq * 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_TICKSis 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_rpcdeadlines. 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 longIDLE_QUANTUM_MULTquanta (~1 s) instead of the ~10 ms quantum, thenhlt. 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_nextreturns 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_HALTis true. The win is real only when the core actuallyhlts between ticks (then fewer ticks = deeper/longer sleep = less power). On Goldmont+ (the Wyse 5070), C-states are BIOS-locked andIDLE_CAN_HALTis false, so idle coressti-spin and never sleep - slowing the timer saves nothing there (their idle spin is a separate, firmware-gated problem, and noteobservehides 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_HALTtrue) proves power.observewill 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:
| Request | Reply | Meaning |
|---|---|---|
[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.casks 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_defconfigmakesondemandthe 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). Overup_thresholdthe clock goes to its maximum and, if it was below it, the next sample waitssampling_down_factorintervals. At or under it, the clock goes to the step nearestmin + load * (max - min) / 100, and the next sample is one interval away.- The kernel's defaults are an
up_thresholdof 95 (80 without microsecond idle accounting), asampling_down_factorof 1, and an interval of 10 ms on this board (the driver's steps carry no transition latency, socpufreq_policy_transition_delay_uscaps it). - Raspberry Pi OS overrides them at boot, in
debian/raspi-config.init:up_threshold50, asampling_rateof 100,000 us,sampling_down_factor50, andio_is_busy0 (time waiting on I/O counts as idle). drivers/cpufreq/cpufreq-dt.cputs 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 → capmap at boot and on restart; theregistrySERVICE is retired and deleted (Phase 4);initis 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- aSpinLock<[NameEntry; 128]>mapping service name →EndpointId. Populated at spawn (task/mod.rs,names::register(name, ep_id)), read to wire each service's declaredsend_peersSEND 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 asreacquire_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 facility | Kind | Verdict |
|---|---|---|
Routing table (EndpointId → core/gen/liveness) | mechanism - opaque ids, the kernel routes messages | STAYS |
| Cap mint / validate / install, the spawn syscall, IPC enqueue/IPI | mechanism | STAYS |
Task labels ("this task is fs") for death notices, observe, logs | mechanism - a task's own identity, not third-party resolution | STAYS |
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 concern | OUT OF SCOPE here, see §7 (since moved to the supervisor) |
name → EndpointId resolution for IPC send-peers (ipc::names) | policy - who may reach whom | LEAVES |
Syscall 10 AcquireSendCap (ungated mint-by-name) | policy + ambient surface | LEAVES |
Spawn-time send-peer name wiring (the names::lookup loop in spawn_service_with_config) | policy | LEAVES |
Key distinction. The kernel keeps task labels (it may report "the task you named
fsdied" - 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 routesEndpointIds 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 holdscap_slotwith GRANT, derives a copy into the new task's cap table, and recordspeer_label → child_slotin the child's send-peer metadata - so the child'sctx.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|GRANTcap 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
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 servesregister/lookup/reacquiredirectly. 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
registryas 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 needs | Recovery source |
|---|---|
| caps to running services | AcquireSendCap from the kernel directory (re-minted by the kernel) |
| which services should run | a 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 endpoint | the kernel re-points death notices to the new instance |
service_control + other authority caps | re-minted at spawn (the kernel grants them by name) |
| clients finding the new supervisor | clients 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::lookupsend-peer wiring loop inspawn_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_imageinkernel/src/task/mod.rs;spawn_service_with_configno longer exists) still runs anames::lookuploop 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, andspawn_wiredlogskernel will name-wire itfor 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 flatname → EndpointIdrecovery directory (names::registerstill runs at spawn;names::lookupserves 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 thinreacquire_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.
| Phase | Change | Status |
|---|---|---|
| 0a | New SpawnReturningEndpoint syscall - spawn returns the new endpoint cap to the caller. Additive. | ✅ merged |
| 1 | Supervisor builds its name → cap map (NameCapMap) from those caps (shadow). | ✅ merged |
| 0b | New SpawnWithCaps syscall - kernel installs caller-supplied send-peer caps. Wiring becomes a merge (install caller caps, then name-wire any peer not provided). | ✅ merged |
| 2 | Flip fs ← block-driver (wired from the map). | ✅ merged |
| 3a | Flip shell ← fs; generalize spawn_wired(name, peers). | ✅ merged |
| 3b | Move registry spawn init → supervisor (§11); provide registry to all services → every real service 100% supervisor-wired at boot. | ✅ merged |
| 3c | Flip 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.
| Phase | Change | Status |
|---|---|---|
| 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. Retireregistryfrom §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_peersis 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 wereinclude_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 issupervisoralone. Seedocs/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
- Supervisor endpoint load. If
registryis retired, every reacquisition (and anyregister/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. - Spawn syscall ABI. Passing a variable-length
installlist through the register-based syscall ABI needs a small in-memory descriptor (likehandle_spawn_pipealready does for its sink string). Bounded, fixed-maxinstallentries (§26.6). - 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.
- Query 2 consumers. Confirm exactly who reads "endpoint generation by name" before removing it (Phase 5) so a test doesn't silently lose coverage.
- 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)
- 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.
- 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
fswould re-introduce a dependency (andfsis itself restartable); a compiled-in manifest sidesteps it. - Gating
AcquireSendCap(Phase 4). It becomes the universal recovery primitive, so it must be gated (aRECOVER/directory cap) rather than ambient - held by the supervisor and by services that legitimately reacquire their own declared peers. - 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).
- 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 - anIF=1point Core 0 reaches by blocking or via the timer ISR. Underchaos max-carnagethe foreground task never blocks (it paces withyield), so recovery hinged solely on the timer ISR - which a storm starves on real hardware (slow tick + longIF=0console writes + IPI/shootdown pressure), draining the live set to 0 with no respawn. Fix: drive the pending respawn from the yield path too (yield_currentmirrors 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 newunsafe(it lives insideyield_current's existing block). Confirmed on hardware: a 5000-round max-carnage soak sustains the live set instead of draining to 0. - 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 doublekill all-serviceson the T630 recovers the full set with no duplicate spawns and no wedge (the storage-stack half of the same run isdocs/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
registryservice gone, through the gated kernel directory.AcquireSendCaprejects 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.mdessentially unchanged (the kernel additions - gated directory, respawn-supervisor, re-point death notices - are small and in permitted layers or safefns).
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). TrailsCLAUDE.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
Streamthat is either a byte buffer or a typedTable;from json/to jsonflip 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 indocs/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 commandsabout/version/whatis/mem/cores/date/net/ping/sock/help/pwd, plusrandom,tcp,churn verify(churn's other verbs refuse), and the report verbs ofwifi,audioandhardware(their action verbs refuse). Record built-ins (is_record_producer):status,dir,caps,drives,find,uptime,events,trace,jobs,ask, the report verbs ofgodspeed(its action verbs refuse), andobserve now. Services:greet(text),roster(records). (There is nocat:readis the one file reader -utilities/18_read.md, the replacement for POSIXcat, 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 verbswhere/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 Thewritesink below),assert(the verifying sink) andpaginate. 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 ofwifi/audio/hardware/godspeed,status,dir,caps,drives,find,uptime,events,trace,jobs,ask,tree,read,echo,input,observe now. Each renders through anOuttarget that is the console when run bare and a capture buffer when piped - soaboutprints, andabout | write /fsaves, 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>). Pipingdelete /x | write logto 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
observelive view andeditown the screen and never yield a discrete stream; piping them is a loud refusal (useobserve now). - Orchestrators (
run/selfcheck) → NOT sources. They run the suite's own sub-pipelines, so capturing one through a pipe would nest apipe_run(which holds a 16 KiBStreamon 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), thenread <path> | …brings it into the pipe world (readis a leaf producer). That is the right shape anyway: a report is a durable artifact (keep it, re-read it, grep it,editit), 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 foredit's windowing instead, append a simple producer a few times:help | write /big.txtthenhelp | 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:
| Form | Effect |
|---|---|
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:
- The shell asks the supervisor to spawn the service with
"shell"as its peer (spawn_via_supervisor(svc, 0xFFFF, peers)indrain_stage, with&["shell"]as the peersdrain_servicepasses). 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. - 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.
- The service processes and sends its output back to the shell (
peer_at(ctx, 0)), ending with an EOT. - 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_SECSdeadline for output that never comes, so producer services are an explicit whitelist (is_pipe_producer_service, currentlygreet); 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
writesink is not limited this way: it streams the captured buffer to a multi-block file viaWriteNew/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:
| Limit | Value | Set by |
|---|---|---|
| IPC message | 4 KiB | MAX_PAYLOAD (§8.5) - a stage through a service is one message |
| Capture buffer | 16 KiB | the inter-stage buffer; a builtin-only pipeline is bounded by this |
| Concurrency | none | stages 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:
- 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.
- Backpressure. With bounded per-endpoint queues (depth 16, §8.5), a fast producer must
block on a full queue until the consumer drains it -
sendalready blocks, so the queue is the backpressure, but every stage must then use thetry_send/structured discipline §8.9 requires so a stall can't become a deadlock. - Multi-block files - done.
\| writealready 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. - A streaming filter contract. A filter service reads a chunk, emits a chunk, repeats -
upperis 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): theTablemodel, eleven shell-side record producers (wifi listthe eleventh;wifi hardware,hardware's tables,askand the reports ofgodspeedhave joined since) plus therosterservice,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 amendCLAUDE.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
- 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. - Wire codec - a compact, bounded binary encoding, used when a record crosses a service
boundary as records. Built (
Table::encode/decodein 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 theTableitself on the wire. A record service (examples/roster) emits this; the shell decodes it straight into aTablewith nofrom json. (JSON is still available for human/interop output viato json, andfrom jsonfor parsing external JSON text.) - 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;reverseleads, likematch 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:torenders the model → text,fromparses text → model. A bare| jsoncouldn't express "parse incoming json" without the word pointing two ways (the implicit magic §26.5 forbids).to json,to yamlandfrom jsonare 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
Tablemodel (owned column names + arena);to_grid(default, full string cells - no clipping),to_json,to_yaml; the compactwhere,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), andobserve now(the roster + atickscumulative-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/tobridging). All in-process (no wire codec), QEMU-verified incl. a json → records → yaml → file round-trip anddir | where type=file | sort reverse size. - Also built: the SDK record API - the
Tablemodel,where/select/sort,to_json/to_yaml/to_grid(over aRecordSink),from_json, and the binary wire codec (encode/decode) now live ings::record, so any service can produce records, render them, or put them on the wire as records (sdk/rust/CLAUDE.md).examples/rosteris a record service: itencodes aTable, the shelldecodes it -roster | where role=core, nofrom json. - Next: a JSON string-escaper (values are plain ASCII today);
from yaml; record streams larger than one IPC message (chunkedencode+ 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, readqto 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
eventsservice. 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:
| Kind | Examples | Wants to go to |
|---|---|---|
| Log (diagnostic) | kernel kprintln, xhci: progress, spawn[...], cap-test | the log stream (serial + a queryable buffer) |
| Console (interactive) | the shell prompt, observe output, command results | the 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 theevents). 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 thexhci:/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
consoleservice 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, readsq, and releases - with no log lines smearing it.
Stage 3 - events becomes real (optional, parallel)
- Route
ctx.logto theeventsservice (today a stub) so logs have a real home (a queryable buffer,osdev logs <svc>, later a file viafs). 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 -kprintlnand every service'sctx.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
ConsoleWritesyscall (23) +ctx.console_write/console_writeln- the interactive path. Gated byLOG_WRITEfor 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 keepsctx.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
InspectKernelquery 9 for screen geometry. - 2c (live
observe): a non-blockingTryConsoleRead(24) and aConsoleEcho(25) echo toggle, both gated byCONSOLE_READ;observegains aMODE_LIVE_FGthat hides the cursor, suppresses echo, repaints in place (home +ESC[Kper line, no full-clear flicker) every ~0.5 s, and pollsq; the shell spawnsobserve-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)
-
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.
-
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.
-
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.
-
Keyboard ownership. Does the console service own keyboard input (the shell asks it for lines), or does the shell keep reading
ConsoleReaddirectly 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'sq). -
Log routing granularity. Stage 1 needs to mark output as log-vs-console. By API (
ctx.log= log, a newctx.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
helpandtraceeach grew a pager of their own - andhelp'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 doublekill 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 byscripts/commandments.py, for whichkernel/src/fbconwas 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) | |
|---|---|---|
| Serial | owns it, unchanged - still the source of truth | never touches it |
| Framebuffer | a minimal boot/panic blit | the whole terminal |
| Text model | none (no grid, no cursor, no scrollback) | ANSI/CSI, UTF-8, shadow grid, cursor, scroll, reverse video, scrollback (§10) |
| Geometry | private to its own blit, never published | the 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 -
unsafeis forbidden in services (§18.2) andDCCMVACis 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), andfb_commit(a clean-to-PoC on ARM, ansfenceon x86) collapses on ARM to a store barrier - aDSB, 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_CHEAPdisappears with it: reading back a non-cacheable framebuffer is never cheap, so the terminal always repaints from its shadow grid andscroll_by_copyis 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_fbmatches it;PCDkeeps 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 tookwarning on any machine, andselfcheckcompletes 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 gathering | encoding | |
|---|---|---|
| x86 | PCD alone (UC-, upgradable to WC by MTRR) | PCD | PWT is strong UC and is NOT |
| arm32 | PCD | 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
| Added | a 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 |
| Removed | InspectKernel 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
ConsoleDrainsyscall and aCONSOLE_RENDERauthority gating it, and the sentence above said "the syscall pin grows by one". Neither exists - not inkernel/, not inservices/, not insdk/, andSyscallNumberhas no such entry. What shipped is what §9.2 of this document describes:ConsoleWriteenqueues 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:
- A placeholder ELF.
arm_build.pykept 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 fromkernel/build.rs, not reconciled against it - the third time that shape has bitten this repo. - The wrong green.
grant()reconstructed the framebuffer's channel shifts fromblend_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. - 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.
- 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 nowlost its tail every time. The writer now blocks (§8.5/§8.6 back-pressure); the kernel still never waits. reclaim_for_panichad 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:
osdev buildhad been shipping a staleconsole. Its crate list omittedconsole,control,timeandhw-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 shapeservices/supervisor/build.rsalready records for its USB list: a missing binary trips a guard and a stale one does not, because a stale file is not missing.scripts/stack_fit_check.pywas 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 fromosdev build.Term::newmaterialised the terminal twice - 180 KiB, from a 118 KiBservice_main. Its own doc comment warned about exactly this while the code did it; it was survivable only becauseFbwas small.blank()is aconst fnreturning a literal now, built directly into the caller's local, withattach(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 (
driveslist,flash,label, pluscheck,scrubandreset, which this doc does not cover) are built overfs; the default flag, multi-drive and[N:]label/pathaddressing are not (utilities/15_drives.md§8). Records thedrivesshell 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). TrailsCLAUDE.md; does not amend it.Command surface superseded by
utilities/15_drives.md(+ the file commandsutilities/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/pathwhenever it's plugged in;15_drives.md§3.1), anddrives use/drives use default(the current location is a single drive+dir pointer moved bycd, not a second "current drive" verb;15_drives.md§3.2). The boot layer is reserved as thedrives godspeed install|update|defaultnamespace (designed indocs/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 (aDEFAULTbit in theflagsfield), so the drive is self-describing: on boot,fsscans 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
mountedstate (utilities/15_drives.md§3.1). The earlier model here hadraw → flashed → mounted → current; themountedstep 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/pathwhenever 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 bycd(utilities/17_cd.md). One location pointer (drive + dir), not a separate "current drive" - seeutilities/15_drives.md§3.2 for whydrives usewas 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/…vs1: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.
drivesflags a duplicated label so you can see it (and relabel withdrives 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:datadrives use datawith twodatadrives asks you to qualify:drives use 1(ordrives 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.
| Command | Effect | Persists? |
|---|---|---|
drives | list 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 once | data: 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 godspeed | list 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/delete | navigate / 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 aflagsfield (DEFAULTbit) and alabelfield. (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/pathaddressing; drives API =list/flash/label/mount/use/use default. (As built, the API islist/flash/label/reset/check/scrub;mountandusewere dropped, note at the top.)- shell:
drives(+ subcommands) and the file commands (dir/read/write/cd/mkdir) with[N:]label/pathaddressing.
8. Suggested order
- Hierarchical GSFS - evolve the format to inodes + directories + path walking
(the foundation the rest needs).
persistence.md. - Single-drive
drives-flash/use/use default+ boot auto-mount of the default (one disk; proves the format/mount/default loop). - File commands -
dir/read/write/cd/mkdiron the current drive. - Labels - name a drive at flash/
label; address + select by label. - Multi-drive - enumerate all disks; per-drive block IPC;
mountvsuse;[N:]label/pathcross-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_MAXinservices/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 labelis available but optional. - Confirmation UX for
flash(a[y/N]prompt vs a--force/yestoken), 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 incapability::init.handle_kill(syscall 8) gates oncurrent_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 onePROBE_ELFconst, and the mint matchescore::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_killstill checks holdings ofSERVICE_CONTROL_RESOURCE(kernel/src/syscall/dispatch.rs). But nothing is minted by name or by ELF identity any more: the kernel no longer holdsPROBE_ELF(the probe image moved to the supervisor,docs/probe-params-design.md), andSERVICE_CONTROLis 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 fromprobes::privileges_of. The COM2 control channel below is theservices/controlservice now, not a kernelcontrol.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 framessucceeding 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_resourceis valid (skips the gen check). ReturnCapNotHeldif absent.- Mint name-gated at spawn (
task/mod.rs), likeINTROSPECT/console_read. restartthen requires bothservice_control(kill) andspawn(spawn) - both held by the same privileged callers.- Shell
cmd_caps: map id 6 →service_control. - Docs:
syscall/CLAUDE.mdsyscall 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_controlfor any service spawned from that ELF, plusshell+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 withprobe-/prop-/stress-/fuzz-/adv-/perf-/chaos-(and the brutalbp-/bs-/bf-/ba-/bc-forms). Mirrors the existingINTROSPECTgate 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
| # | Decision | Choice |
|---|---|---|
| 1 | What to gate | Disclosure of another task's or system-wide state requires the cap; reading only your own state or a hardware clock stays ambient. |
| 2 | Mechanism | A holds-resource check - the kernel verifies the calling task holds INTROSPECT_RESOURCE (READ) - not a passed cap-slot. |
| 3 | Denial | Return 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 / query | Discloses |
|---|---|
TaskStat (16) | any task's full snapshot |
InspectKernel 1 | live endpoint count (system) |
InspectKernel 2 | another named endpoint's generation |
InspectKernel 4 | free physical frames (system) |
InspectKernel 5 | total physical frames (system) |
InspectKernel 6 | a core's active ticks (system) |
InspectKernel 7 | a core's total ticks (system) |
InspectKernel 8 | ready core count (system) |
InspectKernel 24 | the endpoint a given task owns |
InspectKernel 25 | the endpoint a given task is blocked in Call awaiting |
InspectKernel 26 | the count of fault diagnostics emitted without the serial lock |
InspectKernel 27 | the boot record (the first 32 KiB ever logged; CLAUDE.md 11.4) |
Ambient (no capability)
| Syscall / query | Why it stays open |
|---|---|
InspectKernel 0 | the caller's own allocated bytes - its own state |
InspectKernel 3 | the cycle counter - a hardware clock, not anyone's state |
InspectKernel 13 | whether the CALLER owns the console foreground - caller-specific, like 0 |
InspectKernel 10, 11, 12, 16, 17 | board-neutral timing: input-ready, the RTC now/boot reads, TSC-per-quantum, deglitched monotonic seconds |
InspectKernel 14, 15, 18, 19, 20, 21, 23 | board 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 28 | the 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:
| Service | Needs the cap because it calls | Notes |
|---|---|---|
shell | task_stat (status), inspect_core_count (cores) | privileged broker already |
observe | task_stat + aggregates | the metrics utility itself |
probe | inspect_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_RESOURCEamong the stable resources.) kernel/src/syscall/CLAUDE.md: addINTROSPECT_RESOURCEto the syscall table and document the holds-resource form for argument-saturated read syscalls.audits/unsafe-audit.md: unaffected - no newunsafe(the gate is safe Rust).utilities/1_observe.md§7/§8: update from "introspection is currently ambient (verify)" to "introspection is gated byINTROSPECT_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
introspectcallstask_stat/ a gatedInspectKernelquery 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
capability/mod.rs: addINTROSPECT_RESOURCE = ResourceId(5)+ register it.task/scheduler.rs(+capability/table.rsas needed):current_task_holds_resource(rid, right) -> bool.syscall/dispatch.rs: gatehandle_task_statunconditionally, and gatehandle_inspect_kernelby listing the UNGATED query ids - so anything new is gated by default (§3).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.- Docs: update
syscall/CLAUDE.md, the§7capability list, andutilities/1_observe.md§7/§8. - Build (
osdev image), run the identity/property/perf/adversarial suites; add the new "introspection denied without cap" adversarial test. - 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.
| File | Lines | Status |
|---|---|---|
sdk/rust/src/syscall.rs | 8 | permitted (§18.1 - the syscall ABI) |
sdk/rust/src/mmio.rs | 9 | permitted (§18.1 - device registers) |
sdk/rust/src/dma.rs | 10 | permitted (§18.1 - DMA memory) |
sdk/rust/src/adversarial.rs | 10 | permitted (§18.1 - the red-team module) |
sdk/rust/src/memory.rs | 3 | permitted (§18.1 - a task's own memory, 2026-10-17 amendment) |
sdk/rust/src/service_context.rs | 80 | grandfathered 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.rs | 8 | grandfathered 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.
| File | Lines | Why |
|---|---|---|
sdk/rust/src/service_context.rs | 79 -> 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.
| File | Lines | Why |
|---|---|---|
sdk/rust/src/memory.rs | new, 2 | Region::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.
| File | Lines | Why |
|---|---|---|
arch/x86_64/acpi.rs | new, 5 | ACPI 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.rs | 73 -> 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.rs | 52 -> 53 (+1) | power_off (Pi 2): the same for the BCM2836, in the Device-mapped peripheral window. |
arch/aarch64/mod.rs | 74 -> 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.rs | 53 -> 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.rs | 11 -> 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.rs | 77 -> 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.
| File | Lines | Why |
|---|---|---|
smp/ipi.rs | 17 -> 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.rs | 8 -> 6 (-2) | shrank: alloc_atomic_u64_slice, which carved only the shootdown's ack arenas. |
arch/x86_64/boot.rs | 107 -> 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.rs | 74 -> 73 (-1) | shrank: broadcast_ipi_all_but_self; the SGI id it used (SGI_TLB) is retired with it. |
arch/riscv64/mod.rs | 67 -> 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 each | shrank: the empty broadcast_ipi_all_but_self stub. |
sdk/rust/src/adversarial.rs | 8 -> 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.
| File | Lines | Why |
|---|---|---|
sdk/rust/src/service_context.rs | 80 -> 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.
| File | Lines | Why |
|---|---|---|
sdk/rust/src/service_context.rs | 84 -> 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.rs | 8 -> 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.
| File | Lines | Why |
|---|---|---|
arch/aarch64/ptables.rs | 27 -> 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.rs | 9 -> 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.
| File | Lines | Why |
|---|---|---|
arch/aarch64/ptables.rs | 24 -> 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.rs | 47 -> 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.
| File | Lines | Why |
|---|---|---|
arch/aarch64/mod.rs | 73 -> 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.rs | 72 -> 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.rs | 52 -> 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.
| File | Lines | Why |
|---|---|---|
arch/aarch64/mod.rs | 71 -> 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.
| File | Lines | Why |
|---|---|---|
sdk/rust/src/service_context.rs | 83 -> 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.
| File | Lines | Why |
|---|---|---|
sdk/rust/src/service_context.rs | 83 -> 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.
| File | Lines | Why |
|---|---|---|
sdk/rust/src/service_context.rs | 82 -> 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.
| File | Lines | Why |
|---|---|---|
arch/aarch64/mod.rs | 70 -> 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.rs | 2 -> 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.
| File | Lines | Why |
|---|---|---|
arch/aarch64/sdio.rs | 0 -> 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.
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 52 -> 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.
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 51 -> 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.
| File | Change | Why |
|---|---|---|
arch/x86_64/mod.rs | 37 -> 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.rs | 69 -> 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.rs | 50 -> 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).
| File | Change | Why |
|---|---|---|
arch/aarch64/video.rs | new, 2 | fb_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.rs | 3 -> 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.rs | 53 -> 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:
- 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.
- 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'skprintln. - 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_SIZEpacks 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).
| File | Change | Why |
|---|---|---|
arch/aarch64/genet.rs | 3 -> 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 mutarrays and cursor (TX_BUFS,TX_NEXT,RX_BUFS), indexed inside their length during single-threaded boot. Identical to theRX_BUFSentry above. - One copy through the direct map in
transmit: the frame is written to a physical frame handed back by the frame allocator, addressed throughKERNEL_VA_BASE + phys, bounded by an explicit length check againstRX_BUF_LENGTHbefore 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/genet.rs | 4 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/xhci.rs | 37 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mmu.rs | 17 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/exceptions.rs | 12 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/xhci.rs | 38 -> 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).
| File | Change | Why |
|---|---|---|
arch/aarch64/genet.rs | 13 -> 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:
finalize_service_address_space(previous entry) - a new address space installed before its descriptors were visible. The supervisor-respawn fault.map_in_root- called bymap_in_active_tablesagainst THIS CORE'S LIVE TABLES, so the walker can reach a descriptor that has not become visible. Same defect, tighter window.unmap- cleared the descriptor and handed theFrameback 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/ptables.rs | 20 -> 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)
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 44 -> 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)
| File | Change | Why |
|---|---|---|
arch/aarch64/mod.rs | 67 -> 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. |
| File | Change | Why |
|---|---|---|
arch/aarch64/mod.rs | 66 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mod.rs | 65 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/genet.rs | 9 -> 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_CMDand friends are not inbcmgenet.h. Modern Linux moved the UniMAC registers to a sharedunimac.h. Searching the obvious header finds nothing, and the obvious conclusion - that this part has no such register - is wrong.- MDIO
READ_FAILmust be checked. A read of an absent PHY returns a perfectly plausible0xFFFFwith 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/genet.rs | 1 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/genet.rs | new, 1 | The 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/exceptions.rs | 14 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/gic.rs | 6 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/exceptions.rs | 13 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/genet.rs | 14 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/gic.rs | 5 -> 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.rs | 64 -> 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
smcis 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
_startimmediately. Parking them at_startmakes 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/smp_boot.rs | new, 9 | The 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.rs | 15 -> 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.rs | 62 -> 64 (+2) | Recording and reading the entry exception level. |
arch/aarch64/gic.rs | 4 -> 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).
| File | Change | Why |
|---|---|---|
arch/aarch64/xhci.rs | 35 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mod.rs | 61 -> 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:
- 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.
- 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.
pendingthen stayed set forever and no further report was ever queued. - 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.
drivesreported 0 MiB while the kernel had found a 15 GB stick:block-driverwas never grantedUSB_DISKon aarch64, so the capacity syscall refused withCapNotHeld.
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.
| File | Change | Why |
|---|---|---|
arch/aarch64/xhci.rs | 34 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/xhci.rs | 26 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/xhci.rs | 24 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/xhci.rs | 18 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/xhci.rs | new, 18 | Ring 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.rs | new, 4 | The 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.rs | 5 -> 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.rs | 12 -> 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.rs | 59 -> 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.rs | 11 -> 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.rs | 2 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mod.rs | 55 -> 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.rs | 19 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/exceptions.rs | 10 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/uart_rx.rs | new, 2 | The 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_KERNELwas 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/ptables.rs | 19 -> 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.rs | 51 -> 53 (+2) | finalize_service_address_space calling it, and the count it reports. |
arch/aarch64/usermode.rs | 14 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mmu.rs | 11 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/sched_user.rs | new, 4 | Copying 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.rs | 50 -> 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:
Logwith 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/exceptions.rs | 9 -> 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.rs | 13 -> 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:
- A range check - pointer and length inside the user half, addition checked for overflow.
ldtrb/sttrb, notldrb/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.- 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/uaccess.rs | new, 5 | core_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.rs | 7 -> 9 (+2) | aarch64_sync_current_dispatch: reading ESR_EL1 and rewriting the trap frame's elr to the recovery address. |
arch/aarch64/usermode.rs | 11 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mod.rs | 45 -> 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.rs | 19 (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:
_startreaches symbols only throughadrp/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
enableand 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mmu.rs | 5 -> 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.rs | 42 -> 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.rs | 7 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mmu.rs | 4 -> 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.rs | 41 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/ptables.rs | new, 19 | Page-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.rs | 3 -> 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.rs | 33 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/context.rs | 2 -> 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.rs | 28 -> 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.rs | new, 5 | Demo-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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mod.rs | 27 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mailbox.rs | new, 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.rs | new, 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.rs | 27 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mmu.rs | 2 -> 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.rs | 9 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/usermode.rs | new, 9 | The 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.rs | 5 -> 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.rs | 2 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/context.rs | new, 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.rs | new, 7 | Demo-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.
| File | Change | Why |
|---|---|---|
arch/aarch64/gic.rs | new, 4 | Four 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.rs | new, 5 | System-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.
| File | Change | Why |
|---|---|---|
arch/aarch64/exceptions.rs | new, 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.rs | 27 -> 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.
| File | Change | Why |
|---|---|---|
arch/aarch64/mmu.rs | new, 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.rs | 25 -> 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.
| File | Change | Why |
|---|---|---|
arch/riscv64/sbi.rs | 10 -> 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.rs | 23 -> 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.rs | 23 -> 25 (+2) | The two unsafe fn page-table stubs. |
arch/riscv64/mod.rs | 23 -> 25 (+2) | The two unsafe fn page-table stubs. |
arch/riscv64/display.rs | 5 -> 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.rs | 4 -> 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.rs | 2 -> 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.rs | 0 -> 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.rs | 57 -> 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.rs | 34 -> 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.rs | 30 -> 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.rs | 66 -> 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.rs | 46 -> 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.rs | 65 -> 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.rs | 58 -> 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.rs | 57 -> 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.rs | 54 -> 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.rs | 53 -> 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.rs | 7 -> 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.rs | 50 -> 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.rs | 64 -> 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.rs | 65 -> 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.rs | 64 -> 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.rs | 61 -> 64 | 2026-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.rs | 60 -> 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.rs | 59 -> 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.rs | 12 -> 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.rs | 10 -> 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.rs | 8 -> 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.rs | 6 -> 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.rs | 6 -> 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.rs | 39 -> 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.rs | 28 -> 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.rs | 38 -> 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.rs | 37 -> 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.rs | 0 -> 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.rs | 9 -> 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.rs | 25 -> 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.rs | 17 -> 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.rs | 21 -> 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.rs | 36 -> 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.rs | 0 -> 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.rs | 35 -> 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.rs | 0 -> 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.rs | 0 -> 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.rs | 6 -> 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.rs | 34 -> 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.rs | 0 -> 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.rs | 4 -> 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.rs | 0 -> 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.rs | 15 -> 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.rs | 33 -> 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.rs | 32 -> 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.rs | 0 -> 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.rs | 28 -> 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.rs | 0 -> 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.rs | 27 -> 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.rs | 26 -> 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.rs | 25 -> 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.rs | 23 -> 25 (+2) | The two unsafe fn page-table stubs. |
arch/s390x/mod.rs | 18 -> 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).
| File | Change | Why |
|---|---|---|
arch/x86_64/page_tables.rs | 48 -> 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.
| File | Change | Why |
|---|---|---|
arch/arm/fbcon.rs -> arch/arm/bootcon.rs | 1 -> 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 | +1 | Framebuffer::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/ | 0 | Unsafe-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.
| File | Change | Why |
|---|---|---|
arch/x86_64/fb.rs | 5 -> 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.rs | 6 -> 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.rs | 0 | Neutral 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.
2026-07-29 - LAN9514 networking: interrupt-driven RX, PHY link state, SNTP (feat/arm-usb-interrupt)
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.
| File | Change | Why |
|---|---|---|
arch/arm/irq.rs | 11 -> 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.rs | 43 -> 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- mailboxSET_POWER_STATEdevice 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- mailboxGET_CLOCK_RATEclock id 1. The base clock MUST come from the platform: the Arasan's capability register reports it wrongly on this SoC (Linux marks itmissing_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 othersdhostcontroller, 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.
| File | Change | Why |
|---|---|---|
arch/arm/video.rs | 11 -> 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.rs | 42 -> 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).
| File | Change | Why |
|---|---|---|
arch/arm/fbcon.rs | 4 -> 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.
| File | unsafe | Why |
|---|---|---|
arch/arm/fbcon.rs | 5 -> 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.
| File | Change | Why |
|---|---|---|
arch/arm/hid.rs | new, 0 | Pure 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.rs13 -> 15 (+2): the split-transaction microframe trace added while diagnosing the low-speed-keyboard split XactErr.trace_splitcaptures(phase, HFNUM, HCINT, GNPTXSTS, GINTSTS)into a fixedSPLIT_TRACEarray (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 nounsafe.)video.rs6 -> 11 (+5):set_usb_power_on(the VideoCoreSET_POWER_STATEmailbox call that powers the DWC2 AXI master - the breakthrough that made USB work on real silicon) is one MBOX-fill block;read_board_macis three (MBOX fill, MBOX response read, and theBOARD_MACstatic write - a plain pre-MMU store, NOT an atomic, because ARMv7STREXDis UNPREDICTABLE before the MMU is on);board_macis one (theBOARD_MACstatic read). These read the board Ethernet MAC (GET_BOARD_MAC, tag 0x00010003) since the Pi 2 has no EEPROM.
| File | Change | Why |
|---|---|---|
arch/arm/video.rs | 6 -> 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).
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 41 -> 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).
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 40 -> 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.
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 39 -> 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 onpoll's call site +on_core0()on the net syscalls) and mutually exclusive in time:pollruns from the timer IRQ; the net syscalls run IRQs-masked (SVCcpsid i) and never block, so the timer cannot fire mid-transfer. During boot enumerationpollis unreachable -arm_irq_dispatchroutes the tick to the demo scheduler untilNEUTRAL_SCHEDflips afterdwc2::init(this closes the one real-looking race: the hub walk keeps issuingctrl_xfers afterKBD_READY/NET_READYare set mid-walk). This invariant is now documented at theDMAstatic.- Device-controlled lengths are all bounded before a copy (
bulk_xferHCTSIZ residual only shrinksrecv;net_frame_rxsmscflenhard-checked4 + flen > got; every descriptor-parse loop guardsi + k <= total). No transfer exceeds the 2048-byte scratch buffer. NoMPS0 == 0div-by-zero. NET_MAC/PREV_KEYSstatics are boot-single-writer (Release) / core-0-single-accessor;net_inforeadsNET_MACafter an Acquire onNET_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 withdlen > data.len()can neither panic nor DMA past the buffer. No newunsafe(edits to an existing block + comments);dwc2.rsstays at 13. One liveness (not safety) note recorded: a wedged controller spins the core IRQs-off for the boundedwait_halttimeout.
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.
| File | Change | Why |
|---|
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).
| File | Change | Why |
|---|
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.
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 38 -> 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/.
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 36 -> 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)
| File | Change | Why |
|---|---|---|
memory/allocator.rs | 43 → 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).
| File | Change | Why |
|---|---|---|
arch/arm/fbcon.rs | new, 3 | put_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/.
| File | Change | Why |
|---|---|---|
arch/arm/video.rs | new, 4 | VideoCore mailbox (Device MMIO), framebuffer writes, MBOX static access - the framebuffer acquisition + fill. |
arch/arm/mmu.rs | 6 -> 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.
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 35 -> 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.
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 34 -> 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).
| File | Change | Why |
|---|---|---|
arch/arm/page_tables.rs | 25 -> 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.rs | 47 -> 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.
| File | Change | Why |
|---|---|---|
arch/arm/exceptions.rs | 23 -> 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.)
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 33 -> 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.rs | 26 -> 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.rs | 4 -> 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.rs | 21 -> 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.rs | 10 -> 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)
| File | Change | Why |
|---|---|---|
arch/arm/mod.rs | 23 -> 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.rs | unchanged count | stub_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)
| File | Change | Why |
|---|---|---|
arch/arm/page_tables.rs | 23 -> 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.rs | 46 -> 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.rs | 22 -> 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.rs | unchanged (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)
| File | Change | Why |
|---|---|---|
arch/arm/irq.rs | 9 -> 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.rs | unchanged count | Added 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.rs | unchanged count | Doc-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)
| File | Change | Why |
|---|---|---|
arch/arm/spawn.rs | unchanged count | Refactored 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.rs | 6 -> 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)
| File | Change | Why |
|---|---|---|
arch/arm/exceptions.rs | unchanged count | stub_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.rs | unchanged count | TrapFrame 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.rs | unchanged count | L1 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.rs | 0 -> 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)
| File | Change | Why |
|---|---|---|
arch/arm/context_switch.rs | 14 -> 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.rs | 21 -> 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.rs | unchanged count | Refactored 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)
| File | Change | Why |
|---|---|---|
arch/arm/irq.rs | 8 -> 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.rs | unchanged count | The 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)
| File | Change | Why |
|---|---|---|
arch/arm/sched_demo.rs | 0 -> 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).
| File | Change | Why |
|---|---|---|
arch/arm/spawn.rs | 0 -> 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.rs | 19 -> 21 | fill_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.rs | 21 -> 22 | The 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)
| File | Change | Why |
|---|---|---|
arch/arm/usermode.rs | 0 -> 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.rs | 19 (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.rs | unchanged counts | The 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)
| File | Change | Why |
|---|---|---|
arch/arm/syscall.rs | 0 -> 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.rs | 21 (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)
| File | Change | Why |
|---|---|---|
arch/arm/meminit.rs | 0 -> 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_tables | guard relaxed + PHYS_IS_IDENTITY const | The 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)
| File | Change | Why |
|---|---|---|
arch/arm/page_tables.rs | 0 -> 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)
| File | Change | Why |
|---|---|---|
arch/arm/context_switch.rs | 0 -> 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)
| File | Change | Why |
|---|---|---|
arch/arm/dtb.rs | 0 -> 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)
| File | Change | Why |
|---|---|---|
arch/arm/context.rs | 4 -> 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.rs | 21 (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)
| File | Change | Why |
|---|---|---|
arch/arm/context.rs | 0 -> 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)
| File | Change | Why |
|---|---|---|
arch/arm/irq.rs | 0 -> 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.rs | 21 (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)
| File | Change | Why |
|---|---|---|
arch/arm/timer.rs | 0 -> 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)
| File | Change | Why |
|---|---|---|
arch/arm/mmu.rs | 0 -> 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.
| File | Change | Why |
|---|---|---|
arch/arm/exceptions.rs | 0 -> 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):
| File | Change | Why |
|---|---|---|
arch/x86_64/mod.rs | 36 -> 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.rs | 104 -> 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.rs | 100 -> 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.rs | 98 → 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.rs | 35 → 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
unsafemoved to a new audited SDK modulesdk/rust/src/adversarial.rs(§18.1 amendment): safefuzz_syscall(wraps the ABIraw_syscall; the kernel validates every fuzzed call) and safefault_null_read/fault_noncanonical_read/fault_divide_by_zero(the deliberate faults, each SAFETY-commented).probecalls these safe wrappers and is nowunsafe-free. scripts/unsafe_check.pynow also scansservices/and FAILS on any serviceunsafeline - mechanically enforcing §18.2 and catching any future regression (the M8 blind spot). As a bonus,fuzz_syscalluses the SDK'sud2trap, removing probe's old rawsyscallinstruction (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.
| File | Change | Why |
|---|---|---|
arch/x86_64/mod.rs | 35 → 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.rs | 14 → 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.rs | 23 → 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.rs | 6 → 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])
| File | Change | Why |
|---|---|---|
arch/x86_64/syscall_entry.rs | 15 → 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
| File | Change | Why |
|---|---|---|
memory/allocator.rs | 37 → 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.
| File | Change | Why |
|---|---|---|
arch/x86_64/page_tables.rs | 41 → 46 (+5) | CR3 read/write + invlpg primitives (the MMU-base + TLB seam). |
arch/x86_64/mod.rs | 33 → 35 (+2) | switch_to_boot_stack (the boot stack-pointer seam; #[inline(always)]). |
arch/aarch64/mod.rs | 68 -> 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)
| File | Change | Why |
|---|---|---|
arch/x86_64/boot.rs | 90 → 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
| File | Change | Why |
|---|---|---|
arch/x86_64/syscall_entry.rs | 13 → 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.rs | 84 → 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
| File | Change | Why |
|---|---|---|
arch/x86_64/fb.rs | 3 → 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.rs | 80 → 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.rs | 34 → 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
| File | Change | Why |
|---|---|---|
arch/x86_64/fb.rs | 4 → 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→35permitted;main.rsandtask/mod.rsheld 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.
| File | Net | Layer | What |
|---|---|---|---|
arch/x86_64/page_tables.rs | 25 → 35 (+10) | permitted | entry_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.rs | 2 → 2 (no change) | grandfathered | install_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.rs | 7 → 7 (no change) | grandfathered | install_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
| File | Change | Why |
|---|---|---|
arch/x86_64/interrupts.rs | 12 → 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.rs | 79 → 81 (+2) | cpuid_arat_supported (unsafe fn + __cpuid(6)) - detects whether the LAPIC timer survives a C-state, gating the halt. |
task/scheduler.rs | 36 → 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).
| File | Change | Why |
|---|---|---|
arch/x86_64/pci.rs | new, 5 lines | PCI config mechanism #1 port I/O (outl/inl + config_read32) to locate the xHCI controller. |
arch/x86_64/mod.rs | 33 → 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.rs | 29 → 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.
| Area | Result |
|---|---|
| Policy violation | Fixed - unsafe removed from ipc/ (§18.1); moved to SpinLock::ZEROED in smp/spinlock.rs. |
| Safety / correctness lints | 0 - 11 unnecessary unsafe, 11 static mut refs (→ addr_of!), 14 fn-item→int casts, 6 no-op mem::forget. |
| Cruft removed | orphaned page_fault_handler + INTERRUPTED_* statics. |
| Inventory | reconciled below - 302 lines / 23 files, passes clean; task/scheduler.rs 37 → 36 (under floor). |
| Kernel warnings | 104 → 57 (rest intentional unwired architecture). |
| Hardware | boots clean on T630, cross-core ping/pong to 83k+ msgs, zero #PF/panic. |
Policy (§18)
unsafe is permitted only in:
| Permitted layer | Path |
|---|---|
| Architecture | kernel/src/arch/ |
| Memory | kernel/src/memory/ |
| Capability table | kernel/src/capability/ |
| SMP | kernel/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:
- Add
// SAFETY: <argument>on the line immediately above it in the source. - Increase the count for that file in the inventory table below.
- Add a SAFETY argument entry under that file in the Entries section.
- 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/) | Count | Layer |
|---|---|---|
| arch/aarch64/mod.rs | 75 | permitted |
| arch/aarch64/sched_user.rs | 4 | permitted |
| arch/aarch64/uart_rx.rs | 3 | permitted |
| arch/aarch64/sdio.rs | 3 | permitted |
| arch/aarch64/exceptions.rs | 17 | permitted |
| arch/aarch64/uaccess.rs | 7 | permitted |
| arch/aarch64/context.rs | 8 | permitted |
| arch/aarch64/sched_demo.rs | 5 | permitted |
| arch/aarch64/ctxdemo.rs | 7 | permitted |
| arch/aarch64/gic.rs | 7 | permitted |
| arch/aarch64/timer.rs | 5 | permitted |
| arch/aarch64/mmu.rs | 24 | permitted |
| arch/aarch64/ptables.rs | 28 | permitted |
| arch/aarch64/usermode.rs | 16 | permitted |
| arch/aarch64/mailbox.rs | 4 | permitted |
| arch/aarch64/memmap.rs | 8 | permitted |
| arch/aarch64/video.rs | 2 | permitted |
| arch/aarch64/genet.rs | 1 | permitted |
| arch/aarch64/pcie.rs | 4 | permitted |
| arch/aarch64/smp_boot.rs | 9 | permitted |
| arch/arm/exceptions.rs | 23 | permitted |
| arch/arm/context.rs | 6 | permitted |
| arch/arm/context_switch.rs | 13 | permitted |
| arch/arm/dtb.rs | 6 | permitted |
| arch/arm/irq.rs | 17 | permitted |
| arch/arm/meminit.rs | 4 | permitted |
| arch/arm/mmu.rs | 8 | permitted |
| arch/arm/video.rs | 17 | permitted |
| arch/arm/bootcon.rs | 2 | permitted |
| arch/arm/page_tables.rs | 37 | permitted |
| arch/arm/sched_demo.rs | 6 | permitted |
| arch/arm/sched_ipc.rs | 9 | permitted |
| arch/arm/spawn.rs | 4 | permitted |
| arch/arm/syscall.rs | 5 | permitted |
| arch/arm/usermode.rs | 17 | permitted |
| arch/arm/timer.rs | 7 | permitted |
| arch/arm/mod.rs | 54 | permitted |
| arch/loongarch64/mod.rs | 24 | permitted |
| arch/riscv32/mod.rs | 24 | permitted |
| arch/riscv64/fdt.rs | 3 | permitted |
| arch/riscv64/sbi.rs | 12 | permitted |
| arch/riscv64/sv39.rs | 29 | permitted |
| arch/riscv64/trap.rs | 7 | permitted |
| arch/riscv64/syscall.rs | 1 | permitted |
| arch/riscv64/context_switch.rs | 35 | permitted |
| arch/riscv64/usermode.rs | 17 | permitted |
| arch/riscv64/uaccess.rs | 7 | permitted |
| arch/riscv64/display.rs | 6 | permitted |
| arch/riscv64/mod.rs | 64 | permitted |
| arch/s390x/mod.rs | 19 | permitted |
| arch/x86_64/ap_boot.rs | 3 | permitted |
| arch/x86_64/boot.rs | 103 | permitted |
| arch/x86_64/context_switch.rs | 11 | permitted |
| arch/x86_64/fb.rs | 2 | permitted |
| arch/x86_64/interrupts.rs | 26 | permitted |
| arch/x86_64/ioapic.rs | 9 | permitted |
| arch/x86_64/acpi.rs | 5 | permitted |
| arch/x86_64/iommu.rs | 79 | permitted |
| arch/x86_64/mod.rs | 36 | permitted |
| arch/x86_64/page_tables.rs | 51 | permitted |
| arch/x86_64/pci.rs | 22 | permitted |
| arch/x86_64/rtc.rs | 1 | permitted |
| arch/x86_64/syscall_entry.rs | 14 | permitted |
| capability/table.rs | 7 | permitted |
| memory/allocator.rs | 48 | permitted |
| memory/frame.rs | 1 | permitted |
| memory/mod.rs | 1 | permitted |
| memory/page.rs | 1 | permitted |
| smp/ipi.rs | 4 | permitted |
| smp/mod.rs | 1 | permitted |
| smp/percpu.rs | 6 | permitted |
| smp/names.rs | 4 | permitted |
| smp/spinlock.rs | 5 | permitted |
| interrupt/route.rs | 1 | grandfathered |
| loader.rs | 1 | grandfathered |
| main.rs | 2 | grandfathered |
| syscall/dispatch.rs | 2 | grandfathered |
| task/mod.rs | 7 | grandfathered |
| task/scheduler.rs | 37 | grandfathered |
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.pyindependently 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 thearch/x86_64/pci.rs19 → 20 (C8-1, 2026-08-14): onerdtsc()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 repeatingunsafewould have grown the audited surface fourfold for one read of a CPU counter.arch/x86_64/pci.rs17 → 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 (commitffe1a0f).memory/allocator.rs32 → 37: one from the page-table reclaim guard (phys_in_ram, commitb9dbc4c) 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.rs7 → 9: a secondwithout_interruptsguard 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 (allarch/, each line already carrying a SAFETY comment - no policy issue, the counts just weren't bumped as the work landed):arch/x86_64/interrupts.rs13 → 21 (USB MSI ISR plumbing),arch/x86_64/pci.rs15 → 17 (MSI-X table mapping), and the previously-unlisted filearch/x86_64/ioapic.rs(+8, IOAPIC MMIO register reads/writes for legacy-INTx routing).smp/spinlock.rs5 → 7 (thewithout_interruptscli/sti added for the kstack-lock irqsafe fix).task/scheduler.rs40 → 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 fromstatic mut(TASK_ENDPOINT,TASK_LAST_BADGE) toAtomicU64, making themunsafe-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_pioretired - 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.rs38 → 34 (theport_in8/16,port_out8/16wrappers removed;inb/outbstay - used by serial + reboot), andcapability/hw_pio.rsdeleted (−3). Back to 413/27. ThePortRead/PortWritesyscalls and the SDKpio.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 wrappersport_in8/16+port_out8/16(thein/outasm, isolated in the arch layer; callers validate the port first). New filecapability/hw_pio.rs+3 (permitted): the per-taskhw_piogrant store (set/clear/allowed) - placed in the capability layer on purpose, so the per-task port-range state does not grow the grandfatheredunsafefloor intask/(§18.5).task/scheduler.rsandsyscall/dispatch.rsgained nounsafe(they call the safe wrappers / the capability-layer functions).
2026-06-10 (branch
feat/iommu-dma-confinement). New filearch/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 hardwareunsafeis contained here behind the safe wrapperconfine_device();task/mod.rscalls it without any newunsafe(its grandfathered floor of 7 is unchanged). See thearch/x86_64/iommu.rsentry 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 onmain(boot.rs, mod.rs, interrupts.rs, ipi.rs, allocator.rs).smp/spinlock.rs+1 is the newZEROEDconst (below). Reductions: the static-analysis pass removed unnecessaryunsafeblocks (ap_boot, boot, mod, scheduler) and the orphanedpage_fault_handler/INTERRUPTED_*diagnostics (interrupts.rs net still up from the AMD work).task/scheduler.rsis 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: writesbppbytes (one alignedu32store on the 32bpp fast path) at a bounds-checked offset (x<width,y<height).fill_rect: fills aw x hrectangle clamped towidth/height, writing each row as a contiguous run (alignedu32stores on the 32bpp path). Sound: the clamped rect stays inside the mappedheight*pitchregion;x*bpp/pitchare 4-aligned on the 32bpp path.draw_glyph(fast 32bpp path): writes each glyph output row as one contiguous run ofcwalignedu32stores. 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 checkedfill_rect), so the unchecked run stays withinheight*pitch, andx0*4/pitchare 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, whichbringupmaps 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_tablefrees only the page-table frames (reached top-down from a root thatrelease_devicehas 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 byCMD_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, ansfence(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_entrychain - 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 PTEinvlpg) - marks a 4 KiB page non-present; no-ops on a large page (fails safe).
unmap_4k_strided(base, stride, count)- a safefnthat unmaps the low page of each kstack slot viaunmap_active_4k; the guard-unmap loop moved here fromtask/(§18.1 - page-table work belongs in arch) so it adds no grandfathered unsafe. Boot-ordering contract (BSP, before APs).harden_hhdm_nx()- a safefn(CR3 read + HHDM subtree walk OR-ing NX into every present PDPT/PD/PT, then CR3 reload) that flips the HHDMNO_EXEC, closing the Limine-mapped RWX direct map (§3.12). Boot-ordering precondition (aftersmp::init), not UB - hence safe; the CR3/PTE work inside staysunsafe {}.
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)(anunsafe fn+ oneunsafe { free_frame }) - frees one physical frame by address during task-death teardown.reclaim_user_framesnow frees each leaf / PDPT / PD / PT frame INLINE viafree_phys_frame(four call sites) instead of collecting into the fixed 512-entryReclaimBuffer, whosepushsilently DROPPED - i.e. LEAKED - every frame past 512 (a 32 MiBalloc_memtask 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 innerunsafe {}block - 32-bitout dx, eaxport write.unsafe fn inl/ its innerunsafe {}block - 32-bitin eax, dxport read.unsafe {}inconfig_read32- pairs anoutl(address)theninl(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 asconfig_read32), themap_in_active_tablescall mapping the EHCI MMIO page to read HCCPARAMS, and theread_volatileof 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 {}incmos_read- wraps anout dx, al(select register) followed by anin al, dx(read its value); the two asm blocks are notpure, 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, andunsafe { 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 (thechaos max-carnagefreeze).unsafe impl Send for SpinLock<T>: sound because the atomic spinlock serialises all access toT;T: Sendis 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() }inDeref: sound because the lock is held (we have aSpinLockGuard); no other reference to the inner data can exist simultaneously.unsafe { &mut *self.lock.data.get() }inDerefMut: same reasoning for mutable access.pub const ZEROED: Self = unsafe { core::mem::zeroed() }: all-zeroes initializer for placing a largeSpinLock<T>in.bsswithout the undef padding bytes that LLD rejects there. Sound only when the all-zeroes bit pattern is a validT- the caller's responsibility via theTinstantiated. Replaces acore::mem::zeroed()that previously sat inipc/routing.rs(outside the permitted layers); moving it here keepsipc/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 remainunsafebecause it processes untrusted register values from user space.unsafe { map_in_active_tables(va, phys, flags) }insidehandle_alloc_mem: sound becausevais in the task heap region (above0x1_0000_0000);physis 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 standaloneunsafeincurrent_task_slot(); accesses updated to.load()/.store().CORE_RR_SLOT→[AtomicUsize; MAX_CORES]: removed both standaloneunsafeblocks inpick_next().CORE_PENDING_KSTACK_LEN→[AtomicUsize; MAX_CORES]: removed both standaloneunsafeblocks indrain_pending_kstack().TASK_KERNEL_STACK_TOP→[AtomicU64; MAX_TASKS]: removed the standaloneunsafeinprepare_ring3_switch().TASK_VALID→[AtomicBool; MAX_TASKS]: removed the standaloneunsafe { TASK_VALID[slot] = false; }inrelease_task_slot()and the inlineif !unsafe { TASK_VALID[slot] }infor_each_active_cap(). All stores useReleaseordering; the lock-freefor_each_active_capread usesAcquireto pair withReleasestores and ensure cap table visibility; all reads inside lock-protected regions useRelaxed.CORE_PENDING_PML4isAtomicU64so its load/store sites are safe - only theFrame::from_phys+free_framepair and thesend_ipicall neededunsafeblocks.
One remaining line in for_each_active_cap is still unsafe:
unsafe { TASK_CAP[slot].assume_init_ref() }.for_each_slot(&mut f)- reads aMaybeUninit<CapTable>afterTASK_VALID[slot].load(Acquire)returnedtrue. Sound because theAcquireload pairs with theReleasestore inreserve_task_slot/enqueue, establishing that theCapTablewrite happened-before this read.CapTablecannot be const-constructed soMaybeUninitis necessary;assume_init_refis 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).
| file | count | what |
|---|---|---|
arch/x86_64/syscall_entry.rs | 15 -> 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.rs | 49 -> 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.rs | unchanged | the new copy_user_to_kernel is a SAFE wrapper over the existing private copy_from_user. |
loader.rs | 2 -> 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.
| Block | Argument |
|---|---|
unsafe impl Sync for NameTable | Every 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:
| Line | Purpose | SAFETY argument |
|---|---|---|
mask_usb_irq | write bit 9 to IC_DISABLE_IRQS_1 | volatile 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_irq | write bit 9 to IC_ENABLE_IRQS_1 | as above, against the enable register. Reached from the IrqUnmask syscall. |
route::deliver(USB_VECTOR) | hand the IRQ to the registered endpoint | called 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_linesis LIVE, and once it returns the stack unwinds and ordinary prompt code reuses those addresses. The final run took a PERMISSION FAULT atSP_EL0 == FAR_EL1with 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 anddocs/console-service.md9.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:
| Block | Why 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
LICENSEfiles: the OS is GPL-2.0-only (repo-rootLICENSE) and the SDK + examples are Apache-2.0 (sdk/LICENSE). Per-fileSPDX-License-Identifiertags mark every source file's zone. This document records the strategy and rationale behind that text.Where the terms are written.
sdk/LICENSEcarries the Apache-2.0 text for the SDK (sdk/rust), the standard library (stdlib/rust) andexamples/; the last two have noLICENSEfile of their own and are marked by their SPDX tags. The protocol crates beside the SDK -sdk/wifi,sdk/tlsandsdk/audio- are GPL-2.0-only, like the OS, and their tags say so.scripts/spdx_check.pychecks on every build that each tracked.rsfile carries a tag, and that the tag is its tree's licence.
1. The decision
| Component | License | Why |
|---|---|---|
The OS - kernel + trusted root (supervisor, arch, smp) + system services (events, block-driver, fs, drivers, shell) + host tooling (osdev) + tests | GPL-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.0 | Services 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.0 | They are reference apps meant to be copied as the starting point for real services; permissive keeps that copy unencumbered. |
| Userspace services / apps | Any 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:
- 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.
- 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.
- Where the
LICENSEfiles live: repo-rootLICENSE(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. - 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:
LICENCEbeside 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.pyenforces 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-stackandnic-driverare 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'snet/pingall 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 intoCLAUDE.md. This doc records the architecture and the phased plan, mirroringdocs/persistence.md.Note (2026-10-09): TCP is built too.
services/net-stack/src/tcp.rsimplements 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/recvgo through it. net-stack↔nic-driver: a NIC-agnostic frame interface - "transmit this raw Ethernet frame", "here is a received frame".net-stackis 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-driveris spawnedhwclass::pci(0x02_00_00, BAR_AUTO, false)- the third argument isconfine, and it isfalse(services/supervisor/src/main.rs, thenic-driverspawn row; on the boards whose NIC is not on PCI it ishwclass::NIC, with no confinement either).xhciandaudio-driverare the only in-tree drivers spawned confined (an installed driver package's PCI device is asked for confinement too, CLAUDE.md 14.1);ehciandblock-driverare deliberately left in passthrough because they keep a stale firmware DMA pointer that confinement would fault (kernel/src/task/mod.rs, theconfineflag onHwClass::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. Allunsafelives behind the SDK's auditedMmio/Dmawrappers (§18.1) - the driver itself isunsafe-free. - Model-specific, like AHCI. The dev driver is e1000 (Intel 82540EM) - exhaustively documented
and emulated by QEMU (
-device e1000). (virtio-netis 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::initnow 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 printsvendor=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 printedMMIO=0xe000is 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 (
binda UDP port, orconnectto a peer): the service asksnet-stack, whichresource_mints a fresh socket capability (a delegated resource cap, §7.10) with chosen rights (SEND/RECV), recordsSocketId -> (proto, local, remote), and hands a narrowed copy to the service - the exact movefsmakes onOpen. - 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 opaqueSocketId(theLastRecvBadgemechanism, §22 Test 14).net-stackbuilds the datagram and hands the frame tonic-driver. The kernel never learns this is a packet - it routes an opaque resource.
- required right) and routes the message to
- Recv. Incoming datagrams are matched to a socket by
net-stackand delivered to the holder's recv endpoint. No socket cap, no delivery. - Close = revoke.
net-stackresource_revokes the socket (generation bump, §7.5); every outstanding cap to it goes stale - the next use returnsCapRevoked. 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-stackowns 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 ofnet-stack- a real lease + gateway is HW-proven on the T630, §11 Phase 3), with static config still the fallback. - Ports are resources
net-stackallocates. 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 <-> MACon the local segment (a tiny cache + request/reply). Needed before any IP frame can be addressed on the LAN. The shell surfaces it asnet arp <ip>(resolve one host) andnet 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.
pingis 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 anet-stackrestart 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-stackrestart 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, andnet-stackreacquires it by name and retries (§14.3). What does not follow is the TCB drop - that needs confinement, and it is spawnedconfine=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, reacquirenet-stackby name, and re-bind- the §14.3 client-recovery pattern. The host IP is config, re-read on mount. - TCP is stateful: a
net-stackrestart drops live connections - the peer sees a reset, the holder reconnects. We deliberately do not build a "TCP journal" (the wayfsgot 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.
- UDP is stateless: a restart loses nothing structural. Sockets are caps; holders see
- 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
| Event | Result |
|---|---|
send on a closed socket | CapRevoked (generation mismatch) |
recv on a dead net-stack | EndpointDead |
| UDP datagram sent | queued/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 fails | send fails loudly after a bounded retry; never an indefinite hang (§26.6 bounded) |
net-stack / nic-driver death | endpoint 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)
| Phase | Deliverable | Milestone / test | Status |
|---|---|---|---|
| 0 | PCI-enumerate + print the NIC (vendor:device, BARs, IRQ) | We learn the T630's chipset from one boot line | ✅ done |
| 1 | nic-driver (e1000): raw Ethernet TX/RX via DMA rings + RX IRQ | Send a raw frame; receive a raw frame (host-side listener / loopback) in QEMU | ✅ done |
| 2 | ARP + IPv4 + ICMP in net-stack | ping the host end to end - the networking ping/pong | ✅ done + HW-proven |
| 3 | UDP + socket-as-capability (resource_mint/badge/revoke); a net shell utility | A 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) |
| 4 | The Realtek RTL8168 driver (10ec:8168, the T630's NIC), same frame interface - HW-only, no QEMU model | ping from bare metal on the T630 | ✅ done + HW-proven |
| 5 | TCP - committed next (§8, no longer far-future) | A TCP echo against a real peer | built (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
| Decision | Section |
|---|---|
| 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 = location | invariant 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)
- 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. - 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. - 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.
- IPv4 only to start, IPv6 far-future - agreed?
- 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.
- One
net-stackservice owning IP + all protocols, vs splitting (e.g. a separateudpservice)? Recommendation: onenet-stackfor now (whiteboard-simple); split only if a real reason appears (§26.2).
15. Chaos and link resilience (hardware-proven, 2026-07-07..09)
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 disciplineblock-driver/fsgot.
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-stackruns its configuration dance automatically - ARP announce, then DHCP DISCOVER/REQUEST to a lease. No command, no reboot; plugging in the cable is the trigger. pingrides an unplug/replug. A runningpingdoes 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 instantqabort throughout (the utility never blocks un-abortably on a dead link - Commandment VIII, and the "[q] quit" utility convention).netreflects the live link.netreads carrier + lease state at call time, so it never shows a stale "up" for a cable that is out.net renewrecovers in place. A link that came up mis-configured, or a lease that expired, is repaired by re-running the configuration dance inside the runningnet-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.
timepursues the clock, as it owns it: its nudge (op 11) starts a BACKGROUND query.net-stacksends it and returns to the loop; the poll step, which already drains frames everyPOLL_MS, records the answer; the loop hands it totime, 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'stry_recvclear (used bylink_is_up) discards capless messages, and the unsifted waits indns_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, andtimere-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.
| Event | Phase | send_remote result | Delivery state |
|---|---|---|---|
| Remote node unreachable | Before transport handoff | Err(RemoteNodeUnreachable) | Definitively not delivered |
| Timeout expires | Before transport handoff | Err(RemoteTimeout) | Definitively not delivered |
| Remote endpoint dead (generation mismatch) | Any | Err(RemoteEndpointDead) | Definitively not delivered |
| Remote queue full, timeout expires while waiting | Waiting for queue space | Err(RemoteTimeout) | Definitively not delivered |
| Transport error | After handoff, before remote ack | Err(RemoteTransportError) | Unknown |
| Timeout expires | After handoff, before remote ack | Err(RemoteTimeout) | Unknown |
| Remote service crashes after receipt | After delivery | Ok(()) already returned | Delivered; processing unknown |
| Success | Transport handoff complete | Ok(()) | 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_remotecall 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:
- 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.
- Node membership tracking. What nodes are in the cluster? Who decides? How is the membership list kept consistent across nodes?
- 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? - 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.
- 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:
- 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.
- 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.
- 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
| Question | Where addressed | Blocking? |
|---|---|---|
| Transport protocol choice | §8 | Yes - shapes failure semantics and ordering |
| Cluster membership and cert trust model | §8.1 | Yes - mTLS alone is insufficient; membership check needed |
| Name-directory consistency model | §7 | Yes - defines what remote_send_cap("name") can guarantee |
| TCB authority model across nodes | §9 | Yes - cannot ship without this resolved |
| Flow control / backpressure primitive | §5 | No - deferral forces app-level rate limiting; tradeoff stated |
| Ordering guarantee level | §6 | No - "transport-dependent" with documentation is acceptable |
| Delivery acknowledgment semantics | §4 | No - "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.

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.

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.

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.

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.

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.

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.

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 ownCLAUDE.mdexplaining why it is built the way it is, grounded in a Commandment. Copyexamples/00-helloto 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 anything | COMMANDMENTS.md, then skim CLAUDE.md |
| Write a new userspace service | GETTING_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 machine | godspeed-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 syscall | kernel/src/syscall/CLAUDE.md, then syscall/mod.rs |
| Add a new CPU architecture | kernel/src/arch/CLAUDE.md (the seam + the five-place checklist) |
| Change capability or generation logic | kernel/src/capability/CLAUDE.md |
| Touch IPC or routing | kernel/src/ipc/CLAUDE.md (and bring a benchmark - the fast path may not change without one) |
| Add or change a test | tests/CLAUDE.md, then the category under tests/qemu/ |
| Know the full law and the instant-reject list | CLAUDE.md (the constitution; section 21 is the reject list) |
| Check my change against the anti-patterns before I open a PR | docs/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 known | backlog/ - 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 this | Then read this commit | What to copy from it |
|---|---|---|---|
| Write a service | examples/counter (restart with state), then services/power - a whole real service on gs | 7dfe3fbd - every service moved onto gs | Use 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' jack | 13be2d55, then 3ac042c2 - the Pi 4's one underrun per tone | Measure before fixing: a one-line instrument first, the fix second, each with a prediction, each tested on the board |
| Support new hardware in a driver | services/audio-driver - Intel HD Audio, its PLAYABLE table of verified codecs | 3787e6dc (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 scratch | examples/driver-skeleton and examples/e1000 | - | The grant, the interrupt, the re-arm, the serve loop, restart from cold |
| Change the kernel | kernel/src/task/mod.rs pci_dev, kernel/src/arch/x86_64/pci.rs bar_len | 7956c174 (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 utility | utilities/57_audio.md (the spec) and utilities/0_conventions.md | 0c31c4d4 - Ctrl+Alt+Up, Down and M | Spec first, then the shell, its help, its test and the docs in one change |
| Add a test scenario | osdev/src/shell_test.rs boot_audio, run by osdev test audio | 39746569 - a decoy controller placed FIRST on the bus, so the test fails if the wrong device is granted | Build the situation that would expose the bug, not the one that passes |
| Fix documentation | audits/documentation-audit.md | fc95b1d7 (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.py | ae1b6fc4 (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:
| Commit | What chaos exposed |
|---|---|
c51245c6 | A 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 |
bdc7adaa | On 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 |
6737ae52 | On the Pi 2 the kernel's page-table arena ran out with RAM free: after a 1000-round run every new program was refused |
b2a54655 | The 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 -
selfcheckfails 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 bootholds only the boot, and the supervisor'shardware eventsrecord 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_withinhas 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
unsafewithout a// SAFETY:comment, or outside the permitted layers (section 18). - Changes the IPC fast path without a benchmark, or edits
CLAUDE.mdwithout 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_CHECKSinosdev/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-okmarker 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.md22.7 puts it in terms: a gate that fires with an unhelpful message is a finding, not a pass. That is whytests/conformance/ui/holds the exact text of every diagnostic andpy scripts/conform.py --selftestcompares 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>::) outsidearch/. This is enforced byscripts/arch_boundary_check.pyon everyosdev build- a violation means a neutral file made an arch-specific assumption, and the fix is to add anarch::impprimitive, never to special-case an arch at the call site. - Never use
core::sync::atomic::AtomicU64directly - importportable_atomic::AtomicU64. That one dependency is the entire cost of 32-bit support (32-bit RISC-V has no 64-bit atomic); reaching for thecoretype 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.