unsafe, and What Crosses Is a View for the Call or an Opaque Handle That May Be Absent — Never a Raw PointerStatus: Accepted
Date: October 3, 2026
History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached
Answers: how a program calls a C function: which words the boundary takes
and where it is visible; what a C signature with a pointer in it is written as
— a buffer the caller owns, a handle the library owns, text the library hands
back; and how a declaration says that C may hand back nothing, including
through an out-parameter
Related:
ADR-298 D1 and D8 (a reserved word is not a name, and a word is reserved only for a construct),
ADR-276 (a keyword is the most expensive thing a language adds; D20, a throw is an expression),
ADR-277 D7 and ADR-288 D21, D24 (what a body-less declaration says by default, and what sync promises),
ADR-290 D3 (a described Rust crate is fail-closed; the same boundary one language over),
ADR-024 D1 (?, the absence of a claim),
ADR-094 D1, D3 (a view is what the compiler writes; a parameter that changes says mut),
ADR-179 D1 and ADR-184 D1, D3, D4 (a run is a type everywhere, and a view is spelled ref X),
ADR-152 D1 (Array[T, N] carries its length),
ADR-285 D1 (a length is an i64),
ADR-278 and ADR-279 (T?, ?? and ?.),
ADR-297 (cleanup),
ADR-284 (the other direction: letting C call in),
Part I 2.1, 2.3, 6.4, 6.6; Part III 15.1, A.2, C.1
How does a Nikaia program call a C function, and what may a C signature say?
Part III 15.1 writes C interoperability as an extern "C" block and a call
inside unsafe { … }. That needs two reserved words with constructs, a rule
for what a body-less C declaration promises, and an answer to the question
every useful C signature raises: a pointer. Most of C is pointers — libc’s
memory surface and every library that hands out a handle (a database
connection, a file, a compressor) — and the one thing this language is built
not to allow is a pointer that outlives what it points at. Without an answer,
only C functions over plain numbers (getpid, abs) are writable. And C’s
NULL — the fopen that cannot open, the getenv that finds nothing, the
out-parameter a constructor fills — needs a spelling, or a declaration lies
about what it hands back.
The boundary is compared with the language below, because that is what the lowering is: Rust.
| Language | Its answer | What does not transfer |
|---|---|---|
| Rust | an extern "C" block of declarations, every call inside unsafe; a C pointer is *const T / *mut T, dereferenced inside unsafe |
the raw pointer: Rust trusts the unsafe block’s author with every dereference, and this language admits no dereference it cannot check |
| Rust | &[T] is a fat pointer (address and length) |
C has no such thing; a declaration that passed one would be a signature the two languages disagree about, and rustc would answer with improper_ctypes about a generated file (Part III C.1) |
| Rust | Option<NonNull<T>> lies over one machine word |
carries over exactly, and is why a nullable handle costs nothing (D11) |
| Option | Lost because |
|---|---|
Reserve extern and unsafe ahead of their constructs |
a word reserved with no construct buys only a sentence the undeclared-name refusal can say for free (ADR-298 D7, D8); reserved with the constructs, in one change, they carry their weight (D1) |
| Mark the boundary with an attribute on the declaration | the boundary would be visible in a file beside the call, not at the call a reader reads (D3) |
An extern declaration may pause unless it says sync, as a function type does |
C has no suspension point; a C function that sleeps blocks a thread, and calling that pausing would make every C call an .await of a future nobody produced (D2) |
A Pointer[T] type |
it admits a value that can dangle and makes every dereference a hole the compiler cannot close; the two shapes C libraries are made of each have a form that cannot dangle (D5, D7) |
A shape for malloc |
an address with no length is memory the language could index without knowing how far; a program makes its buffer in Nikaia and lends it (D9) |
| A constructor for a handle, so an out-parameter has something to point at | an address somebody chose is the one thing a handle may never be; a nullable slot is the answer (D10, D14) |
Reserve opaque, type, released, by |
the grammar is scannerless and the position says it; all four are names a program may want (D7) |
| A separate nullable spelling for handles | a second spelling for what T? already says (D10) |
Trust a declaration without ? |
a handle that is secretly null is undefined behaviour in a non-null hull; one branch on a call already crossing into C is the price of the check (D12) |
CStr::to_string fails on a null address |
conflates there is no text with the bytes are not text, which a caller answers differently (D13) |
? on a boundary view belongs to the view, as in Part I 2.3 |
a view at the boundary lives for the call and is never absent, so that reading is a shape nothing writes, and the out-parameter is what every C library with one means (D14) |
Rust crate calls in unsafe too |
a different mechanism for a different guarantee: Rust’s signature is checked through a description (ADR-290), C’s is asserted |
The number that decided D1: no program in examples/, no fixture in
tests/ and nothing in the specification wrote extern or unsafe as a name,
and nothing was released, so reserving both broke nothing.
extern and unsafe are reserved words, with their constructs.Changed by ADR-324 D6: written extern, without "C"; extern "C" is NK1227.
They are on Part I 2.1’s list because each has a construct — the extern "C"
block, an item, and the unsafe { … } block, an expression — and a word is
reserved for a construct of this language and for nothing less
(ADR-298 D8).
extern "C" declaration is sync and cannot throw, without writing either word.A body-less declaration in this language may pause unless it says sync
(ADR-277 D7, ADR-288 D24). For a C declaration the
default is turned around: C has no suspension point, and a C function that
sleeps blocks a thread, which is the question println already answers
(Part II 12.1, ADR-288 D21), not a pause. sync is not written
because there is nothing it could be contrasted with. C has no failure channel
this language reads — a return value that means “it went wrong” is a value — so
the declaration carries no throws, which a body-less declaration carries
anyway.
extern name is written inside unsafe { … }, and nowhere else.That is what the word buys: the boundary is visible at the call, in the body a
reader reads. unsafe is an expression — a block with a value — and makes no
other rule: what is inside is checked as anything else is. A call to such a
name outside one is refused with NK1143, naming the block to write; the
refusal is this compiler’s, not the language below’s (Part III C.1). unsafe
stands on a block only — not on a function, an impl or a trait.
touches is everything and locks is may, as for a described crate
(ADR-290 D3), and for a stronger reason: a C signature says less
than a Rust one. An argument type the language cannot name is ?
(ADR-024 D1), and a program may do with such a value what it may
do with any ?.
extern "C" {
fn read(fd: i32, buf: ref mut Array[u8], count: usize) -> isize
fn strlen(s: ref Array[u8]) -> usize
}
ref T, ref mut T, ref Array[T] and ref mut Array[T] in an extern
declaration are the pointer C wants, and each lives for the call, which is
what a view is everywhere in this language (ADR-094 D1, Part I
6.6). Nothing is stored and nothing escapes. A ref Array[T] takes whatever
lends a run of T — a Vec[T], an Array[T, N], and text where T is u8 —
and the call makes the address; nothing fits out of a boundary view, because
what C hands back is an address and the value made of it is D7’s handle or D8’s
copy. In a declaration a ref Array[T] lowers to an address, not to Rust’s fat
pointer. ref mut belongs to the boundary: away from it a parameter that may
change is written mut name: T (ADR-094 D3), and a ref mut
outside an extern "C" declaration is refused with NK1158. A ref on a word (ref i32, const int32_t *) is likewise the boundary’s alone: away from it it is NK1257 (ADR-views-3 D4). A ref Array[T]
is a view of a run everywhere (ADR-179 D1); only in a declaration
does it become an address beside a count. The spelling is
ADR-184’s.
A ref Array[T] followed by a usize is one fact in C, and the declaration is
where this language says so. The type marks the length, not the parameter’s
name: usize is not a type this language’s own values have
(ADR-285 D1), so a declaration that writes one beside a buffer is
saying C’s size_t. A caller passes an i64 and the conversion is emitted. A
call whose count cannot be shown to be at most the buffer’s length is refused
with NK1159. Two shapes are accepted, narrow on purpose: the buffer’s own
len(), and a constant a known length covers — an Array[T, N] carries its
length (ADR-152 D1), a Vec[T] does not; a zero is covered by
every buffer. Anything else is refused asking for one of the two.
cleanup.extern "C" {
opaque type FILE released by fclose
fn fopen(path: ref Array[u8], mode: ref Array[u8]) -> FILE
fn fileno(f: FILE) -> i32
fn fclose(f: FILE) -> i32
}
An opaque type is an address the language never dereferences. It is moved
and stored like any value; it has no fields, no indexing and no constructor —
there is no value of one this language can make, and an address somebody chose
is the one thing a handle may never be. A field, an index or a call reaching
past one is refused with NK1160. Its release is a cleanup the compiler runs
at the end of its scope (Part I 6.4), so a handle cannot be forgotten. A handle
is lent to every declaration but its release: fileno(f) reads it and f
is still the caller’s, while fclose(f) takes it and is the cleanup. A handle
is the address itself, so a handle passed by value is C’s pointer and
ref mut FILE is FILE **. What the language cannot check is a C function
that keeps the address past its own call, and it says so. None of opaque,
type, released and by is reserved: each means something only in this
position.
CStr handle that std copies.A C string is memory the caller does not own, whose lifetime is the library’s,
ending at a zero byte. It arrives as foreign::CStr, an opaque handle with
no cleanup, and to_string copies it into a String — the walk to the zero
byte is written once, inside unsafe, in std, and a program writes no
unsafe of its own for it. The copy fails in one way: where the bytes are not
UTF-8, which is what fs::read_to_string fails on. A string that is not there
is D13’s.
malloc stays unwritable by design.Pointer[T] is not a type; Pointer[u8] is refused with NK1135. Memory the
language indexes arrives with a length the language knows, and malloc hands
back an address and no length. A program that needs a buffer makes one in
Nikaia and lends it (D5). This is the decision, not a gap: D5 and D7 are a raw
pointer’s safe subset, so nothing here is lost the day one is admitted, and
admitting one first could not have been narrowed later.
T?, and that is all.extern "C" {
opaque type sqlite3 released by sqlite3_close
fn sqlite3_open(path: ref Array[u8], db: ref mut sqlite3?) -> i32
fn sqlite3_errmsg(db: sqlite3) -> foreign::CStr?
}
Part I 2.3’s nullable on a handle means what it means everywhere: a type is
non-nullable unless it says otherwise, ?? and ?. get past it, and NK1217
and NK1125 are what a program that forgets meets. There is no second spelling
and no rule of its own.
null is the address, so a nullable handle costs nothing.A handle holds a non-null address and T? is the absence of one — the same
two states C spells with a pointer and NULL. A nullable handle is one machine
word, and ref mut sqlite3? is sqlite3 ** exactly as C writes it, which is
what makes an out-parameter work rather than merely typecheck: a shape chosen
freely would be the right words around the wrong memory.
? is a claim, and it is checked.fn fopen(…) -> FILE says this never hands back nothing. Where C does, the
program aborts with a message naming the declaration (Part III A.2) — never a
handle that is secretly null. The claim is the author’s and the check is the
compiler’s, as with D2’s sync: a declaration is written from a header by
somebody reading it, and reading is where it goes wrong; one branch on a call
already crossing into C is the cost.
CStr is nullable like any other handle.getenv is declared -> foreign::CStr? and the program writes ?? or ?.:
there is no text is a value, not a failure of to_string.
? on a ref mut T or a ref Array[T] belongs to what it points at.ref mut sqlite3? is a slot that holds a handle or nothing, not a view that may
be absent: a view at the boundary lives for the call (D5) and is never absent,
so the other reading is a shape nothing writes. This is the one place Part I
2.3’s reading is turned around, and only for those two forms; a plain ref T?
stays 2.3’s nullable view.
extern "C" { fn strlen(s: ref
Array[u8]) -> usize } and unsafe { strlen(text) }. The C libraries worth
calling are writable, because a buffer and a handle are what their surfaces
are made of, and most of C’s handle-returning surface — fopen, getenv,
dlopen, sqlite3_errmsg — says honestly that it may hand back nothing.cleanup, a T? — as ADR-284 describes the other direction.T? reaches a parameter that takes a T through ?? throw:
slot ?? throw Refused::NoDatabase is a sqlite3
(ADR-276 D20 meeting ADR-279’s ??).usize beside a
buffer that is not its length (an offset) cannot be declared; a handle’s
cleanup; the check behind every handle a declaration claims is never null; and
one std function for C strings.Pointer[T], and arithmetic on an address, which is what one is
for. No program has asked; D9 keeps the door open without spending anything.unsafe on anything but a block.null. ?? and ?. are how a T?
is read; x == null on a handle does not compile, because a handle does not
compare; whether it may is not decided here.ref mut i32 is D5’s already;
what a ref mut Vec[i64] at the boundary would mean no program has asked.