ADR-155: A Handle May Be Absent, and T? Is How It Says So

Status: Superseded by ADR-302 — its D1-D5 are D10-D14 there Date: September 19, 2026 Target Version: Nikaia 0.0.32 Related: ADR-147 D3 and D4 (the opaque handle and the CStr, and the question this answers), ADR-293 (an index out of range aborts), Part I 2.3 (T?), Part III 15.1, Part III A.2

1. Context

ADR-147 D3 writes its own example as an out-parameter:

extern "C" {
    opaque type sqlite3 released by sqlite3_close
    fn sqlite3_open(path: &[u8], db: &mut sqlite3) -> i32
}

and 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 — deliberately, because an address somebody chose is the one thing a handle may never be. So the record’s own example is not a program, and NK1160 says so.

The same hole is on the other side. fopen returns NULL where it cannot open the file, getenv returns NULL where the name is not set, and most of C’s handle-returning surface does. Nothing in a declaration could say it, so CStr::to_string had to report a null address as a failure — which conflates there is no text with the bytes are not UTF-8, two different things a caller answers differently.

One shape answers both, and the language already has it.

2. Decisions

D1 — 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: &[u8], db: &mut sqlite3?) -> i32
    fn sqlite3_errmsg(db: sqlite3) -> CStr?
}

Part I 2.3’s nullable, on a handle, meaning what it means everywhere else: a type is non-nullable unless it says otherwise, ?? and ?. are how a program gets past it, and NK1121 and NK1125 are what a program that forgets meets. No second spelling and no rule of its own — what is new is only that a handle may now stand where a T? stands.

D2 — null is the address, so a nullable handle costs nothing.

A handle holds a non-null address, and T? is the absence of one. Those are the same two states C spells with a pointer and NULL, so the lowering is the pointer either way: a nullable handle is one machine word, and &mut sqlite3? is sqlite3 ** exactly as C writes it.

That is what makes the out-parameter work rather than merely typecheck. A shape the language chose freely would be the right words around the wrong memory, and the boundary is the one place where the two cannot differ.

D3 — A declaration that does not say ? is a claim, and it is checked.

fn fopen(path: &[u8], mode: &[u8]) -> FILE says this never hands back nothing. C may still do it, and what the program gets then is an abort naming the function and this language’s words for what happened (Part III A.2) — never a handle that is secretly null.

The claim is the author’s and the check is the compiler’s, which is the arrangement sync on a declaration already has (ADR-124 D2). A declaration is written from a header by somebody reading it, and reading is where this can go wrong; one branch on a call that is crossing into C anyway is what that costs.

D4 — A CStr is nullable like any other handle, and to_string stops

answering for it.

getenv is declared -> CStr? and the program writes ?? "" or ?.. The copy then fails in one way rather than two — the bytes are not UTF-8, which is what fs::read_to_string already fails on — and there is no text stopped being a failure and became a value.

D5 — At the boundary a ? on a view belongs to what it points at.

&mut sqlite3? is a slot that holds a handle or nothing, not a view that may be absent. A view at the C boundary lives for the call (ADR-147 D1) and is never absent, so the other reading is a shape nothing writes — and the out-parameter is what every C library with one means.

It is the one place Part I 2.3’s own reading is turned around, and it is turned around only for &mut T and &[T]. A plain &T? is untouched: let mut m: &str? = null is 2.3’s own example and stays a nullable view.

3. Consequences

4. What this does not decide

5. What is built

All four. Step 4 is ADR-147 §5’s own step 5 — examples/sqlite/main.nika, which is what this record was in the way of.

The hull under a handle is NonNull, and that is the whole of D2. Rust lays Option<T> over the same machine word for a type shaped like this, so a nullable handle costs nothing and &mut sqlite3? comes out *mut Option<sqlite3> — one pointer to one pointer, which is sqlite3 **. The out- parameter works rather than merely typechecking, which at this boundary is the only kind of working there is.

It forced the declaration’s result to change, and that is D3’s real cost. A handle cannot be the declared return type any more: the hull is non-null, so a NULL arriving in one is undefined before any check could run. So a declaration hands back the address C returned and the hull goes on at the call, where from_c checks the claim or maybe reads NULL as None.

D5 was found by building rather than by writing. &mut Block? parsed, and the ? went to the view — Part I 2.3’s own reading, and the wrong one here: what C wants is a slot that holds a handle or nothing. A view at this boundary lives for the call and is never absent, so the other reading is a shape nothing writes. It is turned around for &mut T and &[T] only, in Ty::from_ast and in the ledger’s text in both directions; a plain &T? is untouched.

CStr::to_string lost a failure. It reported a null address and bad UTF-8 as one throws, which conflated there is no text with the bytes are not text. The first is a value now — a declaration says -> CStr? and the program writes ?? — and what is left is the failure fs::read_to_string already has.

Six more tests in crates/nikaia/tests/foreign_pointers.rs, twenty-nine in the file, one of which runs getenv both ways: HOME is set and NIKAIA_NOT_SET is not, so finding nothing is a value the program reads with ?? rather than a failure it has to catch.

And step 4 found that D1 is not enough on its own. sqlite3_prepare_v2 takes the database the out-parameter filled, and a sqlite3? cannot be handed to a parameter that takes a sqlite3: ?? wants a fallback and there is no second handle, ?. reaches a member and this is a free call. The answer was already in the language — 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 written the two together, and no shorter program would have.

This site is open source. Improve this page.