pub extern "C" fn Is an Entry Point, the Caller Owns the Memory, and Every Call Answers With a StatusStatus: Superseded by ADR-284 — its D1-D10 are D4-D13 there (D10 together with ADR-128)
Date: September 19, 2026
Target Version: Nikaia 0.0.8
Related: ADR-062 (a build that lets foreign code call in is a
target; one artifact, safe at its boundary), ADR-124 (extern
is reserved with its construct — the block without a body declares a foreign
function; this record gives the word its second form, a function with one),
ADR-119 D4 and D7 (an allocator the build names; settings baked
at build time), ADR-112 (an expired cleanup deadline), ADR-115
(secondary), ADR-110 and ADR-111 (the doors, and
what leaves a lock), ADR-010 D1 (an FFI value is Untrusted),
ADR-005 D8 and ADR-100 (a deterministic ledger and
--locked), Part I 4.2 and 6.4, Part II 12.2, Part III 13.3, 15.1, 17.2,
Appendix A
A language that lowers to Rust can be the language a library is written in
for programs written in something else: a C program, a Python module, a Node
add-on, a Java service over Panama. Every one of those hosts speaks the C
calling convention, and the higher-level bindings (PyO3, napi-rs) are
comfortable wrappers over the same entry points. So the C ABI is not one
target among many; it is the floor every other host stands on, and it is what
this record decides.
ADR-062 settled the hard half: such a build is a target, because the caller brings the threads; every entry point and everything reachable from one takes the safe shape (atomic counts, real locks), the rest of the library keeps the per-value answer, and no check changes because no check ever read the switch. What it left open is everything a C programmer sees: how an entry point is written, what crosses the boundary and in which memory, how a failure arrives, what a function that pauses does to a caller that has no executor, and how the library starts and stops inside a process it does not own.
Two rules of good C libraries shape the answers, because the caller’s expectations are the specification here. The caller owns the memory: it allocates the buffers results are written into, and if it wants, it supplies the allocator for everything else, so that a host with its own heap discipline — an embedded system, a garbage-collected runtime, a fuzzer — is never handed memory it did not ask for. And a call never surprises: it returns a status, never unwinds, never blocks a thread it was not told it would block, and never touches memory after the caller freed it.
pub extern "C" fn with a body is an entry point, and the artifact is the build’s.pub extern "C" fn greet(name: &str) -> String { f"Hello, {name}" }
pub struct Session { … }
impl Session {
pub extern "C" fn(url: &str) -> Session throws { … } // the constructor
pub extern "C" fn send(&mut self, payload: &[u8]) throws { … }
}
extern "C" before fn is ADR-124’s word in its second form:
the block without a body declares a function the language calls; a function
with a body is one the language provides. The string names the physical
calling convention, and there is one: "C" is what every host speaks, a
WebAssembly module’s exports included — the target names WebAssembly, not
the word (ADR-130).
The build says what is made of it: artifact = "c-library" in [build],
beside target. The pair is what ADR-062 calls the target — the
platform and whether foreign code may call in — and the second key is the
answer to that record’s first open question. The artifact is a shared library
and a static one for the platform, and a header, <package>.h, generated
from the ledger.
Four refusals at the declaration, in this language’s words: extern without
pub (what is reachable from outside must be reachable from inside); extern
with a body in a build without a library artifact (nothing would be exported,
and silence would let somebody believe otherwise); a generic function or a
trait method (C has no way to ask for an instance); and a signature whose
shape D2’s table does not cover, naming the shape to use.
| in the declaration | in C | rule |
|---|---|---|
i32, i64, u8, f64, bool, char |
int32_t, int64_t, uint8_t, double, bool, uint32_t |
by value, both ways |
&str, &[u8], Bytes in |
const char*, size_t / const uint8_t*, size_t |
a pointer and a length, never a terminator scanned for; the caller keeps ownership; text is checked for UTF-8 at entry and refused with a status if it is not |
String, Bytes, Vec[u8] out |
uint8_t* out, size_t cap, size_t* written |
D3: the caller’s buffer |
Vec[i64] and other lists of numbers in |
const int64_t*, size_t |
an array the caller keeps |
a pub extern "C" struct |
the struct, by value | C’s layout, copied both ways; a Vec of them is an array in and the caller’s buffer out (ADR-127) |
any other pub struct the library exports |
struct <package>_<Type>*, opaque |
a handle; created by its extern constructor, released by <package>_<Type>_free, its pub fields read through <package>_<Type>_<field>, changed through its extern methods and nowhere else |
enum without payload |
a C enum, <PACKAGE>_<TYPE>_<VARIANT> |
numbered in declaration order, and the numbering is part of the ABI: a variant is appended, never reordered |
enum with payload |
an opaque handle with <package>_<Type>_kind and a getter per variant field |
as a struct |
T? of a handle or a text |
a null pointer | absence is NULL |
T? of a number |
a status code, <PACKAGE>_NONE |
C has no absent integer |
fn(A) -> R sync as a parameter |
a C function pointer and a void* ctx, forwarded |
the callback is called on the thread that called in, or on a library thread for D6’s async form; a callback type that may pause is refused, because C cannot. A callback returning bool is the streaming shape, and its false is the caller’s stop (ADR-129) |
Locked, Shared, Seen, a map, a list of anything but numbers and value structs |
— | refused at the declaration; the shape to use is a handle around a struct that holds it, or — for many results — a function that takes a fn(item) -> bool sync |
Every value that enters is Untrusted — ADR-010 D1’s barrier
for an FFI value, unchanged — which is what a program wants of bytes a host
it does not know handed it.
A result that is text or bytes is written into the caller’s buffer. The
exported form of fn render(…) -> String is
int hello_render(const char* name, size_t name_len,
uint8_t* out, size_t cap, size_t* written);
The library writes at most cap bytes, sets written to the length of the
result, and returns HELLO_E_TOO_SMALL where cap did not hold it — with
written carrying the size that would — so that the caller comes back with a
buffer that does. out == NULL is the size query. Nothing is allocated for
the caller and nothing is freed by it.
Where the library must hold memory across calls — a handle, its contents, the runtime’s own state — it takes it from an allocator the caller may supply:
int hello_set_allocator(void* (*alloc)(size_t size, size_t align, void* ctx),
void (*free)(void* ptr, size_t size, size_t align, void* ctx),
void* ctx);
Called before the first entry point (before hello_init, D7), it becomes the
library’s global allocator, and everything the library allocates from then on
comes from it — the emitted crate’s allocator is a forwarder to these two
pointers. Not called, the platform’s heap is used, so a caller that does not
care does nothing. <package>_<Type>_free stays, because the caller cannot
know what a handle holds, and it returns the memory to the allocator it came
from.
What a callback receives is a view that lives for the call. Text or bytes handed to a C callback are valid until the callback returns; a caller that wants them afterwards copies them into memory of its own. That is the rule the language has inside, with views, said at the boundary.
One shape for every exported function, whether or not it throws:
int <package>_<name>(inputs…, outputs…). 0 is <PACKAGE>_OK. A function
that throws numbers the variants of its error type from 1, per library and
in declaration order, and the header names each as <PACKAGE>_E_<VARIANT>;
the numbering is part of the ABI as an enum’s is. Negative codes are the
boundary’s own and the same in every library:
| code | meaning |
|---|---|
-1 E_ARGUMENT |
a null pointer where one was not allowed, text that is not UTF-8, a handle already freed |
-2 E_TOO_SMALL |
the caller’s buffer did not hold the result; written says what would |
-3 E_NOT_RUNNING |
the library has not been initialised, or has been shut down |
-4 E_PANICKED |
a panic was caught at the boundary; the library is poisoned (D5) |
-5 E_REENTRANT |
a call on a handle from inside a callback running on that same handle (D8) |
-6 E_CLEANUP |
shutdown’s drain did not finish inside its deadline (D7) |
-7 E_CANCELLED |
the async call was cancelled through its ticket before it finished (ADR-129) |
<package>_last_error(uint8_t* out, size_t cap, size_t* written) renders the
last failure on this thread: the message, the raise site of
ADR-023 D6, and the secondary list of ADR-115,
indented. It is a thread-local slot, so two threads failing at once do not
read each other’s failure.
Unwinding into C is undefined behaviour, and aborting is ending a process
that is not ours. So the emitted crate for a library artifact unwinds —
the one artifact where Appendix A’s abort at user_parallelism = no does not
hold, for the reason that page gives for aborting: there, nobody survives
for whom anything else would be done; here, the host does. Every entry point
catches at the boundary, returns E_PANICKED, and the library is
poisoned: every later call returns E_PANICKED until <package>_shutdown
and <package>_init have run again. A half-finished library state is not one
the host may keep using without saying it knows.
The panic hook runs before the catch, as it does everywhere (Part I 7.2).
The caller has no executor. So pub extern "C" fn fetch(…) whose body may
pause becomes two symbols:
hello_fetch(inputs…, outputs…) blocks the calling thread until the
work is done. The calling thread drives that one future to completion
itself, with the library’s reactor shared; two callers on two threads block
two threads and neither waits for the other.hello_fetch_async(inputs…, void (*done)(int status, void* ctx), void* ctx, hello_op** op)
returns at once; the work is spawned on the library’s executor, and done
is called on a library thread with the status, after which the caller
reads the outputs (which it owns, D3) or last_error. The caller’s ctx
must be safe to touch from another thread, and the header says so. op
is the ticket the call can be cancelled by, or NULL (ADR-129).A sync function has the first form only. A C programmer who wants nothing
to do with pausing uses the first form and never learns the second exists.
On a WebAssembly target, where the host’s thread may not block, only the
second form exists and the generated JavaScript makes it a Promise
(ADR-130).
<package>_init() starts the runtime — idempotent, safe from any thread —
and every entry point calls it if nobody has, so a caller that does not care
does nothing. <package>_shutdown() drains pending cleanups within
cleanup-deadline; where the deadline expires it returns E_CLEANUP with the
resources named in last_error, and never ends the process, because the
process is not ours (ADR-112 is the program’s rule, and this is
its library form). After shutdown every call is E_NOT_RUNNING until init.
set_allocator after init is E_ARGUMENT.
There is no file to read at start: nikaia-runtime.toml’s settings are read
at build time and baked into the artifact, as ADR-119 D7 does
for a target without a filesystem, and user-pool sizes the library’s own
executor for D6’s async form.
A C program may call two methods on one handle from two threads at once, and
nothing in C stops it. Inside the language that is a data race a compiler
refuses; at the boundary it is caught by a lock the handle carries:
exclusive for a method the ledger marks mutates, shared otherwise, taken for
the duration of the call. The cost is one lock per call, which is what a
boundary is for.
A callback that calls back into the same handle from inside a method’s
call would wait on that lock forever. The lock records the thread that holds
it, and a call from that thread on the same handle returns E_REENTRANT
instead of waiting. The header says that a callback may not call into the
handle it was called from.
--locked catches an ABI change.<package>.h is derived from nikaia.contracts — the signatures, the types,
the error variants, the enum numbering — and carries the ledger’s hash and the
package version in a comment. An exported signature that changes changes the
ledger, and a --locked build refuses until the ledger is committed
(ADR-100, ADR-101); so an ABI change is never an
accident a downstream build discovers by linking.
<package>_<fn> for a function — the prefix is the package’s name, or the one symbol-prefix the build sets, and no declaration renames itself (ADR-128) — <package>_<Type>_<method> for a method,
<package>_<Type>_new for the anonymous constructor, <package>_<Type>_free,
<package>_<Type>_<field> for a pub field’s getter; constants
<PACKAGE>_OK, <PACKAGE>_E_<VARIANT>, <PACKAGE>_<TYPE>_<VARIANT>. A name
that collides after prefixing is refused at the declaration. Only these
symbols are visible in the artifact; everything else is hidden.
&str, Bytes, Session,
throws; the C shapes are the emitter’s.std::db and before the bare-metal target, which reuses
the allocator and the baked settings; the HTTP server is the last of the
five big pieces, by the owner’s order.Nothing. extern "C" with a body is a parse error, there is no library
artifact, and the compiler has no header generator. The order of work:
pub extern "C" fn with a body; the four refusals of D1;artifact = "c-library" in [build], and the safe shape seeded at entry
points as ADR-062 D2 says;set_allocator, init, shutdown, last_error, free and the getters,
with baked settings (D3, D7);throws, called
from a C program in examples/, and the test that links it.#88 carries it. Five records stand on this one: the value struct (ADR-127), the symbol prefix (ADR-128), cancel and stream (ADR-129), the WebAssembly library (ADR-130) and the generated bindings (ADR-131).