ADR-218: Every unsafe Topic Is a Small Crate of Its Own

Status: Accepted Date: September 26, 2026 Answers: the owner’s decision, weighing where the one unsafe line a struct of views needs should live: not in nikaia-std — one small extra crate for every unsafe topic; they stay in the repository but have a reason to exist of their own, can be used by others, can be audited, and can have their own tests and toolchain Related: ADR-001 D1 (one toolchain for building, which stands), ADR-283 (the tether, the first topic), ADR-002 D1 (a dependency is kept to what it is worth), ADR-306 (proven-index, an index read without its check), ADR-302 D3 (a call into foreign code is written inside unsafe)

1. Context

unsafe was spread over ten files of nikaia-std, and the emitted code wrote it too (unsafe { nikaia_std::tether::hold(…) }). Nothing checked any of it beyond the ordinary tests. The first check that could — Miri, on the code moved out for this record — found two cases of undefined behaviour in code the compiler already relied on:

Both were fixed in the same package, and are why this record is more than housekeeping.

2. Decisions

D1 — One unsafe topic, one crate, under crates/unsafe/<name>.

A topic is one thing the language needs that safe Rust cannot say: a view held beside its buffer, a one-word error, a mapped file, the I/O ring, the C boundary, an index proved inside. The crate has a name that says what it does and not that it is Nikaia’s, a README.md that lists every unsafe in it with the argument for it, no dependency it does not need, and an API the compiler’s generated code can use without writing unsafe itself wherever that can be arranged.

D2 — Each is its own workspace, and is checked on its own.

Its own Cargo.lock, excluded from Nikaia’s workspace and reached from it as a path dependency. scripts/check-unsafe-crates.sh runs, per crate: cargo fmt --check, the tests and clippy -D warnings on stable, and Miri on nightly under both of its aliasing models (Stacked Borrows and Tree Borrows). CI runs the script as a job of its own.

Nightly is used for checking these crates’ tests and nothing else. What is built — the compiler, nikaia-std, the crates as dependencies, the generated programs — stays on stable (ADR-001 D1), and a crate here that needed a nightly feature would fail Nikaia’s own stable build.

D3 — The first topic: tether.

crates/unsafe/tether holds Keep and Held, Holding<V> - a struct of views beside the one buffer it points into, with a safe constructor (the struct is built from the buffer inside a closure that must work for every lifetime, and Views makes rustc check covariance) - Rebase, which builds a Holding from the views a value was made of, and Dangling<T>, the storage a value with stretched views needs. nikaia_std::tether re-exports it, and the emitted code names it by that path. It has no dependencies; self_cell was measured beside it and costs one allocation per value (~13 % in a container loop), which this does not.

The wakers, a topic that needed no crate at all: std::task::Wake has built a Waker from an Arc safely since Rust 1.51, so the wakers are built on it and no RawWaker vtable is written by hand.

D4 — The rest, and nikaia-std without unsafe.

The owner’s follow-up: the remaining unsafe goes out of nikaia-std too.

crate topic from
ref-or-box one word that is a &'static A or an owned Box<B>, with a niche error’s tail
checked-text bytes checked as UTF-8 once, chunked across rayon (feature), a file mapped and checked (feature) fs
c-text text a C library owns; an extern declaration returns Option<CText>, so the promise is made where C is declared foreign
polled a poller that owns every descriptor it watches rt::readiness
file-ring io_uring file operations with owned buffers, and an eventfd bell rung by safe std writes rt::uring
proven-index an element read or written without its bounds check, at an index the compiler proved inside (ADR-306) new, not moved

libc is not a dependency of nikaia-std (a test’s mkfifo is the command). nikaia-std says #![forbid(unsafe_code)], so the compiler itself holds that no unsafe is left in it.

Where Miri cannot run a crate’s tests — polled (no epoll) and file-ring (no io_uring) — the README says so, and says what upholds the argument instead: the unsafe there is a contract with the operating system, not a question about memory.

The emitted code. It writes no unsafe for the tether: a value a task takes with it is a Holding over the task’s keep, built by Rebase from the views it was made of. It writes unsafe only around a call into foreign code (ADR-302 D3) and around proven-index’s read or write at an index proved inside (ADR-306) - the one operation each proof licenses.

This site is open source. Improve this page.