Skip to the content.

Zero unsafe, v1 (2026-10-10 to 2026-10-16): the record

Superseded on 2026-10-16 by docs/ZERO_UNSAFE.md section 0 (decisions 20-24). Kept whole as the record of how the first version was measured and why it was dropped: a listed core (unsafe-core.toml) with #[forbid(unsafe_code)] on every other module’s declaration, and the forbid/legacy ratchet. The tool that implements it (cargo xtask unsafe-report, lz check’s forbid rule) still runs until N2.4 replaces it with the crate groups. Nothing here is a rule.

1. Status and goal

Status: in force since 2026-10-10 (docs/PHASE2.md, “Decisions”; section 10 records the answers). The plan is iterative: each step is measured and this file follows what the steps teach. Every number below was measured on main at 72ea58e, with cargo xtask unsafe-report, grep over sys/ and one compile experiment.

The goal, as a tool can check it

  1. No unsafe outside the core. Every module outside a listed core compiles under #[forbid(unsafe_code)]. The compiler then refuses unsafe blocks, unsafe fn, unsafe trait and unsafe impl there, macro expansions included.
  2. The core is listed and budgeted. All unsafe lives in the paths of unsafe-core.toml (new): the hardware (sys/arch/amd64/, sys/arch/arm64/, sys/machine/), the boot code (sys/stand/, sys/lib/libsa/, a separate binary), the host test harness (sys/arch/host/) and a runtime of primitives (section 4). Every unsafe there has a // SAFETY: (clippy’s undocumented_unsafe_blocks is already denied workspace-wide, Cargo.toml:42). Each core row has a budget, and no budget goes up without the user.
  3. Legacy only shrinks. What lies outside the core and is not yet forbid is legacy (below). Its total never rises, an LZ sync aside (section 8), and it reaches 0 at N9.

Why: “fewer and smaller unsafe blocks” (goal 1 of docs/PHASE2.md) has no end point. A boundary the compiler checks has one, and every step towards it is a number from a tool.

What userland sees does not change. forbid and safe traits act at compile time only, and each slice keeps the ABI edge and passes the smokes and just diff-openbsd.

Why not literal zero

A kernel on real hardware must do what no type system can check: write device registers (MMIO), build page tables, switch contexts, enter and leave interrupts, copy across the user boundary (copyin, copyout) and hand out raw memory inside its allocator. Some code has to state those invariants by hand. The goal keeps that code small, listed, argued and reviewed, and keeps everything else out of it.

The legacy layer

Legacy is a state, not a directory: a module outside the core that does not compile under forbid yet. Today that is most of the kernel (section 2): the LZ modules not yet redesigned that still hold unsafe. They keep it for now. A module leaves legacy when the sweep marks it because it holds no unsafe (section 3), or when it is redesigned, which then means forbid (section 5). It never goes back: the count of forbid modules never falls.

Prior art

Others draw the same line:

2. Where the unsafe is today

cargo xtask unsafe-report at 72ea58e, kernel column (test code is counted apart):

Layer unsafe-report rows Total Fate
Core: hardware arch/amd64 863, arch/arm64 943, machine 85 1,891 stays, budgeted
Core: boot stand 235, lib/libsa 84 319 stays; a separate binary
Core: test harness arch/host 38 38 stays; host only
Core: runtime candidates the files of the next table ~250 stays, budgeted, shrinks
Kernel (everything else) net 1,579, kern 1,146, netinet 553, netinet6 355, sys 330, uvm 215, the 22 dev rows 3,975 (dev/pci 737, dev/ic 664 and dev/usb 528 the largest), the seven file-system rows 635, scsi 149, ddb 15, init 11, lib/libkern 10, crypto 5, conf 2 ~8,700 0
Total   11,228  

The kernel rows hold 8,980 in all. The runtime candidates sit inside kern, sys and lib/libkern; their row is a grep of unsafe tokens in the candidates’ code zones, not an unsafe-report count, and the kernel layer is what is left, about 8,700.

Runtime candidates (unsafe tokens in each file’s code zone):

