OS/3 microkernel — from-scratch, capability-secured, preemptive, ring-3, stable Rust
Find a file
Hannes Lehmann b3b1b4c9ce feat(os3dos,scope,layout): the machine reads who is at which terminal
Build order step 3, first half: `/system/security/users.cfg` exists, `os3dos`
reads it, and each session says whose it is. Terminal 1 comes up as `admin` and
terminals 2-4 as `guest` — the first thing on this machine that differs between
two sessions.

**`scope::roles` is the reader**, beside the scope arithmetic rather than in a
crate of its own: a role IS a set of scopes, and `parse_list` is what turns its
`files` line into them. Three lookups over `key = value` text and no table — a
parsed structure would need an allocator or a ceiling on users, for a file read
once per session rather than per request.

**The file is NOT under `configs/`**, and this page's own §9.3 is why: an
administrator who may write every config could otherwise make a guest an admin,
and the boundary would be decorative. A prefix grant has no EXCEPT, so what not
everyone-with-the-prefix may touch lives in its own subtree.

**And the VT-to-user assignment lives in it too — the design said `os3dos.cfg`
and the design was wrong.** That file is in `configs/`, so whoever may edit it
decides who sits at terminal 1, which is the admin role one indirection later.
The same hole §9.3 found, one file over. §5 records the correction.

**`os3dos` writes `session::KEY_USER`; `console.sys` stopped.** The console owns
that page and every other key in it, but the user is not its fact to know — this
root is the only component that may read `users.cfg`. One key, one writer, which
is the rule the context has always followed. A terminal nobody is assigned to
gets NO key rather than a default: inventing an administrator for an unassigned
terminal is the one answer that could be dangerous.

**A layout test pins the shipped file to the shipped volume.** `users.cfg` is
data on the disk and `HOME`/`DEFAULT_USER` are constants, with no compiler
between them; a `vt.1` naming somebody else hands terminal 1 a home directory
that is not there, and the symptom is a first write answering "no such file"
about a path nobody typed.

Proven on a running machine, not inferred: the boot log says `terminal 1: admin`
and `terminal 2: guest`, and `set` on terminal 2 draws `user=guest` while
terminal 1 draws `admin`. The `disk` lane asserts BOTH lines — one alone would
also pass on a machine that gave every terminal the same user.

Also written down (docs/namespace-and-authority.md): §6.1 decides that
`exec.sys` tells `fs.sys` a pooled channel's scope, since it is the component
that hands the channel out and reclaims it; §5.3 states what add, change and
remove do, including the two gaps — nothing creates a new user's home directory,
and editing `users.cfg` needs a scope no session holds.

Host tests 2232/0; `disk`, `screen`, `parallel` and `complete` pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqjsJEjCB3rSc5RT9u9X7e
2026-09-03 18:40:17 +02:00
abi feat(kernel,exec,netd,listen): a wait that returns, a handler that can be cut, a channel that is vacated 2026-09-03 07:43:06 +02:00
apps fix(script,test): the test's address and the test's data leave the shipped image 2026-09-03 18:21:56 +02:00
backlog fix(listen,exec,net): the review of the server side — funding, lost bytes, lost completions, leaks 2026-09-03 00:52:02 +02:00
dist feat(script): the script language ships as an app — script.exe, examples, dist and boot lane 2026-09-03 11:38:45 +02:00
docs feat(os3dos,scope,layout): the machine reads who is at which terminal 2026-09-03 18:40:17 +02:00
issues fix(layout,os3dos,exec): a file channel per command child, and a child can be refused for one 2026-09-03 16:36:51 +02:00
lib feat(os3dos,scope,layout): the machine reads who is at which terminal 2026-09-03 18:40:17 +02:00
review feat(diag): SYS_DIAG_SHUTDOWN — the boot can say it is finished 2026-08-28 23:50:51 +02:00
stage0 feat(kernel,exec,netd,listen): a wait that returns, a handler that can be cut, a channel that is vacated 2026-09-03 07:43:06 +02:00
stage1 feat(os3dos,scope,layout): the machine reads who is at which terminal 2026-09-03 18:40:17 +02:00
test feat(os3dos,scope,layout): the machine reads who is at which terminal 2026-09-03 18:40:17 +02:00
.gitignore fix(gitignore): dist/ holds only recipes — ignore stale build output left there 2026-09-01 14:06:15 +02:00
BACKLOG.md fix(listen,exec,net): the review of the server side — funding, lost bytes, lost completions, leaks 2026-09-03 00:52:02 +02:00
build.sh feat(script): the script language ships as an app — script.exe, examples, dist and boot lane 2026-09-03 11:38:45 +02:00
Cargo.lock feat(fs,layout,protocol): a file channel reaches what it was granted, and no more 2026-09-03 18:13:05 +02:00
Cargo.toml feat(script): the script language engine, v0.1 — apps/script/core, host-tested, not yet integrated 2026-09-03 00:07:41 +02:00
ci.sh refactor(layout,apps,test): the home directory is named for its user 2026-09-03 14:41:19 +02:00
CLAUDE.md feat(apps): add apps/ bucket + a BASIC interpreter (engine, REPL, hangman) 2026-08-31 22:11:29 +02:00
deploy.sh Merge branch 'main' into entropy-flag 2026-09-02 17:30:28 +02:00
iss fix(iss): --dry-run wrote files, and the claim that it could not was untested 2026-08-31 16:36:16 +02:00
ISSUES.md fix(layout,os3dos,exec): a file channel per command child, and a child can be refused for one 2026-09-03 16:36:51 +02:00
README.md feat(clip,console,shell): Super+V pastes into the input line, not into the output 2026-09-03 13:28:39 +02:00
rust-toolchain.toml Initial commit: OS/3 microkernel + session handover 2026-06-22 20:45:48 +02:00
STAGE0.md fix(layout): every path a person reads is lower-case, and a test holds it 2026-09-02 16:53:58 +02:00
STAGE1.md docs(ns,origin): the boot volume is projected at /, and data says where it came from 2026-09-03 13:27:16 +02:00
VISION.md fix(layout): every path a person reads is lower-case, and a test holds it 2026-09-02 16:53:58 +02:00

