ADR-284: A Nikaia Library for Other Languages Is One C Artifact — a Target That Is Safe at Its Boundary, Entry Points That Answer With a Status, the Caller’s Memory, and Every Other Host a Generated File Over It

Status: Accepted Date: October 3, 2026 (D30-D33: October 10, 2026) History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: the question whether a binary Nikaia library needs a single-threaded and a thread-safe build; #88 (a library C and every C-speaking host can call), #108 (a struct by value), #109 (the symbol prefix), #110 (cancel and stream), #111 (the WebAssembly library), #112 (bindings), and whether Node gets a native add-on Related: ADR-302 (the other direction: calling C — extern reserved with its construct, a view and an opaque handle); ADR-184 D1 (a view is spelled ref X); ADR-037 D1, D2, D7 (the target decides what std offers; user_parallelism bounds your code; the per-value count); ADR-312 D6, D9 and D11 (no verdict reads the switch; no Shared at a foreign call); ADR-119 D4, D7 (an allocator the build names; settings baked at build time — reused here); ADR-010 D1 (an FFI value is Untrusted); ADR-023 D6 and ADR-292 (raise site and secondary); ADR-297 (the cleanup deadline); ADR-100, ADR-308, ADR-005 D8 (the ledger, its hash, --locked); ADR-277 (a function-typed parameter); ADR-152 D2 (Array[T, N], D15’s array field); ADR-292 D14 (cancellation inside the language); ADR-288 D11 (an entry is written and reviewed, not copied from Rust); ADR-290 (nikaia describe, the other generated file); Part III 15.1, 15.3, 17.2, Appendix A

1. Question

What does a Nikaia library look like to a program written in another language? A C program, a Python module, a Node or browser page, a Java service over Panama: every one of those hosts speaks the C calling convention, and the comfortable bindings (PyO3, napi-rs) are wrappers over the same entry points. So the C ABI is the floor every other host stands on, and the question has six parts:

  1. Threads. A library is called by somebody who owns the threads. Is that a third value of user_parallelism, a second artifact, or something else?
  2. The entry point. How is one written, what crosses, in whose memory, how does a failure, a panic or a pause arrive at a caller with no executor, and how does the library start and stop in a process it does not own?
  3. Small aggregates. A Point { x, y } as an opaque handle with _new and _free around two doubles is not what a C programmer expects.
  4. Names. May a declaration choose its own symbol?
  5. Cancel and stream. How does a host stop an asynchronous call, and how do many results cross?
  6. Other hosts. Is the web a second calling convention, and is Python or Node a second artifact?

The question cannot wait for a program: the first target added without these answers would answer them by accident.

2. How others do it

Language / tool Its answer What it costs there
CPython C extensions the GIL serialises callers a C extension may release it around its own work, and free-threaded CPython removes it — one caller thread is not a promise
PyO3, napi-rs a native module per host language a second translation table (Python objects, JS values) beside the C one, a second build dependency, a second thing out of date
Rust #[repr(C)] gives a struct C’s layout one attribute; a packed layout reads slowly and cannot be borrowed into
Rust and every web toolchain on wasm32 a module’s exports are C-convention functions; the host reaches the module’s memory only no second convention; the host must take its buffers from the module
Well-aged C libraries the caller owns the memory; a call returns a status, never unwinds, never blocks unannounced; names prefixed consistently the caller passes buffers and checks every return
Java (Panama), C# (P/Invoke), Go (cgo) load a C library with their own means nothing the library has to do

What does not transfer: a native module per language assumes the compiler should own every host’s object model; this compiler owns one ledger, and every host-facing file is a projection of it.

3. Options, weighed

