SFEP-0079
Systems C Interop — a Specified Layout, Pointer, Callback, and Foreign-Reach Contract
- Status
- Accepted
- Type
- language
- Created
- Updated
- Author
- agent:compiler-architect (drafted); project owner (commissioned 2026-09-18)
- Tracking
- SFN-1288, SFN-1289, SFN-1290, SFN-1291, SFN-1292, SFN-1293, SFN-1294, SFN-1295, SFN-1296, SFN-1297, SFN-1298, SFN-1299; related SFN-1300 (project: https://linear.app/sailfin/project/systems-c-interop-828be16b7d55)
SFEP-0079 — Systems C Interop
1. Summary
Sailfin can already drive a Linux framebuffer from pure Sailfin: a spike
cross-built with sfn build --target aarch64-unknown-linux-gnu ran on an
aarch64 handheld. It opened /dev/fb0, read fb_var_screeninfo through a
Sailfin struct overlay, mmaped the framebuffer, and filled 786,432 pixels in
3.9 ms, with every store made through a typed raw pointer. All of that works
by accident. The struct layout, the pointer-store semantics, the callback path,
and the ABI that externs use are incidental and unspecified. Parts of the
documentation describe features that do not exist (@repr(C), ![unsafe],
&raw, read-only *T).
This proposal turns that accidental floor into a contract. It does five things:
- It gives
@repr(C)real meaning: a guaranteed layout, validated fields,size_of/align_of/offset_of, and optional compile-time size assertions. - It fixes one pointer model, matching Zig:
*Tis read-write,*const Tis read-only, and the existing deref/member/element-scaled arithmetic becomes specified. - It fixes C calling-convention fidelity: variadic externs, and
signext/zeroexton narrow integers. - It makes C↔Sailfin callbacks first-class: typed
* fn (A) -> Rextern parameters, plus C-ABI function definitions that C can call by name and that abort instead of unwinding across C frames. - It proposes an answer to the owner-level
externdesign gate (decision-brief §7.6, SFEP-0016 §4.4 Q3). Externs may attest effects. Every foreign symbol a build reaches is enumerated in the derived build manifest, so the Reach proof stays complete over an explicitly listed foreign trust base instead of silently ending at the firstextern.
2. Motivation
2.1 The consumer
The project owner intends to port MeshClient gradually to Sailfin. MeshClient is a ~140k-LOC C Meshtastic client that runs on a TrimUI Brick: aarch64 Cortex-A53, Tina Linux 4.9, glibc 2.33, 1 GB RAM, and a framebuffer UI. This is a real program with a hard OS surface, and it has clean seams to port across: a UI-backend vtable, a transport vtable, and a core layer that never includes UI headers. It needs the following:
| Surface | C shape | What Sailfin needs |
|---|---|---|
/dev/fb0 |
open + ioctl(FBIOGET_{F,V}SCREENINFO) + mmap(MAP_SHARED); XRGB8888; 1024×16384 virtual, visible page = yoffset |
C-layout structs with inline char/u32 arrays (fb_fix_screeninfo.id[16], fb_var_screeninfo.reserved[4]), variadic ioctl, pointer stores |
| evdev | EVIOCGBIT/EVIOCGNAME ioctls; read() of struct input_event (24 B on LP64) |
size_of<InputEvent>(), pinned layout |
| Serial | tcgetattr/cfsetispeed/tcsetattr at B115200 |
struct termios with c_cc[32] (inline array) |
| BLE | libdbus/BlueZ; C callbacks (watch/timeout add/remove/toggle functions registered into the loop) | typed C function pointers; Sailfin functions callable from C |
| Event loop | one epoll loop plus eventfd/timerfd |
struct epoll_event (union payload, packed on x86_64 only) |
| Crash handler | sigaction + sigaltstack |
handler = C-ABI function definition |
| DNS | fork |
plain externs |
| Libraries | mbedTLS, nanopb | foreign link inputs via [build] link-libs (SFN-1269, shipped) |
A gradual port also runs both directions at once. Ported Sailfin modules call the remaining C, and the remaining C calls ported Sailfin modules by symbol name, through the existing vtables.
2.2 The spike proves the floor
In fbspike.sfn (scratchpad, 2026-09-18), extern fn open/ioctl/mmap/munmap/ memcpy/memset/usleep/clock_gettime are declared by hand. fb_var_screeninfo
is read by casting *u8 to a struct of i32 fields, and pixels are stored
with px.v = … where px: *Word. Readback matched the written values, and
the owner confirmed the image on the device. A host probe confirmed i32
fields at a 4-byte stride and that typed-pointer field assignment lowers to a
store.
ioctl was declared fixed-arity. That is correct on AAPCS64 Linux, where
variadic and fixed integer arguments share x0–x7. It is wrong on Apple
arm64, where every variadic argument goes on the stack. The spike was
correct by platform luck.
2.3 What is actually true today (verified 2026-09-18)
| Claim | Reality | Evidence |
|---|---|---|
@repr(C) controls layout |
No implementation. Struct decorators are parsed into StructDeclaration.decorators (compiler/capsules/syntax/src/ast.sfn:430) and never read. @repr(C) and @bogus_thing(C) both pass sfn check silently. |
site/.../advanced/ffi.md:174-203; probe c_repr.sfn, i_deco.sfn |
| Struct layout is C-compatible | Incidentally, yes, in LLVM. Structs lower to non-packed identified types in declaration order (%P = type { i8, i32, i16, i64, i1, float, %Inner, i8*, i8* }), and allocation uses getelementptr %T, %T* null, i32 1 sizing. LLVM’s natural layout coincides with the LP64 C ABI for these scalars. |
sfn emit llvm probe j_layout.sfn, k_lit.sfn |
The .sfn-asm layout agrees |
It does not. analyze_type_layout knows only i32/i64/int/bool/pointers/string. u8/i8/u16/i16/u32/u64/f32/f64 fall through to size: 8, align: 8 (compiler/capsules/codegen/src/emit_native_layout.sfn:309-465). .layout struct name=Ev size=40 is emitted for a struct LLVM sizes at 24. Plain-struct GEPs use the field index, so this is latent for plain structs, but enum payload offsets do consume .layout offsets (codegen-llvm/src/lowering/instructions_match.sfn:512, expression_lowering/native/core_literals_lowering.sfn:1276). |
sfn emit native probe |
Externs reject i16/u16/u32/u64/isize/f32/f64 |
False. Slice C (#286, a3884092) admitted them. is_extern_primitive_type (compiler/capsules/analyzer/src/typecheck_types/extern_abi.sfn:128-146) accepts all of them, plus f16/bf16, int, and float. The header comment at extern_abi.sfn:22-42 is stale. |
probe a_types.sfn passes sfn check |
| Narrow integers cross the C ABI correctly | Unverified, and likely wrong off Linux-aarch64. No signext/zeroext attribute is emitted anywhere in compiler/capsules. x86-64 SysV (as clang implements it) and Apple arm64 both expect the caller to extend i8/i16 arguments to 32 bits. SFN-575 fixed Sailfin-to-Sailfin sign correctness only. |
grep zeroext|signext → 0 hits |
No pointer store exists except memcpy |
Stale. *p = v lowers to store i32 (probe e_deref.sfn/j_layout.sfn). Field stores through typed pointers appear throughout the runtime (runtime/sfn/type_meta.sfn:154, runtime/sfn/exception.sfn:205). The comment at runtime/sfn/adapters/filesystem.sfn:844-847 is out of date. There is no pointer_write intrinsic, and none is needed. |
probes |
*T is read-only |
Documented, not enforced. ffi.md and spec/06-types.md:125-127 say *T is read-only. Every runtime write goes through a *T. |
spike and runtime |
| Pointer arithmetic is element-scaled | True. q + 1 with q: *i32 → getelementptr i32, i32* %q, i64 1. The index currently round-trips through @round(double), which is lossless below 2^53 and is a codegen-quality issue, not a semantic one. |
j_layout.sfn IR |
| C calling back into Sailfin | Works by raw address. name as *u8 lowers to bitcast @fn to i8* (#1146), which compiler/tests/e2e/fn_reference_pthread_test.sfn covers: libpthread calls a Sailfin start routine. The runtime scheduler depends on it. What is missing: typed extern function-pointer parameters are dead. is_c_abi_function_pointer accepts only the tight fn( spelling, sfn fmt rewrites it to fn (, and * fn (A) -> R (the #1089 plain-pointer spelling) is rejected with E0805 (probes b_fnspace.sfn, n_starfn.sfn). Nothing stops a Sailfin throw from longjmping across C frames. |
runtime/sfn/platform/pthread.sfn:18-31 |
| C can call Sailfin by name | No. Defined functions are module-mangled, and there is no defining form for a C-ABI symbol. The only defining extern is extern var NAME: T = init (#1436). |
fn_reference_pthread.sfn header |
| Variadic externs | Parse error (E0500 at ...). |
probe f_var.sfn |
| Inline arrays in structs | Rejected (E0830, “arrays are written T[]”). The runtime works around this with runs of i64 slots (runtime/sfn/platform/pthread_layout.sfn). |
probe h_arr.sfn |
&raw value |
Documented as shipped, but does not type-check (E0818 “unstructured expression”). |
probe o_raw.sfn |
![unsafe], [capabilities] required = ["unsafe"], [policies.unsafe] |
Unenforced, and unsafe is not a canonical effect (compiler/capsules/analyzer/src/effect_taxonomy.sfn). |
ffi.md:47-52,116-151,331-367 |
| Externs can carry effects | Forbidden (E0804, extern_abi.sfn:420-434). compiler/capsules/analyzer/src/effect_checker/ never mentions externs, so extern calls are invisible to the effect system. |
declaration_and_statement_checks.sfn:177-181 |
| Linux syscall surface | Absent. No ioctl/mmap/epoll_*/eventfd/timerfd_*/tc{get,set}attr/sigaction extern exists in runtime/, stdlib/, or capsules/. Only poll does (runtime/sfn/platform/posix.sfn:56). |
grep |
The status quo has three concrete problems:
- Soundness. A C port needs guarantees, and “the layout happens to
match” becomes silent memory corruption the day a layout optimization lands.
The only thing standing in for a contract today is a hand-computed byte
offset (
fix + 48), and SFEP-0075 is already reshaping struct semantics underneath it. - Honesty.
ffi.mddocuments at least four unshipped features, which violates the “parsed but not enforced is not shipped” rule inCLAUDE.md. - The Reach pillar. Every
externis a hole in the capability manifest: it can do anything, and nothing records that it exists.
3. Design
The unifying principle: no new keywords, and no new effect. Every construct is existing Sailfin syntax given a specified meaning, the C/Rust/Zig spelling of a missing piece, or a library.
3.1 @repr(C) — the layout contract
// linux/input.h — 24 bytes on LP64 (aarch64 and x86_64 Linux).@repr(C, size = 24)struct InputEvent { sec: i64; usec: i64; kind: u16; code: u16; value: i32;}Semantics.
-
Order and padding. Fields are laid out in declaration order with C natural alignment. Each field is placed at the next offset that is a multiple of its alignment. The struct’s alignment is the maximum field alignment, and its size is rounded up to that alignment. This is exactly what the LLVM lowering already produces.
@repr(C)changes no generated IR for a valid struct. What it adds is the guarantee: any future Sailfin-native layout optimization (reordering, niche packing, field elision) must skip@repr(C)structs. -
Admissible field types. Allowed:
i8/i16/i32/i64,u8/u16/u32/u64,isize/usizef32/f64(andf16/bf16, which match clang’s_Float16/__bf16on the governed targets)- raw pointers
*T/*const T - typed C function pointers
* fn (A) -> R(§3.4) - another
@repr(C)struct by value - an inline array
[T; N]of an admissibleT(§3.6)
Everything else is
E0847:string,T[], closuresfn (A) -> R, enums, optionals, generics, non-@repr(C)structs, andbool.boolis rejected in v1 because C_Boolis a byte, and Sailfin’si1storage makes a loaded byte other than 0/1 UB; useu8. Admittingboollater with ani8storage lowering is a compatible widening. -
Target invariance. Every governed target (SFEP-0066 §3.2: x86_64/aarch64 Linux, arm64 macOS, x86_64 Windows) is 64-bit with identical natural alignment for every admissible field type. So a valid
@repr(C)layout is target-invariant, with one exception,packed, below. A C type whose layout does differ across targets (long,struct stat,epoll_event) must be modeled per target by the library that binds it (§3.8). That is a library concern, not a language one. -
packed.@repr(C, packed)lowers to an LLVM packed struct (<{ … }>, alignment 1, no padding), which is what__attribute__((packed))means. It exists for the one case MeshClient needs on x86_64 hosts:struct epoll_event. Loads and stores through a packed field usealign 1. -
Assertions.
size = Nandalign = Nare optional named decorator arguments. The compiler computes the layout and emitsE0848on mismatch, printing the computed per-field offsets. This is the Rustconst _: () = assert!(size_of::<T>() == 24)idiom without const evaluation, and it turns a header transcription error into a compile error. -
Misuse.
@repr(C)on an enum, a generic struct, or a struct with methods that capture, or with an unknown argument (@repr(c),@repr(C, pack)), isE0846. Methods are allowed;@repr(C)constrains data only.
Layout builtins. These use the existing explicit type-argument call syntax
(ident<i64>(3) type-checks today):
let n: usize = size_of<InputEvent>(); // 24let a: usize = align_of<InputEvent>(); // 8let o: usize = offset_of<InputEvent>("value"); // 20size_of/align_of accept any sized type. offset_of requires a
@repr(C) struct and a string-literal field name (Zig’s @offsetOf(T, "field")). An unknown field reuses E0015. All three lower to constants
computed by the same layout engine that E0848 uses. For size_of, that is
the getelementptr null, 1 idiom the struct allocator already emits.
Single layout authority. The C layout engine is new code in
compiler/capsules/codegen/src/emit_native_layout.sfn. It is used only for
@repr(C) structs and for the builtins, and it is checked against the LLVM
lowering by test. The legacy .layout table for non-@repr(C) types is
left unchanged on purpose. Correcting its 8-byte fallback globally would
change enum aggregate types and payload offsets. Runtime objects compiled by
the pinned seed would then disagree with user objects compiled by the fresh
compiler (see §6 and §5), which is a separate, seed-gated change.
3.2 The raw-pointer model
This section adopts Zig’s convention, because it is the one the code already uses:
| Spelling | Meaning | Enforcement |
|---|---|---|
*T |
read-write raw pointer to T |
(current behavior) |
*const T |
read-only raw pointer | store through it → E0852 |
*mut T |
accepted synonym for *T |
documented as legacy, no diagnostic in v1 |
*u8 / *void |
untyped byte pointer (C void*/char*) |
— |
The following operations are specified, and all of them already work:
- Deref.
*pis a load ofT.*p = vis a store ofT. - Member access through a pointer.
p.fwithp: *Sloads or stores fieldfat its offset, auto-dereferencing like Zig’sptr.field. This is the spike’spx.v = …. - Arithmetic.
p + n/p - nadvance byn * size_of<T>()bytes, which is C semantics. On*u8, the step is one byte. - Casts.
p as *Ureinterprets.p as i64/n as *Tconvert between address and integer.0 as *Tand thenullliteral are the null pointer. - Struct place to pointer.
s as *S, wheresis a struct binding, yields the address of that struct’s storage (today: the arena-allocated blocksfn_alloc_structreturned, probep_cast.sfn). This is how a Sailfin-ownedtermiosis handed totcgetattr. stringto pointer.s as *u8yields the data pointer. It is NUL-terminated only for string literals. Anything else passed to a Cconst char*must be copied with a NUL (library helper, §3.8), because SFEP-0033 slices are not NUL-terminated.
Retention rule (normative, matching Rust’s as_ptr borrow discipline): a
pointer into Sailfin-managed storage is valid only for the duration of the
foreign call it is passed to. That covers s as *S, str as *u8, and array
data. Sailfin storage may be arena-backed and reclaimed at phase boundaries
(SFEP-0043). Anything C retains must be allocated with malloc (or a
sfn/sys allocator), such as libdbus user_data or an epoll data.ptr.
This rule is documented, not enforced, in v1. A future ownership rule can make
passing arena storage to a retaining parameter an error once parameters can
be annotated.
&raw is removed from the documentation until it type-checks (it currently
fails E0818). s as *S covers the struct case, and scalars use a
malloc’d slot, which is the existing runtime idiom.
unsafe { } keeps its shipped meaning, and only that meaning: it is the
author-asserted region for the ownership checker (E0906). This SFEP does not
make any pointer operation require unsafe. That is a restriction without a
power, and it would churn the ~46 runtime files that do raw-pointer work
outside unsafe today.
3.3 Calling-convention fidelity
Variadic externs use the C and Rust spelling:
extern fn ioctl(fd: i32, request: u64, ...) -> i32;extern fn printf(fmt: *const u8, ...) -> i32;- Parse.
...is permitted only as the last parameter of anextern fndeclaration, after at least one named parameter. Any other placement isE0851. - Lower. The declaration becomes
declare i32 @ioctl(i32, i64, ...), and each call site uses the full function typecall i32 (i32, i64, ...) @ioctl(…). LLVM then applies the right per-target variadic convention: registers on AAPCS64 Linux and on the stack on Apple arm64. That is exactly the fix the spike needed. - Check. An argument in variadic position must already be a C
default-promoted type:
i32,u32,i64,u64,isize,usize,f64, or a pointer.i8/i16/u8/u16/bool/f32areE0851, with a hint to cast explicitly (x as i32,y as f64). Sailfin has no implicit promotion (SFEP-0058), so the author writes the promotion C would have inserted silently.
Narrow-integer extension. On extern declare lines and the matching
calls, the lowering emits signext on i8/i16 parameters and returns and
zeroext on u8/u16/bool. This matches what clang emits for the same C
prototype on x86-64 SysV and Apple arm64. It is a no-op on AAPCS64 Linux, and
it is what makes extern fn f(x: u16) correct on every governed target rather
than only on the Brick.
3.4 C→Sailfin: typed function pointers and C-ABI definitions
Typed callback parameters. An extern parameter or return of type
* fn (A, B) -> R (either formatter spelling, *fn( or * fn () is a plain
C function pointer. This is the #1089 spelling the lowering already
dispatches for Sailfin-side indirect calls. Each A/B must be a C-ABI
parameter type and R a C-ABI return type, using the same accept-list as the
extern itself. The bare fn (A) -> R form in extern position is retired:
outside externs it means a closure pair {fn, env}, so admitting it in
externs made one spelling mean two ABIs. No tree source uses it; the formatter
already made it unusable. A bare fn (A) -> R in extern position now gets
E0805 with a hint pointing at * fn.
Coercion. At a call to an extern whose parameter is * fn (A) -> R, a bare
reference to a named Sailfin function whose signature is exactly
(A) -> R, with every type C-ABI-admissible, coerces to its address. No as
cast is needed. E0850 is raised for the following:
- a signature mismatch
- a non-C-ABI signature (a
stringparameter, for example) - a lambda or closure with captures, which has no env slot in C
as *Tapplied to a function whose signature is not C-ABI
The existing name as *u8 (#1146) remains the untyped escape hatch, and the
runtime scheduler keeps using it.
extern fn dbus_connection_set_watch_functions( conn: *DBusConnection, add: * fn (*DBusWatch, *u8) -> u32, remove: * fn (*DBusWatch, *u8) -> void, toggled: * fn (*DBusWatch, *u8) -> void, data: *u8, free_data: *u8) -> u32 ![io];
extern fn on_watch_add(w: *DBusWatch, data: *u8) -> u32 ![io] { // runs when libdbus calls back return 1;}C-ABI definitions. extern fn name(params) -> R ![effects] { body } is an
extern fn with a body. It is Rust’s extern "C" fn plus #[no_mangle]
in one form, and the function-level twin of the shipped defining
extern var NAME: T = init (#1436). It has three properties:
- Validated signature. It uses the same accept-list as extern declarations (E0801–E0805, E0803 for type parameters).
- Unmangled symbol. The symbol is exactly
name, so the remaining C code in a gradual port can call a ported module by name through its existing vtable, and a C library can call it back.export extern fnmakes it importable by other Sailfin modules. A duplicate definition across the link fails at link time in v1. - Abort on unwind. The body is emitted inside a guard frame
(
sfn_exception_push_frame+setjmp, the same shape as the generatedmain). A Sailfinthrowor panic that would otherwiselongjmpacross C frames instead prints the panic and callsabort(). That is Rust’s behavior for a panic escapingextern "C"since 1.81, and the only one that cannot corrupt a C library’s internal state.
A C-ABI definition is an ordinary Sailfin function inside: effects,
ownership, and type checks all apply to its body against its declared
![…].
3.5 Externs and the Reach pillar
This section answers the owner-level design gate in decision-brief §7.6 and SFEP-0016 §4.4 Q3. Owner decision (2026-09-18): accepted as drafted. It is kept isolated so that a later revision does not disturb §3.1–§3.4 or §3.6.
(a) Effect-attested externs. E0804 is retired, and an extern may declare
effects:
extern fn connect(fd: i32, addr: *const u8, len: u32) -> i32 ![net];extern fn clock_gettime(id: i32, ts: *u8) -> i32 ![clock];extern fn memcpy(dst: *u8, src: *const u8, n: usize) -> *u8 ![];A call to an attested extern contributes its effects to the caller exactly as
a call to a Sailfin function does. So E0400 (missing effect), E0402
(cross-module propagation), E0403 (capsule manifest cross-check), and
hierarchical sub-effects (SFEP-0017) all apply unchanged. ![] is an explicit
attested-pure claim. An extern with no clause is unattested: it
contributes no effect, compiles as it does today, and is flagged in the
manifest (b). Attestation is trusted, not verified. A lying ![] on
connect compiles, and the manifest shows exactly that claim, next to the
symbol name, for a reviewer.
The restriction-vs-power test. E0804 exists today to force a wrapper
function around every extern just to attach effects, which is pure tax.
Lifting it removes code. The attestation is the power: one line of extern
carries its reach. Nothing new is forbidden.
(b) The foreign-reach record. Every build emits, alongside the existing
per-capsule build/capsules/<scope>/<name>/manifest.json
(compiler/src/capsule_artifact.sfn), an additive foreign object:
"foreign": { "outbound": [ {"symbol": "ioctl", "declared_in": "src/fb.sfn:12", "variadic": true, "attested": ["io"]}, {"symbol": "memcpy", "declared_in": "src/fb.sfn:15", "attested": []}, {"symbol": "usleep", "declared_in": "src/fb.sfn:17", "attested": null} ], "inbound": [ {"symbol": "on_watch_add", "defined_in": "src/ble.sfn:40", "effects": ["io"]} ], "link_inputs": ["-lm", "-lpthread", "-ldbus-1", "-lmbedtls"], "runtime": {"capsule": "sfn/runtime", "version": "<pinned>", "classification": "SFEP-0060 §2.1"}}outboundcovers every extern referenced by call, coercion, orasaddress in the capsule’s sources. It is a conservative superset of the reachable set, so it is complete.attested: nullmarks unattested.inboundlists every C-ABI definition (§3.4), because C code can enter the program there.link_inputsis the resolved link argv libraries (build inputs, not provenance, per the SFN-1269 design note). The runtime’s own foreign surface is recorded as one versioned entry that points at SFEP-0060’s classification rather than being re-enumerated.
The claim this earns: the derived manifest is complete: every effect of
Sailfin code is derived, and every edge into or out of foreign code is
enumerated with its attestation. A reviewer audits a finite, diffable list:
“this release added connect, unattested”. That is the Reach pillar’s
completeness claim, stated honestly over an explicit trust base.
Claims this does not earn, and must not be marketed:
- that foreign code is confined
- that attestations are true
- anything above SFEP-0016’s provenance-sealed tier
A binary with un-digested link-libs sits below that tier (SFN-1269 design
note §3). A later step can route an attested ![net] extern through the
seal’s gate hook (SFEP-0016 §3.4, SFEP-0060 §3.4) so that the attestation is
enforced at runtime. That is designed here, not shipped, and not phased in
this SFEP.
unsafe is not an effect. The ![unsafe] effect,
[capabilities] required = ["unsafe"], and [policies.unsafe] in ffi.md are
withdrawn:
unsafeis not canonical (effect_taxonomy.sfn).- As designed, it was a restriction without a power: a marker on every caller that proves nothing.
- The foreign-reach record supersedes its audit purpose with a derived artifact instead of an authored annotation.
The workspace-policy idea (allowed_capsules) survives as future work keyed
on the record (§9 future considerations).
3.6 Inline fixed-size arrays in @repr(C) structs
@repr(C, size = 80) // LP64 sizeof(struct fb_fix_screeninfo)struct FbFixScreeninfo { id: [u8; 16]; smem_start: u64; smem_len: u32; kind: u32; type_aux: u32; visual: u32; xpanstep: u16; ypanstep: u16; ywrapstep: u16; line_length: u32; // offset_of = 48, the spike's hand-computed constant mmio_start: u64; mmio_len: u32; accel: u32; capabilities: u16; reserved: [u16; 2];}The [T; N] spelling is Rust’s. It is admitted only as a field type of a
@repr(C) struct, where N is an integer literal and T is admissible.
Anywhere else, including a let or a parameter type, it is E0853, and
E0830 keeps its existing meaning for malformed T[]. There is deliberately
no array value type: [T; N] has no binding, copy, or pass semantics in
v1.
- Access.
s.id[i]andp.id[i]load or store elementithroughgetelementptr … i32 0, i32 <field>, i64 i. A constant index is checked at compile time (E0853). A dynamic index is bounds-checked at runtime and panics likeT[]indexing. - Address.
s.id as *u8is the element-0 address, used to handc_cc/id/sun_pathto C. - Lowering. The field lowers to an LLVM
[N x T]member, and the layout engine countsN * size_of<T>()atalign_of<T>().
This is what retires the i64-slot workaround (pthread_layout.sfn) for new
code, and what lets termios, sockaddr_un, fb_fix_screeninfo, and
MeshClient’s own char name[40] structs be written as their headers are.
3.7 Unions: not required
MeshClient’s union-bearing types are epoll_data_t and sockaddr storage.
Neither needs a union type:
epoll_data_tis an 8-byte, 8-aligned union of{ptr, fd, u32, u64}. It is modeled as au64field and read with casts (d as *u8,d as i32), which is exactly what C code does after choosing a member.sockaddrfamilies are distinct@repr(C)structs addressed through*u8/*SockaddrIncasts. That is the POSIX idiom ((struct sockaddr *)&sin).
input_event, termios, and the framebuffer structs have no unions. A
union type is deferred until a consumer needs overlapping members of
different sizes that cannot be expressed as the largest member plus casts.
3.8 The syscall library: a follow-on, not this SFEP and not SFEP-0060
The OS surface (ioctl request encoders _IOR/_IOW, FBIOGET_*, EVIOC*,
termios, epoll/eventfd/timerfd, sigaction/sigaltstack, mmap,
a NUL-terminating to_c_string) belongs in a library capsule, sfn/sys
under stdlib/sys/, with a linux module first. “Libraries over keywords”
applies: no intrinsic, keyword, or builtin is added for any of it.
It is not part of SFEP-0060. That proposal is the runtime-internal
chokepoint: an allowlisted syscall1…6 builtin reachable from one runtime
module (SFEP-0060 §3.2), whose allowlist is a correctness requirement of the
seal. User-facing bindings must not widen that allowlist. sfn/sys binds libc
through effect-attested externs (§3.5). Where SFEP-0060 later owns a stub
for a symbol, sfn/sys calls the runtime’s owned stub instead, so the gate
hook covers it.
It is not part of this SFEP, because its API is a design of its own:
Result-returning wrappers, Linear<Fd> ownership, and per-target struct
modules, since epoll_event is packed only on x86_64 and Sailfin has no
cfg. It needs its own SFEP and Project, blocked by this one’s Phases 1–3.
3.9 Phasing
Each leaf is one session and one PR, and each self-hosts from the pinned seed.
Every capability is bundled with its own regression tests as its consumer
(.claude/rules/seed-dependency.md), so no leaf creates a seed-cut gate.
| Leaf | Scope | Blocked by |
|---|---|---|
| L1 | Reconcile ffi.md, spec/06-types.md:125-127, and the stale comments in extern_abi.sfn:22-42 and filesystem.sfn:844-847 with shipped reality: mark @repr(C) as planned (link this SFEP), withdraw ![unsafe]/[policies.unsafe], drop &raw, and document the accepted extern widths. |
— |
| L2 | @repr(C) validator + C layout engine + packed + size/align assertions + size_of/align_of/offset_of (E0846–E0848) |
— |
| L3 | Pointer model: *const T store rejection (E0852); spec of deref, member access, arithmetic, and casts; the retention rule |
— |
| L4 | Calling-convention fidelity: variadic externs (E0851) + signext/zeroext |
— |
| L5 | Typed * fn extern parameters + named-function coercion (E0850) |
— |
| L6 | C-ABI definitions: extern fn … { body }, unmangled, abort-on-unwind |
L5 |
| L7 | Effect-attested externs: E0804 retired, ![] vs. no clause |
owner decision on §3.5 |
| L8 | Foreign-reach record in manifest.json |
L6, L7 |
| L9 | [T; N] inline arrays in @repr(C) structs (E0853) |
L2 |
L1 lands first because it removes documentation of unshipped features, which
CLAUDE.md forbids today. Every later leaf extends
reference/spec/13-foreign-interface.md and keeps ffi.md truthful.
4. Effect & capability impact
- Effect checker. Externs enter the callee-signature index that
compiler/capsules/analyzer/src/effect_checker/collector.sfnbuilds, so an attested extern call is indistinguishable from a Sailfin call. An unattested extern contributes nothing, as today. Declared effects on C-ABI definitions are checked against their bodies like any function. - Canonical effects. Unchanged:
clock, gpu, io, model, net, rand. Nounsafe,ffi, orforeigneffect is added. §3.5 explains why a derived record beats an authored effect. - Capsule manifest.
E0403now sees attested extern effects transitively. A capsule whose externs attest![net]must declarenetin[capabilities] required, which closes the “externbypasses the manifest” half of the hole for attested externs. - Seal (SFEP-0016). There is no change to the claim ladder. The
foreign-reach record is input to the seal: it enumerates what the
vetted-link-inputsrule (SFEP-0016 §3.5, unimplemented) will later digest-check. Nothing here upgrades any tier. - Owner gate. §3.5 is the proposed resolution of decision-brief §7.6
(“Is
externcapability-typed, forbidden in untrusted units, or something else?”). The answer is capability-attested + derived-enumerated. This SFEP must not move toAcceptedwithout the owner’s explicit call on §3.5. If the owner rejects it, only leaves L7 and L8 (§8.2) change. - Honest wording until L7 and L8 ship. “Extern calls are invisible to the effect system and not enumerated.” After they ship: “Every foreign edge is enumerated with its author attestation; attestations are trusted, not verified.”
5. Self-hosting impact
Passes touched
| Stage | Change | Section |
|---|---|---|
Parser (compiler/capsules/syntax/src/) |
... in extern params; extern fn with a body; [T; N] field type; * fn (…) already parses |
§3.3, §3.4, §3.6 |
AST (ast.sfn) |
FunctionSignature.variadic: boolean; ExternFunctionDeclaration gains body: Block?; an explicit-empty-effects flag so ![] is distinguishable from no clause. Structural: needs --clean-tree. |
§3.3–§3.5 |
Typecheck (analyzer/src/typecheck_types/extern_abi.sfn, declaration_and_statement_checks.sfn, a new repr_c.sfn) |
accept-list for * fn, E0804 retirement, E0846–E0853, builtin typing |
all |
Effect checker (analyzer/src/effect_checker/collector.sfn, analyzer.sfn) |
externs in the signature index | §3.5 |
Native IR (codegen/src/emit_native_layout.sfn, emit_native.sfn) |
C layout engine for @repr(C); .layout agrees with LLVM for those structs; variadic/definition markers |
§3.1, §3.3, §3.4 |
LLVM lowering (codegen-llvm/src/type_context.sfn, expression_lowering/native/core_call_lowering.sfn, core_expression_tail.sfn:312, lowering/lowering_helpers.sfn declare tracking, the mangling post-pass) |
packed types; [N x T] members; variadic declare/call; signext/zeroext; * fn coercion; unmangled guarded definitions; builtin constants |
§3.1–§3.6 |
Build (compiler/src/capsule_artifact.sfn) |
foreign object in manifest.json |
§3.5 |
Invariant. Every capability is consumed only by tests, examples, and user
capsules, all compiled by the freshly built compiler. No compiler-source or
runtime-source file adopts a new construct in the same PR. So each leaf
self-hosts from the current pinned seed with no seed cut
(.claude/rules/seed-dependency.md: bundle capability with consumer; here the
consumer is the leaf’s own regression tests).
The runtime carve-out, stated so nobody trips on it. The pinned seed
compiles runtime/. Runtime source may adopt a construct only after a seed
that understands it is pinned. Each adoption is a separate, later change:
[T; N]: the seed rejects it with E0830.- extern
...: the seed gives a parse error. - extern-with-body: the seed gives a parse error.
size_of: the seed has no such builtin.- attested externs: the seed rejects them with E0804.
The one exception is @repr(C) itself. The seed parses and ignores struct
decorators, so annotating runtime/sfn/platform/pthread_layout.sfn structs is
seed-safe at any time, but it earns nothing until the validator runs. No leaf
below requires runtime adoption.
Two hazards held out of scope deliberately:
- Correcting the legacy
.layout8-byte fallback for non-@repr(C)types. That would change enum aggregate LLVM types and payload offsets. Seed-compiled runtime objects and fresh-compiled user objects would then disagree on any enum crossing that boundary, a cross-seed ABI break. The fix is real, but it is its own seed-gated change and not a prerequisite here. - Rewriting the runtime’s hand-declared
*u8-typed callbacks to* fn. This is cosmetic and gated on the seed.
6. Alternatives considered
- Make the incidental layout the contract without
@repr(C)(“all structs are C-layout”). Rejected. It forecloses every future layout optimization (field reordering, niche packing) for all Sailfin code, just to serve the subset that crosses FFI. Rust, Swift, and Zig (extern struct) all separate the two. SFEP-0075 is already re-deciding struct semantics, and pinning layout globally would couple it to FFI. - Zig’s
extern structkeyword instead of a decorator. Rejected. It adds a keyword (“libraries over keywords”), and@repr(C)is already the documented spelling users and agents will reach for. Decorator arguments (packed,size = N) are named-argument syntax that already parses (DecoratorArgument.name). - A
pointer_write_*intrinsic family (the store half thefilesystem.sfncomment asks for). Rejected:*p = vandp.f = valready lower to correct stores. Intrinsics would be a second spelling of the same thing. - Rust’s
*const T/*mut Twith*Tmeaning const. Rejected. It inverts the meaning of every existing runtime*Twrite and forces a tree-wide churn with a seed gate. Zig’s*T/*const Tmatches what the code already does. - Require
unsafefor pointer operations and extern calls. Rejected. It is a restriction with no power attached (decision-brief §3). It would also be a large runtime churn and seed-gated. - A new canonical effect (
![ffi]or![unsafe]) on every extern caller. Rejected. It is authored rather than derived, it propagates through every caller ofmemcpy, and it tells a reviewer less than the enumerated symbol list does. It fails the restriction-vs-power test. - Forbid externs outside allowlisted modules (SFEP-0060-style). Rejected for user code. A gradual C port is thousands of externs across the tree. The allowlist model is right for the one module that can issue raw syscalls and wrong for the library boundary.
- Keep
E0804and require wrappers (status quo). Rejected. Wrappers are boilerplate, they are not enumerated anywhere, and an unwrapped extern is still invisible. That is the hole decision-brief §7.6 names. - By-value struct passing across externs. Deferred, and out of scope. It
needs per-ABI classification (SysV eightbytes, AAPCS64 HFA/HVA, Windows
x64 ≤8-byte rule), and SFEP-0075 keeps struct transport at
%T*. Every MeshClient surface passes structs by pointer (ioctl, termios, epoll, sigaction, dbus, mbedTLS, nanopb). E0805 continues to reject by-value struct parameters with a clear message. - Header import / bindgen. Deferred as future work (§9). It is the right answer for the libdbus and mbedTLS surfaces at scale, but it needs a C parser (or libclang, which is a foreign toolchain dependency SFEP-0066 would have to admit). Hand-written bindings over a checked layout contract come first.
7. Stage1 readiness mapping
Each box is ticked per leaf. The SFEP is Implemented only when all leaves are.
- Parses.
@repr(C, …)already parses...., extern-with-body, and[T; N]land in L4, L6, and L9.* fnextern types already parse. - Type-checks / effect-checks. Every construct: E0846–E0853, the E0805 hint update, E0804 retirement, and extern effect propagation.
- Emits valid
.sfn-asm.@repr(C).layoutagrees with LLVM, and variadic/definition markers are present. - Lowers to LLVM IR. Packed,
[N x T], variadic call types,signext/zeroext, guarded unmangled definitions, and builtin constants. - Regression coverage (§8).
- Self-hosts. Per leaf,
sfn dev bootstrap build. The AST-shape leaves (L4, L6, L7, L9) use--clean-tree. -
sfn fmt --checkclean. The formatter round-trips..., extern-with-body,* fn (…),[T; N], and@repr(C, size = N). - Documented in the spec chapter. A new
reference/spec/13-foreign-interface.mdis grown per leaf.advanced/ffi.mdis reconciled in L1 and kept truthful by every leaf.spec/06-types.md:125-127is corrected in L2.
8. Test plan
8.1 Tests
All tests are Sailfin *_test.sfn files. There are no bash scripts
(.claude/rules/no-bash-e2e.md). C sides use run_external_c_oracle from
sfn/test (capsules/sfn/test/src/external_c_oracle.sfn), which skips when
no C compiler resolves. Tests that spawn builds thread SAILFIN_TEST_SCRATCH
and PATH.
Unit (compiler/tests/unit/):
ffi_repr_c_validation_test.sfn:- E0846:
@repr(c),@repr(C)on an enum, and on a generic struct - E0847: a field of each rejected kind, including
bool - E0848: a deliberate
size = 20onInputEvent, with the message carrying the computed offsets - clean acceptance of the nested-struct, pointer,
* fn, andf16fields
- E0846:
ffi_extern_abi_test.sfn:* fn (…)accepted in both spellings- bare
fn (…)rejected with the new hint - E0851 variadic placement and promotion
- E0850 coercion mismatch, closure, and non-C signature
- E0852 store through
*const T - E0853
[T; N]outside@repr(C), and a constant out-of-range index
ffi_extern_effects_test.sfn:- an attested
![net]extern called from a non-![net]function gives E0400 ![]versus no clause is distinguishable in the analyzed program- E0403 fires for an attested effect absent from
[capabilities]
- an attested
- Update
numeric_low_precision_test.sfnonly if the E0805 hint text assertions depend on the changed message.
Integration (compiler/tests/integration/):
ffi_layout_builtins_test.sfn:size_of/align_of/offset_offorInputEvent(24/8/20), a packedEpollEvent(12/1),FbFixScreeninfowithid: [u8; 16](offset_of("line_length") == 48, the spike’s hand-computed constant), and nested structs.ffi_pointer_model_test.sfn: deref load/store,p.f = vthrough*S, element-scaled+/-on*i32/*i64/*u8, ands as *Sround-trip throughmemset.
E2E (compiler/tests/e2e/):
ffi_repr_c_c_oracle_test.sfn. This is the layout acceptance test. It writes a C harness that printssizeof/offsetoffor mirrors ofInputEvent,FbFixScreeninfo,termios-shaped{u32×4, u8, [u8;32], u32×2}, and a packed struct. The Sailfin program printssize_of/offset_offor the same types, and the test asserts the outputs are identical. A second leg builds the C side into a static archive linked via root[build] link-libs. C fills a@repr(C)struct through a pointer, and Sailfin reads every field back (plus the reverse direction).ffi_variadic_test.sfn: Sailfin calls a C harnessint sum_va(int n, ...)with mixedi32/i64/f64/pointer variadic arguments, andsnprintf. This runs on macOS arm64 and Linux. The macOS leg is the one that fails today under a fixed-arity declaration.ffi_narrow_int_abi_test.sfn: C harness functions taking and returningint8_t/uint16_t, called with boundary values (-128,65535). Compiled at-O2so the callee relies on caller extension.ffi_callback_test.sfn:qsortwith a Sailfin comparator passed as a bare name to a* fn (*const u8, *const u8) -> i32parameter- a C harness registering and later invoking a watch-style
(*u8, *u8) -> u32callback (the libdbus shape) - a C
maincalling an unmangledextern fn sfn_core_tick(…)definition by name (the gradual-port direction) - a definition whose body throws, which must
abort(asserted by exit signal) and never return into C
ffi_foreign_manifest_test.sfn: builds a capsule with attested, explicitly pure, and unattested externs, one C-ABI definition, and alink-libsentry. It asserts theforeignobject inbuild/capsules/<scope>/<name>/manifest.json, with byte-stable field order.- Cross-target smoke, folded into
ffi_repr_c_c_oracle_test.sfnas a best-effort leg.sfn emit llvm --target aarch64-unknown-linux-gnuof the layout fixture must show the same[N x T]and packed types as the host. This proves target invariance without a device.
8.2 Verification commands per leaf
build/bin/sfn check <touched .sfn files>sfn dev bootstrap build # add --clean-tree for L4, L6, L7, L9 (AST shape)build/bin/sfn test compiler/tests/unit/ffi_repr_c_validation_test.sfnbuild/bin/sfn test compiler/tests/e2e/ffi_repr_c_c_oracle_test.sfnbuild/bin/sfn test compiler/tests/e2e/ffi_callback_test.sfn -k "abort"sfn fmt --check <touched files>sfn dev verify runs once, when the last leaf lands.
Device acceptance (manual, owner). Rewrite fbspike.sfn against the
contract: @repr(C, size = 160) struct FbVarScreeninfo, offset_of instead
of + 48, and a variadic ioctl. Cross-build with
sfn build --target aarch64-unknown-linux-gnu and run it on the Brick. This
is recorded on the final leaf, not automated.
9. References
SFEPs
- SFEP-0016: capability-sealed runtime (claim ladder, §3.4–3.5 link provenance, §4.4 Q3)
- SFEP-0017: hierarchical effects
- SFEP-0023: capsule decorators (decorator surface)
- SFEP-0025: native runtime architecture (§3.6 extern lowering)
- SFEP-0033: length-aware string ABI (why
str as *u8is not a C string) - SFEP-0043: phase-scoped arena reclamation (the retention rule)
- SFEP-0056: aarch64 Linux target
- SFEP-0058: sized integers (no implicit promotion)
- SFEP-0060: owned syscall layer (§2.2 holes, §3.2 allowlist)
- SFEP-0066: governed targets
- SFEP-0067: platform-access ownership
- SFEP-0068: cross-target builds
- SFEP-0075: struct value semantics (transport stays
%T*)
Issues and PRs
- #286 (Slice C widths), #1089 (plain C fn pointers), #1146 (fn reference →
address), #1436 (defining
extern var) - SFN-571, SFN-575 (narrow-int call boundary), SFN-630
- SFN-1268 (
--static), SFN-1269 (rootlink-libs, plus design notedocs/proposals/design-notes/sfn-1269-link-libs-build-input-not-provenance.md), SFN-1287 (per-target sysroot)
Strategy
docs/strategy/decision-brief.md§3 (restriction-vs-power), §4 Pillar 1, §7.6 (theexterngate), §10
Docs
site/src/content/docs/docs/advanced/ffi.mdreference/spec/06-types.mdreference/preview/unsafe-enforcement.md
Prior art
- Rust:
#[repr(C)],#[repr(packed)],extern "C" fn,#[no_mangle],core::mem::{size_of, align_of, offset_of!}, abort-on-unwind atextern "C"(1.81) - Zig:
*T/*const T,extern struct,packed struct,@sizeOf,@offsetOf(T, "f"),callconv(.C) - C11:
offsetof, default argument promotions (§6.5.2.2)
Future considerations (not phased)
- A
sfn/syscapsule SFEP (§3.8). - Header import / bindgen.
- By-value struct passing.
cfg-style target-conditional declarations, for per-target struct variants.- A workspace policy keyed on the foreign-reach record:
[policies.foreign]allowlists of symbols or libraries per capsule, which is the surviving form offfi.md’s[policies.unsafe]. - Routing attested externs through the seal’s gate hook.
- Correcting the legacy non-
@repr(C).layoutfallback under a seed cut. - Admitting
boolin@repr(C)viai8storage. - Union types, if a consumer appears.