OS/3

A capability-based microkernel operating system for x86-64, booted from UEFI.

This project is being redesigned greenfield. The current focus is agreeing the architecture from first principles, one stage at a time, on paper before code. Stage 0 is settled — see STAGE0.md.

The idea

Most operating systems fuse two things that don't have to be fused: the low-level machinery that talks to the hardware (privilege, memory, interrupts, IPC) and the personality of the system (what a "process" is, how things are scheduled, what the filesystem looks like, which programs exist). OS/3 splits them cleanly into two layers.

Stage 0 — the microkernel

A tiny, permanent, privileged substrate. It boots from UEFI, holds all authority in the machine, enforces capabilities, and exposes a small set of primitives: address spaces, execution contexts, IPC, and interrupt delivery.

It is deliberately opinionless. It has no scheduler policy, no notion of a "process", no filesystem, and no device drivers. It is mechanism, never policy. It does not run the system — it is the thing the system runs on. It executes only when the machine traps into it, and it stays resident for the entire life of the machine.

Stage 1 — the personality

The actual operating system, built entirely on the microkernel's primitives, in ring 3. This is where a "process", a scheduler, a filesystem, drivers, and programs live. Because the microkernel is opinionless, a personality is free to be anything — a completely different model of tasks and scheduling than any other personality — and still run unchanged on the same stage 0.

The personality lives on its own disk, in its own filesystem. The microkernel never touches it.

The vision

  • A microkernel that is a substrate, not a kernel. All authority, minimal mechanism, zero policy. Small enough to reason about; permanent enough to trust.
  • Personalities as first-class, swappable OSes. The microkernel boots whichever personality its config points at. Change the config, boot a different world — the substrate does not care and does not change.
  • Everything above ring 0 is userland. Drivers, filesystems, schedulers, shells — all ring-3 programs talking to the substrate through capabilities. Nothing privileged that doesn't have to be.
  • Clean seams with single owners. Every boundary in the system has exactly one owner. The design is agreed on paper before it is built, so the seams stay clean instead of accreting.

How it boots (the short version)

  1. UEFI firmware loads kernel.efi off the FAT EFI System Partition.
  2. The microkernel reads one more file off the ESP through firmware — bootstrap, a small RAM blob whose only job is to reach the real disk.
  3. The microkernel mints the root authority, starts bootstrap's init in ring 3, and drops firmware — then stays as the permanent substrate.
  4. bootstrap's init brings up a disk driver, reads the personality off its own disk, and hands over. bootstrap is spent.
  5. The personality runs the machine, on the microkernel's primitives.

Full detail, invariants, and the boundary rules: STAGE0.md.

Project layout

The tree mirrors the conceptual model, not the build history. Five buckets, each with one owner:

  • stage0/ — the microkernel (kernel/) and its transient reach-the-disk helper (bootstrap/: init + block + fs + archive).
  • stage1/ — the personality layer (the userland OS: exec, shell, drivers, programs). It is the layer; today's single DOS-style personality sits flat here. A per-personality subdir arrives only when a second personality does.
  • lib/ — pure, host-tested mechanism shared across both stages (FAT, ELF, font, bits, log). The server that wraps a library is policy and lives in its stage.
  • abi/ — the syscall-surface contract, kernel-owned, at the top of the tree so nothing depends upward.
  • apps/ — applications that run on the personality but are not part of it (a BASIC interpreter, say). An app depends on the OS's public surface (abi, lib/prog); nothing in stage0/ stage1/ lib/ abi/ may depend on apps/. Each app is a self-contained bundle: /apps/<name>/<name>.exe + its data.

One Cargo workspace; directories are for humans. Full rules and the "which bucket does this crate go in?" procedure: docs/organization.md.

Building

