ADR-302: A Call Into C Is Written Inside unsafe, and What Crosses Is a View for the Call or an Opaque Handle That May Be Absent — Never a Raw Pointer

Status: 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

1. Question

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.

2. How others do it

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)

3. Options, weighed

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.

4. Decision

D1 — 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).

D2 — An 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.

D3 — A call to an 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.

D4 — What a C declaration does not say is fail-closed.

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 ?.

D5 — A buffer is a view, and it lives for the call.

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.

D6 — A length beside a view is checked at the call.

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.

D7 — A handle is opaque, declared, and released by a 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.

D8 — Text a C library hands back is a 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.

D9 — There is no raw pointer, and 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.

D10 — A handle that may be absent is a 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.

D11 — 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.

D12 — A declaration that does not say ? 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.

D13 — A 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.

D14 — At the boundary a ? 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.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.