ADR-127: A Struct Crosses the Boundary by Value When It Says extern "C", and Its Layout Is C’s

Status: Superseded by ADR-284 — its D1-D5 are D14-D18 there Date: September 19, 2026 Target Version: Nikaia 0.0.9 Related: ADR-125 (the library boundary: handles, buffers, status), ADR-124 (calling C, the other direction), ADR-100 (the ledger’s hash), ADR-062 (a library build is a target)

1. Context

ADR-125 hands a struct across the boundary as an opaque handle: the C side holds a pointer, reads fields through getters, and frees it through the library. That is the right shape for a Session that holds a connection, a lock and a buffer — and the wrong one for a Point { x, y }, a Rgb, a Range, a Sample { t, value }. A C programmer expects to write hello_Point p = {1.0, 2.0} and pass it, not to call hello_Point_new and hello_Point_free around two doubles. And a function that returns ten thousand samples cannot return ten thousand handles.

The language has a struct; what it lacked was the word that says this one has C’s layout. Rust spells it #[repr(C)]; this language says what touches the boundary with a word before the declaration, and it has that word already.

2. Decisions

D1 — pub extern "C" struct is a struct with C’s layout, and it crosses by value.

pub extern "C" struct Point { pub x: f64, pub y: f64 }
pub extern "C" struct Sample { pub at: i64, pub value: f64, pub flags: u8 }

pub extern "C" fn midpoint(a: Point, b: Point) -> Point { … }
pub extern "C" fn stats(samples: Vec[Sample]) -> Point { … }

extern "C" before struct is the same word as before fn, in the same place, saying the same thing: this is the boundary’s shape. The struct’s fields are laid out in declaration order with C’s alignment and padding for the target — what the emitted Rust writes as #[repr(C)] — so the header can write it as a C struct a C compiler agrees with:

typedef struct { double x; double y; } hello_Point;
typedef struct { int64_t at; double value; uint8_t flags; } hello_Sample;

Such a struct crosses by value, in and out, as a parameter, a result and a field. It has no handle, no _new, no _free, no getter and no lock: it is the caller’s memory, and the caller does with it what it does with any C struct. Inside the language it is an ordinary struct — constructed, matched, copied with with, held in a Vec — that happens to have a fixed layout.

D2 — What a field may be.

A field of an extern "C" struct is one of: a number, bool, char, an enum without payload (a C enum, numbered as ADR-125 D2 says), or another extern "C" struct. Every field is pub, because C reads it anyway and the header would otherwise say one thing and the language another.

Everything else is refused at the declaration with NK1145, naming the field and the shape to use: text, bytes, a list, an optional, a lock, a Shared, a function, a struct without the word. The message says a struct that holds this is a handle: leave out extern "C" and export its methods — because that is what ADR-125 does with it.

D3 — Where it stands in the translation table.

The table of ADR-125 D2 gains these rows:

in the declaration in C rule
extern "C" struct in or out the struct, by value copied both ways, as a number is
Vec[T] of such a struct in const hello_T*, size_t an array the caller keeps, as a list of numbers
Vec[T] of such a struct out hello_T* out, size_t cap, size_t* written the caller’s buffer, counted in elements, with the size query and E_TOO_SMALL of D3 there
T? of such a struct a status, <PACKAGE>_NONE, and the out-parameter untouched C has no absent struct, as it has no absent integer

A method on such a struct is exported as a method on any struct is, <package>_<Type>_<method>, with self as const hello_T* and &mut self as hello_T*. No lock is taken around it — ADR-125 D8 guards a handle because the library holds it; a value struct is the caller’s memory, and two threads writing one C struct at once is the caller’s race, as in every C library. The header says so in one line above the type.

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

The field order and the field types of an extern "C" struct are in nikaia.contracts, so a reordered or retyped field changes the ledger’s hash and a --locked build refuses until the change is committed — ADR-125 D9’s rule, which is ADR-100’s. A field that is appended changes the struct’s size, and that is a change too: a caller compiled against the old header passes a smaller struct. There is no versioning trick in the struct; a library that must stay binary-compatible across an added field exports a handle, which is what handles are for, and the record says so where D2 names the shape.

D5 — The same type serves the other direction.

An extern "C" struct is also what a declared C function may take or return by value inside ADR-124’s block — extern "C" { fn div(n: i32, d: i32) -> Div } with pub extern "C" struct Div { pub quot: i32, pub rem: i32 }. One word, one layout, both directions. What that record leaves open, the pointer, stays open: a value struct carries no address.

3. Consequences

4. What this does not decide

5. What is built

Nothing. extern "C" struct is a parse error. The order of work:

  1. the parser: pub extern "C" struct, and the refusal of one without pub;
  2. the field check and NK1145;
  3. the emitter: #[repr(C)], the by-value passing, the array in and the buffer out, the NONE status;
  4. the header’s typedef struct, and the ledger’s field record;
  5. an example: a Point exported and a Vec[Sample] reduced, called from C beside ADR-125’s example.

#108 carries it.

This site is open source. Improve this page.