Two stages, composed by a dist — a named recipe (a personality + a chosen app set + a boot mode), the way a Linux distribution selects packages over a base:

./build.sh dist dev       # OS + the BASIC app  ->  out/dev/{esp,os3.img}
./build.sh dist minimal   # the OS alone
./build.sh dist           # every recipe in dist/
./build.sh os             # just the OS image (out/os3.img)

Recipes are tracked in dist/*.dist; output goes to out/ (gitignored). Recipe format, boot modes, how an app declares capabilities: docs/dist.md.

To hand a machine to somebody, pack a dist into a bundle that needs only qemu — the two disks, the UEFI firmware and a run.sh, with no host or path baked in:

./deploy.sh                    # the `full` dist -> out/bundle + os3-bundle.tar.gz
./deploy.sh --dist minimal     # the OS alone
./deploy.sh user@host:dir      # ...and ship it there, verified by the image's sha256

Making a new program LAND IN THE IMAGE

A crate that compiles is not automatically on the disk. A program is either a system program (part of the OS) or an app (runs on it), and the two land different ways — this is the step most often missed:

  • A system program (dir, showfile, … — ships with the OS):

    1. a crate under stage1/bin/<name>/ ([[bin]] name = "<name>");
    2. name its path in lib/layout/src/paths.rs — a PROGRAMS entry, and both SHIPPED and PLACEABLE. mkdisk REFUSES a path the layout does not name, which is exactly why an unwired .exe silently never appears;
    3. a VOL+=("/system/bin/<name>.exe=stage1/bin/<name>/target/…/<name>") line in test/boot.sh — the boot lanes and build.sh read the shipped set from there, and deploy.sh ships what build.sh produced. The host tests placeable_covers_every_named_path and a_program_is_an_exe_under_bin fail the build if the wiring is incomplete.
  • An app (basicnot part of the OS):

    1. a crate under apps/<name>/prog/ ([[bin]] name = "<name>", its own [workspace]);
    2. it lands via a dist, not the plain OS image: ./build.sh dist <name> (or a recipe with apps = <name> or apps = *) bundles it at /apps/<name>/<name>.exe. An optional apps/<name>/prog/caps file declares its capabilities (e.g. caps.want = fs).

Testing

./ci.sh fast    # everything a compiler can check              — ~4 min
./ci.sh net     # ...plus the eight lanes a network change can break — ~7 min
./ci.sh         # the full gate, 33 QEMU boot lanes and all    — ~16 min

fast runs the host tests, clippy (the workspace, the kernel across its seven feature sets, and all 35 ring-3 crates), both compile matrices, every audit and layering rule, and the image-size budget. It skips the three blocks that own the wall clock — the boot matrix, the mutation gate and the loom models — and names them in its last line.

fast boots nothing, which is why the middle tier exists. A change below the seam is judged by a lane or by nothing: the TLS arc's four defects — a lost TCP event, a socket held past its use, an ARP-dropped SYN, a session key from a guessable generator — were every one of them invisible to fast. ./ci.sh net is fast plus the lanes that cover the network, named in one table in ci.sh so "which lanes cover this?" has an answer that is not somebody's shell history.

A fast run prints CI FAST PASS, never CI PASS — and a group run prints CI NET PASS. A green that booted nothing, or only part of the machine, must not read like one that booted all of it; this tree has already shipped a lane that reported PASS twice with the feature under test switched off.

Run the full gate before landing. Every phase prints its own elapsed time, so what a run costs is measured rather than folklore — the boot matrix is ~450 s of it, mutation ~105 s, loom ~60 s, and everything else is seconds.

One lane on its own: test/boot.sh <lane>disk, screen, edit, net, … (the usage line at the top of test/boot.sh lists them all).

Unchanged env knobs: LOOM=0 drops the models from a full run, MIRI=1 adds the undefined-behaviour check, REVIEW=1 adds the LLM spec review.

Status

  • Stage 0 — designed and agreed (STAGE0.md), refinement of details in progress (BootInfo shape, entry-point config, the primitive syscall surface).
  • Stage 1 — not yet designed. Next once stage 0's frame is fully settled.

For contributors (and agents)

Read CLAUDE.md first. It enforces the stage-0 invariants and the design-before-code discipline. The point of the reset is first-principles reasoning — do not reconstruct the previous implementation to answer a design question.

Tracking work

Two lists, one file per item, managed by ./iss — see issues/_README.md for the full workflow:

  • issues/ → what is wrong right now (defects + decisions). Read ISSUES.md (generated — never hand-edit it).
  • backlog/ → what to build once the issue list shrinks. Read BACKLOG.md (also generated).
./iss ls [--backlog|--all]     ./iss add "title" [--backlog] [--section A2]
./iss show <id>                ./iss close <id>        # fixed: deletes + retires the id
./iss mv <id> --to issues      ./iss gen               # regenerate the two views

Ids are random (#k7f) so two sessions adding never clash; ./iss lint (a CI gate) holds the ids unique and the generated views current.