Option Rejected by Why it lost
A third value of user_parallelism for “foreign code calls in” D1 the switch bounds your code; asking it about a caller’s threads makes it mean two things, the second silently
Two artifacts, single-threaded and thread-safe, the consumer picks D2 two of everything to build and name, and a wrong pick is a data race, not an error
The safe shape (atomic count, real lock) on every value D2 pays everywhere for a property only the boundary has — the cost ADR-037 D7 was built to stop
A NUL-terminated string scanned for, results allocated by the library D5, D6 the length is a fact the caller has; memory the caller did not ask for is what an embedded, garbage-collected or fuzzing host cannot take
Unwind into C, or abort D8 unwinding into C is undefined behaviour; aborting ends a process that is not ours
A handle for every exported struct D14 ten thousand samples become ten thousand handles
A per-declaration rename (as "name", an attribute) D13 the header stops being a projection of the ledger, and a reader cannot find a function by its symbol
A queue behind a stream D21 a buffer grows behind the host’s back; the callback’s return is already the backpressure
Returning a Vec of text or handles, or a Seq D20 no shape for it in C; the refusal names the callback idiom instead
extern "wasm" D22 a second word for the same convention; what differs on the web is memory and thread, not the call
set_allocator and a blocking form on WebAssembly D23, D24 one linear memory the module already manages; the host’s one thread must not block
artifact = "python-module", a native module per language D26 a second artifact, a second table, a second dependency
A napi dependency in the emitted crate, or building a Node add-on now D28 a second artifact reopens which artifact is the program; nothing in the tree needs it

4. Decision

The rules are grouped as the question is: the target (D1-D3), the entry point (D4-D13), the value struct (D14-D18), cancel and stream (D19-D21), WebAssembly (D22-D25), other hosts (D26-D29).

D1 — Whether foreign code may call in is a property of the target, not a third value of user_parallelism.

The target already decides what std offers and what a panic does (ADR-037 D1) — what is outside this program, and what it does to us. user_parallelism bounds your code and cannot answer for a caller’s. A build that exports entry points is therefore a target of its own: the pair target + artifact (D4).

D2 — One build, safe at its boundary: not two artifacts, not the safe shape everywhere.

Every exported entry point, and everything reachable from one, takes the safe count and the safe lock; everything else keeps the per-value answer (ADR-037 D7). An entry point is a root seeded at the floor of the analysis that already answers per value.

D3 — The checks do not change, because no check reads the switch.

The crossing verdict is about a type and a destination (ADR-312 D6, ADR-312 D11), and a ledger contract is written at the floor. A library with foreign entry points is already checked for the world it enters; only what is emitted moves, under its entry points. A C signature names neither count shape, so ADR-312 D9’s representation problem does not return; what returns is the ownership of threads.

D4 — pub extern "C" fn with a body is an entry point; artifact = "c-library" makes the library; "C" is the only string.

Changed by ADR-324 D6: written extern, without "C"; extern "C" is NK1227.

pub extern "C" fn greet(name: ref String) -> String { f"Hello, {name}" }

extern "C" before a body is ADR-302’s word in its second form: the block without a body declares a function the language calls; a function with one is a function it provides. artifact = "c-library" in [build], beside target, makes a shared and a static library and <package>.h generated from the ledger. Four refusals at the declaration: extern without pub; a body in a build without a library artifact; a generic function or a trait method; a signature D5 does not cover, naming the shape to use. The string names the calling convention and is "C" on every target (D22).

D5 — What crosses, and what it is in the header.

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
ref String, ref Array[u8], Bytes in pointer and size_t never a terminator; the caller keeps it; text not UTF-8 is a status
String, Bytes, Vec[u8] out uint8_t* out, size_t cap, size_t* written the caller’s buffer (D6)
a list of numbers in const int64_t*, size_t an array the caller keeps
a pub extern "C" struct the struct, by value D14-D16
any other exported pub struct struct <package>_<Type>*, opaque a handle: its extern constructor, <package>_<Type>_free, a getter per pub field, its extern methods
enum without payload a C enum numbered in declaration order; appended, never reordered
enum with payload an opaque handle, _kind and a getter per field as a struct (D31)
a pub extern enum a tagged union, by value D32
T? of a handle or text NULL  
T? of a number the value’s out-parameter and bool* present C has no absent integer; the status stays OK (D30)
fn(A) -> R sync parameter a function pointer and void* ctx called on the calling thread, or a library thread for D9’s async form; a pausing callback is refused; a bool return is the stream’s stop (D20)
Locked, Shared, Seen, a map, a list of anything else — refused, naming a handle around a struct, or a callback for many results