Primitive Files
Statics sys/lib/libkern/staticcell.rs 11
Locks and reference counts sys/sys/mutex.rs 2, sys/kern/kern_lock.rs 8, sys/sys/rwlock.rs 2, sys/kern/kern_rwlock.rs 2, sys/sys/mplock.rs 0, sys/sys/refcnt.rs 1
Per-CPU data and SMR sys/sys/percpu.rs 7, sys/kern/subr_percpu.rs 17, sys/sys/smr.rs 8, sys/kern/kern_smr.rs 6
Allocators sys/sys/pool.rs 1, sys/kern/subr_pool.rs 58, sys/sys/malloc.rs 3, sys/kern/kern_malloc.rs 14
Lists and trees sys/sys/queue.rs 49 (6 blocks, 38 unsafe fn, the trait and its impl), sys/sys/tree.rs 23, sys/kern/subr_tree.rs 15
Copy, print, time sys/kern/kern_subr.rs 6 (uiomove), sys/kern/subr_prf.rs 7, sys/sys/systm.rs 1, sys/kern/kern_tc.rs 7

The atomics live in sys/machine/atomic.rs, already inside the hardware core.

Shapes to replace, kernel-wide:

Shape Count Target (section 4)
StaticCell<T> uses 626 in 117 files init-once cell, Mutex<T> statics, PerCpu<T>
unsafe impl Send and unsafe impl Sync 325 structs made of Send and Sync parts
Cell<*const T> and Cell<*mut T> 674 (0 left in queue.rs and tree.rs) state owned by its lock, owners’ collections
UnsafeCell 74 Mutex<T>, Rwlock<T>

The primitives are C-shaped. A lock holds no data (mtx_enter(&Mutex), rw_enter(&Rwlock, flags)), so what it guards sits elsewhere, in Cells. pool_get and malloc return Option<NonNull<u8>>: raw memory that the caller types and frees by hand.

Already safe at the signature, with the unsafe inside the core: copyin, copyout and copyinstr over slices (sys/machine/copy.rs:105), uiomove(&mut [u8], &mut Uio) (sys/kern/kern_subr.rs:93), bus_space_read_4 and bus_space_write_4 (sys/machine/bus.rs:195, sys/machine/bus.rs:210).

Day-one baseline. Of the 1,191 .rs files under sys/ (host, stand and mod.rs excluded), 423 hold no unsafe token anywhere, tests included (433 with the tests left out). One mechanical sweep can mark them forbid: about 35% of the files on day one, less the few that section 3 says must wait. For scale, the hardware core is about 9% of the lines: arch/amd64 40,372, arch/arm64 44,715 and machine 5,551 of the 1,002,358 under sys/, tests included.

3. The mechanism

The attribute goes on the mod declaration in the parent, never inside the module’s own file:

// sys/kern/mod.rs, line 89; today the line reads `pub mod sys_pipe;`
#[forbid(unsafe_code)]
pub mod sys_pipe;

Rust note: a lint level says what the compiler does when a lint fires. warn prints a warning, deny makes it an error, and forbid makes it an error that no #[allow] further in can lift.

A compile experiment (cargo check, rustc 1.98.1, a small crate with one forbid module) verified the two facts the design rests on.

Fact 1: forbid on the declaration reaches the out-of-line file. #[forbid(unsafe_code)] pub mod svc; in the crate root made a plain unsafe {} in svc/mod.rs an error (“usage of an unsafe block”). So the ratchet lives in the mod.rs files and the crate roots, which lineage.toml does not track (.claude/rules/lineage.md); the layout rule already keeps every mod declaration there (.claude/rules/rust-kernel.md). An inherited module can be forbid and stay byte-identical to LZ’s. Why there: an inner #![forbid(unsafe_code)] would change the module’s own file and break that identity. The attribute also covers the module’s inline mod tests, so the 10 files whose only unsafe is in their tests wait until those tests lose it.

Fact 2: a macro’s unsafe impl is refused. A macro of the same crate that expands to unsafe impl Trait is an error inside a forbid module (“implementation of an unsafe trait … in this macro invocation”). queue_adapter! expands to unsafe impl Adapter (sys/sys/queue.rs:160), and tree_adapter! expands to queue_adapter! (sys/sys/tree.rs:137): 267 and 37 sites in 133 and 18 files. An unsafe impl is the implementer’s promise that a contract the compiler cannot check holds (here: OFFSET is the offset of the field that entry returns), and forbid refuses that promise wherever it is written. Every unsafe impl Send and unsafe impl Sync (325 in sys/) is refused the same way.

What follows from fact 2:

Rust note: core::mem::offset_of!(Type, field) is a constant the compiler computes, the byte offset of a field inside its struct. It is stable and needs no unsafe; only dereferencing an element pointer computed from it does, and that stays in the core (docs/IDIOMS.md, the container_of row).

4. The core

The initial list

unsafe-core.toml (new, at the root) holds the paths and globs that may contain unsafe. cargo xtask unsafe-report and cargo xtask lz check read it.

