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
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:
user_parallelism, a second artifact, or something else?Point { x, y } as an opaque handle with _new and
_free around two doubles is not what a C programmer expects.The question cannot wait for a program: the first target added without these answers would answer them by accident.
| 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.
| 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 |
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).
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).
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.
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.
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).
| 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).
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.)
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).
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.
_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).
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.
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.
--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).
<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.
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.
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.
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.
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.
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.
_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.
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.
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.
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).
<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.
.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.
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.
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".
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.
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.
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.
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.
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.
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;
extern enum among them,
anything else NK1145. A variant without fields has no member in the union.
A field without a name is _0, _1, …; a variant’s member is its name in
lower case.extern struct: in and out by value, a Vec
as an array in and the caller’s buffer out, its methods with self as
const T* or T*, no lock. It is a C value itself, so it may be the field
of an extern struct or of another extern enum.tag that names no variant is E_ARGUMENT, and the
variant’s fields are checked as D15’s are.enum.ctypes.Structure with the union
in Python (D27), an object { kind: "Rect", w, h } in Node (D28).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….
1 to 99 999 are std’s. A std error that is an errno has the
target’s number for it: io::IoError::NotFound is ENOENT,
PermissionDenied is EACCES, NotText is EILSEQ - 84 on Linux, 92 on
macOS, 42 under Windows’ C runtime, WASI’s own under WASI. The rest of
std’s errors have fixed numbers from 1000, a block of a hundred per type,
a variant appended at the end of its block, a type at the next block; no
number is ever moved. Part III 15.1 has the table.100 000 are the library’s own variants, numbered as D7 numbers
them, in declaration order across the library.<PACKAGE>_E_<NAME> with the number of the target
it was built for. A function that throws several types has the codes of
each.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.
.js and the
Python file need no author and carry the ledger’s hash.symbol-prefix; the safe shape
seeded at entry points; the emitter’s wrappers (status, out-parameters,
buffer protocol, allocator forwarder, catch at the boundary, blocking and
async forms, ticket, handle lock); #[repr(C)] and NK1145; the header,
.js/.d.ts and Python generators; baked settings; the executor bridge to a
host’s event loop.std::db, before the bare-metal target
(ADR-119), which reuses the allocator and baked settings.Locked[T], and the bridge type of ADR-312 D9.Array[T, N]: no counterpart, or a protocol that parses bytes.