Every value that enters is Untrusted (ADR-010 D1).

D6 — The caller owns the memory.

A text or bytes result is written into the caller’s buffer: at most cap bytes, written set to the full length, E_TOO_SMALL with written the size that would do, out == NULL the size query. What the library holds across calls comes from <package>_set_allocator(alloc, free, ctx) if the caller calls it before init, the platform heap otherwise. What a callback receives is a view that lives for the call. (On WebAssembly the host takes its buffers from the module instead, D23.)

D7 — Every entry point returns an int status, and a value travels in an out-parameter.

Changed by D33: a library’s own variants start at 100 000, and std’s errors have codes below it.

0 is <PACKAGE>_OK; a throws function’s variants are 1…, per library in declaration order, <PACKAGE>_E_<VARIANT>, part of the ABI. Seven negative codes are the boundary’s, the same in every library: -1 E_ARGUMENT, -2 E_TOO_SMALL, -3 E_NOT_RUNNING, -4 E_PANICKED, -5 E_REENTRANT, -6 E_CLEANUP, -7 E_CANCELLED. <package>_last_error(out, cap, written) renders this thread’s last failure with its raise site (ADR-023 D6) and secondary list (ADR-292).

D8 — A panic never leaves the library: it is caught at the boundary and poisons it.

The emitted crate of a library artifact unwinds — the one artifact where Appendix A’s abort at user_parallelism = no does not hold, because the host survives. Every entry point catches, returns E_PANICKED, and every later call does too until shutdown and init have run. The panic hook runs first.

D9 — A function that may pause is exported blocking and _async.

<package>_fetch(…) blocks the calling thread, which drives that one future itself over the shared reactor. <package>_fetch_async(…, done, ctx, op) returns at once; done(status, ctx) runs on a library thread; op is D19’s ticket. A sync function has the blocking form only. On WebAssembly only the async form exists (D24).

D10 — The runtime starts on the first call or on init, and stops on shutdown.

<package>_init() is idempotent and safe from any thread; every entry point calls it if nobody has. <package>_shutdown() drains cleanups within cleanup-deadline and returns E_CLEANUP, naming the resources, where it expires — never ending the process. After it, every call is E_NOT_RUNNING; set_allocator after init is E_ARGUMENT. nikaia-runtime.toml is read at build time and baked in, as ADR-119 D7 does; user-pool sizes the library’s executor.

D11 — A handle carries a lock, and a re-entrant call on it is a status.

Exclusive for a method the ledger marks mutates, shared otherwise, held for the call. A call from the thread already holding it, on the same handle, is E_REENTRANT instead of a deadlock.

D12 — The header is generated from the ledger and carries its hash; --locked catches an ABI change.

<package>.h is derived from nikaia.contracts and carries its hash and the package version; a changed exported signature changes the ledger, and a --locked build refuses until it is committed (ADR-100, ADR-308).

D13 — The compiler names every symbol: one prefix, and no declaration renames itself.

<prefix>_<fn>, <prefix>_<Type>_<method>, <prefix>_<Type>_new for the anonymous constructor, _free, <prefix>_<Type>_<field>; constants <PREFIX>_OK, <PREFIX>_E_<VARIANT>, <PREFIX>_<TYPE>_<VARIANT>. The prefix is the package name with - written _, or the one symbol-prefix in [build]; it is a C identifier or the build refuses it, naming the character. There is no as "name" and no attribute: what a program wants called differently outside, it renames inside. A collision after prefixing is refused at the declaration; only these symbols are visible in the artifact.

D14 — pub extern "C" struct has C’s layout and crosses by value.

pub extern "C" struct Point { pub x: f64, pub y: f64 }