Layer Paths Why it is core
Hardware sys/arch/amd64/**, sys/arch/arm64/**, sys/machine/** MMIO, page tables, traps, context switch, copyin/copyout, atomics
Boot sys/stand/**, sys/lib/libsa/** the boot loaders: a separate binary that runs before the kernel
Test harness sys/arch/host/** the host test double; host only
Runtime the files of the runtime table (section 2), and sys/lib/libkern/explicit_bzero.rs the primitives every subsystem builds on; their unsafe pays for the safe API above them

explicit_bzero.rs is not among the measured candidates, but it is redesigned (lineage.toml:4376) and keeps the volatile store that N1 left (docs/ROADMAP.md, N1), so “redesigned = forbid” (section 5) puts it here. The volatile wipe that docs/STATUS.md lists as a crypto follow-up is the same kind of store and would join it. Two rows stay out, as legacy like the rest: conf (2) and init (11, the freestanding init stand-in that unsafe-report also reads).

Rules of the list:

What it must expose

The runtime exists to give forbid modules a safe API. Its target shapes, each a row of docs/IDIOMS.md in the commit that first uses it (the candidates there name most of them):

Rust note: an unsafe fn declares a contract its caller must keep, so every call is written inside an unsafe { } block, where the caller takes that duty on. A module that calls one of queue.rs’s mutators therefore holds unsafe blocks and cannot be forbid.

Its rules

5. The metric

cargo xtask unsafe-report splits the kernel into three groups:

--check, in just ci, then enforces three things:

  1. the number of forbid modules never falls;
  2. the legacy total never rises (an LZ sync is the one exception, section 8);
  3. each core row stays within its budget.

The floor of item 1 and the ceiling of item 2 live in unsafe-budget.toml beside the rows, and --write moves them only in the good direction, as it already does the budgets. Beside them, legacy_baseline and baseline_date record the legacy total on the day the plan was adopted (8,748 on 2026-10-10): /progress measures its pace from them, and --write never moves them. The per-subsystem rows stay; outside the core their sum is the legacy total. --write also writes the Unsafe lines of docs/STATUS.md and of README.md (“Lineage and safety”): core, legacy, and forbid modules out of all modules.

Beside the gate, the report prints four shape counts per subsystem and per module: StaticCell uses, unsafe impl Send and Sync, Cell<*const T> and Cell<*mut T>, UnsafeCell. They gate nothing; the milestone exits name them where they matter (N2, N4). Why: a count of Cells says little alone, and a gate on it would reward moving them around.

“Redesigned” means “compiles with forbid”. cargo xtask lz check refuses a redesigned module outside the core whose declaration lacks the attribute, and an Adapter impl written by hand (section 3). Why: the status then states something the compiler checked, not only that the shape changed. An inherited module may be forbid as well (the sweep) and stays inherited.

Where it shows. cargo xtask lz status --write shows, per subsystem of the README table, the modules outside the core and the forbid ones among them, and a line for the core (its modules, its unsafe, the budget the rows leave it). /progress reads cargo xtask unsafe-report --json, the one parser of the sources, and shows per milestone the forbid modules out of the row’s non-core modules, the legacy modules and legacy unsafe left (the exit criterion’s number: a row is complete when both are 0), its core modules, and an estimate from the legacy unsafe removed per active day since the baseline. Why: the goal’s progress then sits where the redesign’s progress is already read, and the core, which keeps its unsafe by design, does not dilute it.

6. The order

Runtime first, then vertical slices per owner. A slice takes one owner, the module that owns a data structure (sys/kern/sys_pipe.rs for a pipe), with the header types it owns, and redesigns them to forbid in one go, once the core primitives it needs exist. Why: a type’s fields and their users change together; a horizontal pass (“every Cell out of sys/sys/”) would rewrite modules that later milestones own, and pay for it again in every LZ sync.

Before pipe: the two type-erased slots (decision 9, 2026-10-16)

File.f_data: Cell<*mut c_void> (sys/sys/file.rs:169, 16 files) and Knote.kn_hook: Cell<*mut c_void> (sys/sys/event.rs:446, 18 files) are the slots every file type and every kqueue filter casts from. A slice that keeps them needs a seam per slot (a tag-checked cast in kern_descrip, an index smuggled into kn_hook): unsafe moved, not removed. Both are under the 50-module timing rule, so they are typed first, as two slices of their own: f_data becomes an owned enum FileData dropped by fdrop (the direction of a pipe end rides in its variant); kn_hook a typed hook. Then pipe(2) needs no seam into file or kern_event.

Why pipe: it is small, comes from one C file, has four callers, every smoke with a pipeline exercises it, and it uses much of the runtime at once (a sleeping lock, a pool, uiomove, kqueue, SIGIO). It proves the core’s API on real code.

What it needs first, six items:

  1. Rwlock<PipeState>: the pipe’s state owned by its lock;
  2. Pool<Pipe>: pipes from a typed pool;
  3. an owned buffer: km_alloc returning owned pages instead of an address;
  4. a safe seam over the legacy kern_event (klist, knote);
  5. a safe seam over the legacy kern_sig (sigio);
  6. rwsleep and wakeup on the Rwlock<T> guard.

A seam is a safe function that a legacy module exports, with the unsafe inside it, so that a forbid caller can use the module before that module’s own slice. The ratchet still holds: the slice must take out more legacy unsafe than its seams put in.

The slice’s exit is part of N2’s (section 7): both files forbid, no raw-pointer Cell left in them, every smoke and just diff-openbsd equal with the new pipe step.

The warm-up alternative: msgbuf

sys/kern/subr_log.rs (695 lines, 12 unsafe, 5 StaticCell) and sys/sys/msgbuf.rs (7 Cell): 9 callers, dmesg in smoke-virtio and smoke-wscons, and imports of the sockets (sosend) and the kernel lock. It is smaller, and it exercises the statics (the init-once cell, Mutex<T> statics) more than the sleeping lock. Its coverage is two smokes, and it needs a seam into the socket layer. Pipe stays the recommendation for its broader coverage; msgbuf suits a smaller first step (decision 3).

Other owners measured, for the order after the first slice:

Owner Lines unsafe Callers
kern_timeout 1,198 35 93
kern_event 3,199 76 42
kern_task 714 19 46
kern_sensors 467 16 11

kern_timeout has too many callers for a first slice; a change to its API would also fall under the timing rule of .claude/rules/re-engineering.md (more than 50 modules).

7. Milestones

This replaces rows N2 to N8 of the table in docs/ROADMAP.md and adds N9. N0 and N1 stay as they are (met), and so do the order (leaves first, the security subsystems last) and the rolling LZ sync. Same columns; <n> is filled from the tools when a row starts (docs/ROADMAP.md, “Adding or changing a milestone”). A row’s scope is resolved as /progress resolves it, the most specific path winning, so a row’s numbers count only its own modules: sys/kern/vfs_* belongs to N5, and the files N8 names belong to N8, not to N4, N6 or N7.

Milestone Scope Exit criterion
N2 Core runtime (under way; was “Core structures”) First step, done: sys/sys/queue.rs, sys/sys/tree.rs and sys/kern/subr_tree.rs with typed links. Then: the metric in just ci (unsafe-core.toml, the core, forbid and legacy groups, the ratchet); Adapter a safe trait; the forbid sweep (~423 modules); the runtime’s safe API, each piece with its first user: Mutex<T> (sys/sys/mutex.rs, sys/kern/kern_lock.rs), Rwlock<T> (sys/sys/rwlock.rs, sys/kern/kern_rwlock.rs), Pool<T> (sys/sys/pool.rs, sys/kern/subr_pool.rs) and the init-once static; the verdicts of the 46 ABI-constant headers; the first slice, pipe(2): sys/kern/sys_pipe.rs, sys/sys/pipe.rs cargo xtask unsafe-report prints the core, forbid and legacy groups, and --check passes in just ci; forbid modules >= <n>; sys/kern/sys_pipe.rs and sys/sys/pipe.rs declared forbid, and grep -c 'Cell<\*' prints 0 for each; every smoke and just diff-openbsd equal, with the new pipe step
N3 Memory sys/uvm/ in owner slices (amap, aobj, map, the pagers; uvm_fault last, per security-review.md); the pmap interface in the core the uvm row holds 0 unsafe outside the core
N4 Processes and scheduling sys/kern/ by owner (timeout, task, event and kqueue, signals, proc and the scheduler), each with its header types the kern row holds 0 unsafe outside the core, apart from the modules of N5 and N8; StaticCell uses in kern: 0
N5 VFS and file systems sys/kern/vfs_*, sys/ufs/ first, then sys/isofs/, sys/msdosfs/, sys/nfs/, sys/ntfs/, sys/tmpfs/, sys/miscfs/ the ufs, isofs, msdosfs, nfs, ntfs, tmpfs and miscfs rows hold 0 unsafe outside the core; every sys/kern/vfs_* module forbid
N6 Network stack sys/net/, sys/netinet/, sys/netinet6/, except the files of N8 the net, netinet and netinet6 rows hold 0 unsafe outside the core, apart from the modules of N8
N7 Devices sys/dev/ and the bus layers (sys/scsi/), one bus directory at a time; a typed Mmio<Regs> and an owned DmaBuf in the core; drivers forbid; sys/dev/rnd.rs last (security-review.md) every dev row and the scsi row hold 0 unsafe outside the core, apart from sys/dev/softraid_crypto.rs (N8)
N8 Security subsystems Under security-review.md: sys/net/pf* (pf and pfkeyv2), sys/net/if_pf*, sys/net/if_wg.rs, sys/net/wg_*, sys/netinet/ip_ipsp.rs, sys/netinet/ip_esp.rs, sys/netinet/ip_ah.rs, sys/netinet/ip_ipcomp.rs, sys/netinet/ipsec_*, sys/dev/softraid_crypto.rs, sys/kern/kern_pledge.rs, sys/kern/kern_unveil.rs, sys/kern/exec_*, and the crypto framework (N1 did the primitives) every file of the scope forbid; Security-Review: on every commit; cargo xtask lz drift --security empty
N9 Close What no row claims (sys/ddb/, sys/conf/, the init stand-in) and the legacy left; the boundary made final: the core in a crate of its own, with #![forbid(unsafe_code)] at the root of the crate that uses it, or kept as the module list with every declaration outside it forbid (decision 5); Miri on the core’s host tests if adopted (decision 4) cargo xtask unsafe-report: legacy 0; with a core crate, the logic crate’s root carries #![forbid(unsafe_code)] and just build passes; the core’s size recorded in docs/STATUS.md; Miri green if adopted

Every exit also requires:

8. LZ

9. Commits after the OK

One topic each, in this order. Every commit builds, every group ends with just ci green, and the trailers follow .claude/rules/git-commits.md.

  1. docs: adopt zero unsafe outside a listed core. This file in force (its status line); in docs/PHASE2.md the decision row below and goal 1 reworded; in docs/ROADMAP.md the table of section 7; the status lines of docs/STATUS.md and README.md (N2 “Core runtime”).
  2. rules: forbid unsafe code outside the core. unsafe-budget.md (the core list, the ratchet, forbid), re-engineering.md (redesigned = forbid), lineage.md (forbid in mod.rs; the lz check rules), lz-sync.md (a forbid module may need reimplemented; the legacy re-baseline), rust-kernel.md (no unsafe outside the core; the adapter rule), subagents.md (what a launch prompt says about the core), and the one line this adds to the non-negotiables of CLAUDE.md. Files under .claude/ are edited with Edit/Write (the user’s rule).
  3. xtask: split unsafe-report into core, forbid and legacy. unsafe-core.toml; the split, the forbid count, the floor and the ceiling, the shape counts; lz check refuses a hand-written Adapter impl; the forbid columns of lz status --write and of .claude/skills/progress/scripts/progress.py.
  4. sys: the adapters, then the sweep. First Adapter a safe trait (sys/sys/queue.rs, sys/sys/tree.rs; the files that invoke the macros unchanged). Then the sweep over the mod.rs files and crate roots, with unsafe-report --write recording the floor. Last, an xtask: commit turns on the lz check rule “redesigned = forbid”, which passes only once the sweep has marked the redesigned modules of N1.
  5. The pipe slice. A redesigner (opus) with the six items of section 6, then a reviewer, then the integrator and just ci: one commit per primitive with its docs/IDIOMS.md row, the seams, the pipe redesign (kern:) and the diff-openbsd pipe step (xtask:), each with its LZ: and Unsafe: trailers.

The decision row proposed for docs/PHASE2.md:

Date Decision
<date of the OK> Zero unsafe outside a listed core (docs/ZERO_UNSAFE.md): every module outside unsafe-core.toml compiles under #[forbid(unsafe_code)], set on its mod declaration; the core (hardware, boot, the host harness, a runtime of primitives) is budgeted and reviewed; the legacy total only falls, an LZ sync aside; “redesigned” means “compiles with forbid”; N2 becomes “Core runtime” and N9 “Close” is added.

Goal 1 reworded: “Safety. Zero unsafe outside a listed core: every module outside unsafe-core.toml compiles under #[forbid(unsafe_code)], and every unsafe inside the core is budgeted, argued and reviewed (docs/ZERO_UNSAFE.md, .claude/rules/unsafe-budget.md).”