Status: Superseded by ADR-302 — its D1-D5 are D5-D9 there (D4 as narrowed by ADR-155 D4)
Date: September 19, 2026
Target Version: Nikaia 0.0.31
Related: ADR-124 §4 (which left this open — the only part of
that record not built), ADR-284 (the other direction, answered
the same way), ADR-284 (a C value struct, and NK1145),
ADR-094 (a view is what the compiler writes), Part I 6.4
(cleanup), Part I 6.6 (a view is tethered), Part III 15.1
extern "C" { fn malloc(size: usize) -> Pointer[u8] } is refused with
NK1135, because Pointer[T] is a type nothing declares — and
ADR-124 §4 left it that way on purpose: a pointer that outlives
what it points at wants a record with a lifetime story, not a name.
What that costs is most of C. Every function whose signature has a pointer in
it is unwritable: all of libc’s memory surface, and every library that hands
out a handle — a database connection, an HTTP client, a compressor. getpid
compiles; nothing with a buffer or a handle does.
The thing to prevent is the dangling dereference. A raw pointer admits a value that can dangle and makes every dereference a hole the compiler cannot close. But the two shapes real C libraries are made of — a buffer the caller owns and a handle the library owns — can each be given a form that makes a dangling dereference impossible by construction, and both forms are words this language already has.
extern "C" {
fn read(fd: i32, buf: &mut [u8], count: usize) -> isize
fn strlen(s: &[u8]) -> usize
}
&T, &mut T, &[u8] and &mut [u8] in an extern declaration lower to the
pointer C wants and live for the call, which is what a view is everywhere
else in this language (ADR-094). Nothing is stored, nothing
escapes, and the tether of Part I 6.6 is the same one.
read(fd, buf, count) declares count: usize and the compiler refuses a call
where count cannot be shown to be at most buf.len(). The two parameters are
one fact in C and the declaration is where this language says so; a call that
passes a longer length is the buffer overrun the boundary exists to stop, and
it is refused here rather than by the operating system.
Where the length is not a constant and not buf.len(), the call is
refused asking for one of those two — which is narrow on purpose, and is the
same refuse rather than guess every other check here keeps.
cleanup.extern "C" {
opaque type sqlite3 released by sqlite3_close
fn sqlite3_open(path: &[u8], db: &mut sqlite3) -> i32
}
An opaque type is an address the language never dereferences. It is moved
and stored like any value, it has no fields and no indexing, and its release is
a cleanup the compiler runs at the end of its scope (Part I 6.4) — so a
handle cannot outlive what it names unless a C function makes it, which is the
one thing this language cannot check and says so.
std function copies.getenv hands back a C string, which is memory the caller does not own and
whose lifetime is the library’s. It arrives as an opaque CStr handle, and
std copies it into a String inside unsafe — one function, written once,
where every program would otherwise write the same loop.
malloc stays unwritable, by design.Memory the language will index has to arrive with a length the language knows.
malloc hands back an address and no length; there is no shape here for it,
and that is the decision rather than a gap. A program that needs a buffer makes
one in Nikaia and lends it (D1).
cleanup — which is how ADR-284 answered the other direction.std function for C strings.Pointer[T]. D1 and D3 are its safe subset, so nothing spent here
is lost the day one is admitted — and admitting one first could not have been
narrowed later.All of it. Steps 1 to 4, D5 by Pointer[u8] still being NK1135 and this
record saying that is permanent rather than pending, and step 5 —
examples/sqlite/main.nika, which opens a database, prepares a statement, steps
it and reads a row back, and whose test skips rather than fails where the
machine has no libsqlite3.
The four forms, and where each may stand. &T, &mut T, &[T] and
&mut [T] parse, lower to *const T / *mut T, reach the ledger in this
language’s own words, and are refused with NK1158 outside an extern "C"
declaration — because away from the boundary a parameter this language may
change is written mut name: T (ADR-094 D3) and a run of elements
is a Vec[T] or an Array[T, N], each of which carries its length.
Rust’s &[T] is a fat pointer, which is why the declaration writes the
address. A declaration that lowered &[u8] to Rust’s own &[u8] would be a
signature the two languages disagree about, and what a reader would get for it
is rustc’s improper_ctypes about a file nobody wrote (Part III C.1). So a
foreign declaration has a type writer of its own, and the length is D2’s — the
two parameters are one fact in C and the declaration is where this language says
so.
The call is where the address is made, and it is the emitter’s own answer
rather than a hand-over from the checker: bytes becomes bytes.as_ptr(), and
a foreign declaration is a name in the very file the emitter is writing, which
it already resolves by name (ADR-296 D17). One call serves every
caller, because a Vec, an Array and text all answer .as_ptr().
Ty::Pointed is a variant and not two flags on Named, which is the
opposite of how the AST records it. There are seventy-eight places in this
compiler that build a Named and not one of them can produce either shape, so a
field they would all have to answer for is a field for one position’s sake. A
plain &T stays Named with view: it is the view every declaration in this
language writes, and a shape that held the third case too would have two
spellings for one type.
The fit is where the declaration and the caller meet. A &[u8] takes
whatever lends a run of u8 — a Vec[u8], an Array[u8, N], a String or a
&str — and one direction only: nothing fits out of a boundary type, because
what a C function hands back is an address and the value this language would
make of it is D3’s handle or D4’s copy.
What makes a pair, for D2, is the declaration’s own types: a &[T] and,
right after it, a usize. The type says length rather than the name,
because usize is not a type this language’s own values have
(ADR-285 D1) — a length here is an i64 and the machine-width
type left the surface a program can write. A declaration that writes one beside
a buffer is therefore saying C’s size_t, and D2 is what that says.
Two shapes are accepted, which is D2’s narrow on purpose: the buffer’s own
len(), and a constant the buffer’s length is known to cover — an
Array[T, N], whose length is part of its type (ADR-152 D1). A
zero is accepted against any buffer, because no length is smaller. A Vec
with a constant is refused, and correctly: nothing here can show what its length
will be.
What it costs is a usize beside a buffer that is not its length. An offset
in that position has no way to say so, and the call is refused. That is D2’s
narrowness rather than a defect in it — the record chose the pair as
definitional — and it is written here so the day a program meets it, it is met
as a decision.
D2 turned up two defects of the compiler’s own, both invisible until a
declaration named usize, which no program could write before this record.
keeps::moves did not list usize or isize, so a type it did not name was
one that moves and ADR-094 D1 lent the count —
read(0, buf.as_mut_ptr(), &buf.len() as i64), which is not Rust. And a usize
parameter had no way to take this language’s i64, so buf.len() met NK1102
with a help naming as usize — a cast into a type Part I 2.2 does not offer.
Both are fixed: the two names join the copy list, and a size at the boundary
takes an i64 with the conversion emitted, which is the arrangement
str::repeat already had, arrived at from the declaration rather than from
a name.
Fifteen tests in crates/nikaia/tests/foreign_pointers.rs, one of which
compiles the lowering with rustc against libc’s own strlen and abs and
runs it — which needs no library the machine may not have, and is the whole of
D1 end to end.
A handle is #[repr(transparent)] over the address, and Drop is the
cleanup. The layout is the whole point: a handle is the address, so
&mut T at the boundary is T** the way C writes it and a handle passed by
value is the pointer. A newtype Rust may lay out as it likes would be a
different program at the boundary. Drop is Part I 6.4’s cleanup read here —
the release runs at the end of the handle’s scope, so a handle cannot be
forgotten; what it cannot answer for is a C function that keeps the address past
its own call, which is the one thing this language cannot check and D3 says so.
A handle is lent to every declaration but one, and that is D1’s lesson one
type over. fileno(f) reads the handle and f is still the caller’s to
close, so the address goes by value and the value stays here (f.lent()); the
type’s own release function takes it, and Rust’s own move is what keeps it
from being released twice. The & ADR-094 D1 would otherwise
write is the wrong address as well as the wrong ownership — &FILE is FILE**
where C wants FILE* — which is the same mistake Rust’s fat &[T] would have
been in a declaration.
NK1160 covers all three ways of reaching past a handle: a field, an index,
and a call, because an opaque handle has no constructor. There is no value
of one this language can make, so a constructor would have to invent an address
— and an address somebody chose is the one thing a handle may never be.
None of opaque, type, released and by is reserved. The grammar is
scannerless, so a word means something only where a rule asks for it, and all
four are names a program may want. Reserving one buys exactly the sentence a
reader who writes it gets (ADR-298 D7), and this position says
that sentence without taking the word away.
What D3 does not decide, and it is its own example. sqlite3_open(path, db)
needs db to exist before the call, C writes it as an uninitialised pointer,
this language has no uninitialised binding, and D3 gives a handle no constructor
— so the record’s own out-parameter is not a program. NK1160 says so rather
than letting rustc answer. The question is in
open-decisions.md with a recommendation, and what
is built meanwhile is every library whose constructor hands its handle back,
which is fopen’s shape and most of C’s.
D4’s handle is opaque and has no cleanup, which is what tells it from
D3’s: a FILE is ours to close and a C string is not ours at all, so
std::CStr frees nothing. The walk to the zero byte is the one thing a program
must not have to write, so it is written once, inside unsafe, in
nikaia-std, and a program says raw.to_string() with no unsafe of its own.
And it fails rather than guessing, in the two ways it can: the address may
be null, which is what every C function that finds nothing hands back, and the
bytes may not be UTF-8, which text in this language is. Both are throws
rather than an abort, because a caller can do something about either —
getenv finding nothing is an ordinary Tuesday, and Part III A.2’s aborts are
for what a program’s own arithmetic got wrong. When the question below is
answered the null half becomes a CStr? and stops being a failure; the UTF-8
half stays one, which is what fs::read_to_string already is.
Thirty-two tests in crates/nikaia/tests/foreign_pointers.rs, four of
which compile the lowering with rustc against libc and run it — strlen and
abs for D1, fopen/fclose/fileno for D3, getenv for D4,
posix_memalign’s out-parameter for ADR-155 — and a fifth against
sqlite3 that skips where the package is not there.
Step 5 earned its place, which is the part worth writing down. It is a test and not a decision, so it looked like bookkeeping — and it found three things the four libc programs could not.
It found that the four decisions are not four. sqlite3_prepare_v2 takes the
database the out-parameter filled, and a sqlite3? cannot be handed to a
parameter that takes a sqlite3. The language turned out to have the answer
already — slot ?? throw Refused::NoDatabase narrows a T? to a T, which is
ADR-276 D20’s jump-as-an-expression meeting
ADR-279’s ?? — but nothing had ever written the two together,
and four libc calls never would have.
It found a defect of this record’s own. A C type keeps the name its header
gives it, and pub struct sqlite3 is not the shape Rust’s own lint expects, so
rustc warned about the generated file — Part III C.1’s class, and invisible
until a library with a lower-case type name was called.
And it found one older than this record. throw "no database" reads like a
program and is not one: Part I 7.1 says what is thrown implements Error, and
text does not. rustc answered the trait bound str: Error is not satisfied
about a file nobody wrote. It had been writable since throw existed and no
program in the tree had written one. NK1161 is that refusal in this compiler’s
words.
libc is still what proves the four, on any machine, and sqlite3 is what
proves they hold for a library rather than for four calls. That is why the
test skips rather than fails where the package is missing: a gate that depends
on a library not every machine has is a gate people learn to ignore.