Fields in declaration order with C’s alignment and padding (#[repr(C)] below), written into the header as a typedef struct. In, out, as a parameter, a result and a field, by value; no handle, _new, _free, getter or lock. Inside the language it is an ordinary struct.

D15 — A field is a C value, and every field is pub.

A number, bool, char, a payload-free enum, another extern "C" struct, or an Array[T, N] of one of these (ADR-152 D2: inline, laid out as C lays it out). Anything else — text, bytes, a list, an optional, a lock, a Shared, a function, a struct without the word — is NK1145, whose message names the handle as the shape to use.

D16 — Its rows in the table, and its methods.

In or out: by value. A Vec of them in: const <prefix>_T*, size_t. Out: the caller’s buffer counted in elements, with D6’s size query and E_TOO_SMALL. T? of one: bool* present, as for a number (D30). A method is <prefix>_<Type>_<method> with self as const T*, or T* where it changes it — and no lock: a value struct is the caller’s memory and two threads writing one is the caller’s race, which the header says.

D17 — The layout is part of the ABI, and the ledger carries it.

Field order and types are in nikaia.contracts; a reordered, retyped or appended field changes the hash and --locked refuses. There is no versioning in the struct: a library that must stay binary-compatible across an added field exports a handle.

D18 — The same type serves the other direction.

An extern "C" struct is also what a declared C function of ADR-302’s block takes or returns by value. One word, one layout, both directions; a value struct carries no address.

D19 — The _async form takes an optional ticket, and <prefix>_cancel cancels at the next pause point.

<prefix>_op** op is the last parameter; NULL allocates nothing. <prefix>_cancel(op) cancels the task at its next pause point, as a select loser is, with its cleanup run under cleanup-deadline. done is called exactly once: the work’s status, or E_CANCELLED when cancellation came first. Cancel after done, or twice, is OK. The caller frees the ticket with <prefix>_op_free after done; before it, that is E_ARGUMENT and the ticket is kept. The blocking form has no cancellation.

D20 — A stream is a function that takes a callback, and the callback’s bool is the caller’s stop.

pub extern "C" fn lines(path: ref String, each: fn(ref String) -> bool sync) throws { … }

each is called once per item, the item a view for the call; false ends the function with OK. A callback returning nothing streams without a stop. A function that returns a Vec of text, a Vec of handles or a Seq is refused with write it as a function that takes fn(item) -> bool sync.

D21 — Backpressure is the callback’s return, not a queue.

The next item is produced after the callback returns, never before: at most one item in flight per call. A host that wants a queue builds it in memory it owns.

D22 — A WebAssembly library is the same entry point on another target.

target = "wasm32-unknown" with artifact = "c-library" makes <package>.wasm, <package>.js (an ES module for browser and Node) and <package>.d.ts from the same declarations and ledger; the .js is the header for a host without headers. extern "wasm" is refused: the convention is "C" on every target; the target names WebAssembly. Everything of D4-D13 holds except where memory and thread differ (D23, D24).

D23 — The host obtains its buffers from the module.

<prefix>_alloc(size, align) -> i32 and <prefix>_free(ptr, size, align); the .js does the asking, writing, reading and freeing, so a caller writes hello.render("name"). No set_allocator. A handle is an offset wrapped in a class with free(), registered with FinalizationRegistry where offered — a convenience, not a guarantee.

D24 — A pausing entry point has only the callback form, and the .js makes it a Promise.

No blocking form. The module’s executor is driven from the host’s event loop; each pausing function is a Promise, cancellable through an AbortSignal that maps to <prefix>_cancel. A sync entry point is called plainly.

D25 — What a WebAssembly library may do is what its target’s std offers.

Part III 17.2’s availability table decides; the host’s fetch, timers and storage reach the module through the mechanism the target already uses for its I/O worker. Entry points change nothing about it.

D26 — nikaia bind <language> writes a binding over the C library; the artifact stays one.

nikaia bind python writes <package>/__init__.py over ctypes; nikaia bind js is D22’s .js and .d.ts. A binding is generated source the host loads with what it ships, regenerated with the header, carrying the same ledger hash. There is no artifact = "python-module".

D27 — What the Python binding makes of the boundary.

A non-OK status is an exception (<pkg>.Error with a subclass per variant, <pkg>.BoundaryError subclasses for the negative codes, carrying last_error); buffers are str or bytes with the size query done for the caller; a handle is a class with methods, properties, close(), a context manager and __del__; a value struct is a ctypes.Structure; an enum an IntEnum; T? is None; a callback a callable kept alive by CFUNCTYPE; _async a callable form and, under a running loop, an awaitable through call_soon_threadsafe whose cancel() is the ticket; a stream a generator. The wrapper locks only around init and shutdown.

D28 — JavaScript is served by the WebAssembly build; a native Node add-on, where Node must reach a file or a socket, is generated C.

For JavaScript the binding is the WebAssembly build, which runs in a browser and in Node without a native compile. Where a program needs Node to reach a file or a socket, which the WebAssembly build cannot, nikaia bind node writes an N-API shim in C over the C library, which the host’s toolchain compiles — and never a napi dependency in the emitted crate, which would be a second artifact.

D29 — A binding never widens what the library exports.

Only the entry points: no function the ledger does not mark extern, no field the header does not read, no constructor the library lacks. What a host cannot express is decided in the binding’s table, not per program.

D30 — An absent value is a flag beside it, not a status.

Changes D5 and D16, which named a status <PACKAGE>_NONE and gave it no value.

A T? of a number, or of an extern struct, comes out through two out-parameters: the value’s, and bool* present after it. The status is OK whether the value is there or not; where present is false the value’s out-parameter is left untouched.

int64_t n; bool present;
int s = lib_find(key, len, &n, &present);   /* s == LIB_OK; present says whether n holds */

A status keeps one meaning: 0 is a call that worked, anything else one that did not. A binding makes the pair its language’s absent value: None in Python (D27), null in Node (D28).

Rejected: a status for the absent value, either as an eighth negative code (-8), where a caller’s if (s < 0) treats an ordinary not found as the boundary failing, or as 1 with the variants from 2, where it sits among the errors and renumbers them. A positive code that is no error is what makes SQLite’s SQLITE_ROW and SQLITE_DONE easy to mistake.

D31 — A handle to an enum with payload is read through _kind and a getter per field of each variant.

<prefix>_<Type>_kind(h, &kind) writes the variant as a C enum (<PREFIX>_<TYPE>_<VARIANT>). A field’s getter is <prefix>_<Type>_<Variant>_<field>; a field without a name is numbered from 0 in its variant, as Rust’s .0, so Circle(f64) is read by <prefix>_Shape_Circle_0. A getter called on a handle that holds another variant returns E_ARGUMENT and leaves its out-parameter untouched; last_error names the variant the handle holds.

Rejected: a boundary code of its own for the wrong variant, which every library and binding would carry for a case _kind already answers; and a panic, which would poison a library that did nothing wrong.

D32 — pub extern enum is a tagged union and crosses by value.

pub extern enum Shape { Circle(f64), Rect { w: f64, h: f64 }, Empty }

As pub extern struct is to a struct (D14), so pub extern enum is to an enum with payload: C’s layout, no handle. The header writes it as #[repr(C)] lays it out, which is what cbindgen writes for such an enum:

typedef enum { LIB_SHAPE_CIRCLE, LIB_SHAPE_RECT, LIB_SHAPE_EMPTY } lib_Shape_Tag;
typedef struct {
    lib_Shape_Tag tag;
    union {
        struct { double _0; } circle;
        struct { double w; double h; } rect;
    };
} lib_Shape;

D33 — std’s errors have codes below 100 000, its I/O errors the target’s errno; a library’s own variants start at 100 000.

Changes D7, whose variants were 1….

The library and its header are built for one target, so the header can carry that target’s numbers, and a caller may compare a status with EILSEQ on any platform. A number does not travel between platforms; the name does: the constant, last_error, a binding’s exception (Python’s FileNotFoundError for ENOENT, Node’s code).

Rejected: numbering std’s variants after the library’s own, which moves every std code when the library adds a variant, and every library code when std does, and gives NotFound another number in each library; one fixed number for all targets, which a caller comparing it with its own EILSEQ would misread on two platforms of three; and refusing a std error at an entry point, which makes every library that reads a file translate its errors to be exported. libuv, Node, Go and Python all give the platform’s number and a portable name beside it.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.