Proposal: Deep FFI — Higher-Level Foreign Function Interface

Status: Approved Author: Eric Created: 2026-02-21 Approved: 2026-02-21 Affects: Language core (parser, IR, type checker), compiler (codegen), standard library Depends on: Platform FFI (approved), Unsafe Semantics (approved), Drop Trait (approved), Stateful Mock Testing (approved) Extends: spec/24-ffi.md — all existing FFI syntax remains valid


Summary

Deep FFI is a set of opt-in annotations and compiler features that layer on top of the existing extern declaration syntax. Five abstractions — declarative marshalling, ownership annotations, error protocol mapping, capability-gated testability, and const-generic boundary safety — each independently useful, each progressively higher-level. No existing FFI code breaks. The goal: library authors write annotated extern blocks once; consumers see clean, safe, idiomatic Ori APIs with no FFI awareness.


Motivation

The Boilerplate Problem

Every stdlib FFI module follows the same pattern:

  1. Declare raw extern functions with C types
  2. Write an Ori wrapper that converts types
  3. Check error codes and map to Result
  4. Manage ownership (remember to free, on every exit path)

The crypto proposal has ~300 lines of extern declarations and ~600 lines of wrapper code. The wrappers are 2x the declarations — and they are almost entirely mechanical. Every open() call checks < 0 and reads errno. Every create_*() call pairs with a destroy_*() on every exit path.

// Today: 12 lines for one safe wrapper
extern "c" from "libc" {
    @_open (path: str, flags: c_int, mode: c_int) -> c_int as "open"
}

pub @open_file (path: str, flags: c_int) -> Result<FileDescriptor, FfiError> uses FFI =
    {
        let fd = _open(path: path, flags: flags, mode: 0);
        if fd < 0 then
            Err(FfiError { code: get_errno(), message: strerror(get_errno()), source: "libc" })
        else
            Ok(FileDescriptor { fd: fd })
    }
// Deep FFI: 3 lines — the compiler generates the rest
extern "c" from "libc" #error(errno) {
    @open_file (path: str, flags: c_int, mode: c_int) -> c_int as "open"
}

The Safety Gap

The current spec says “C objects follow C conventions” (section 24, Memory Management). Ownership is untracked. Every CPtr returned from C is a potential leak. The sign_rsa function in the crypto proposal has 8 potential exit paths — miss one cleanup call and you leak. A language that tracks every Ori object via ARC but abandons tracking at the FFI boundary has a safety gap proportional to FFI usage.

The Testability Gap

The FFI capability exists and is bindable (not a marker). with FFI = mock in { ... } is syntactically valid today. But there is no infrastructure for what the mock provides or how extern calls are redirected. You cannot unit-test open_file without a real filesystem. You cannot test crypto wrappers without libsodium installed. Every other capability (Http, FileSystem) can be mocked via with...in handlers — FFI cannot.

What Other Languages Get Wrong

LanguageDeclarationMarshallingOwnershipErrorsTestability
Rustextern "C" {}Manual (CString, as_ptr)Untracked (*mut)Manual errnoLink-time tricks
Zig@cImportAutomatic but rawUntrackedManualNone
GoComment preambleC.CString() everywhereGC + pinningAuto errno (one bright spot)None
SwiftAuto-bridgingAutomaticBridge keywordsManualNone

Go captures errno automatically — that is the single bright spot across all FFI designs. Everything else is manual, error-prone, and untestable.


Design

Deep FFI introduces five abstractions that layer on top of existing extern syntax. Each is independently useful and opt-in.

1. Declarative Type Marshalling

Ori types in extern declarations trigger automatic compiler-generated conversion code.

Parameter Modifiers

Two new modifiers for extern parameters:

extern "c" from "sqlite3" {
    @sqlite3_open (filename: str, db: out CPtr) -> c_int as "sqlite3_open"
}
ModifierMeaningCompiler Action
outC writes to this address; value becomes part of returnAllocate stack slot, pass address, extract value after call
mutC may modify this buffer in placePass pointer to existing buffer, mark as mutated after call

Automatic Marshalling Table

When Ori types appear in extern declarations, the compiler generates marshalling code:

Ori Type in ExternC ABI TranslationGenerated Code
str (input)const char*Allocate null-terminated copy, pass pointer, free after return
str (return)const char*Copy into Ori string, do not free (borrowed by default — see §2)
[byte] (input)const uint8_t*, size_tPass (data_ptr, length) as two adjacent C arguments
mut [byte]uint8_t*, size_t*Pass (buffer_ptr, &capacity) — C writes actual length
intc_intint32_tNarrow with bounds check (panic on overflow)
floatc_floatfloatNarrow (precision loss is silent, matches C semantics)
boolintConvert true→1, false→0; reverse on return
out CPtrvoid**Allocate stack slot, pass &slot, return slot value
out T (any #repr(“c”) type)T*Allocate stack space, pass address, return value

out Parameter Semantics

out parameters are removed from the caller’s signature and folded into the return:

extern "c" from "sqlite3" {
    @sqlite3_open (filename: str, db: out CPtr) -> c_int
}

// Caller sees:
let (status, db) = sqlite3_open(filename: "test.db")

// With error protocol (see §3), caller sees:
let db = sqlite3_open(filename: "test.db")?

If a function has one out parameter and an error protocol is active, the out value becomes the success payload of the Result:

// Declaration:
extern "c" from "sqlite3" #error(nonzero) {
    @sqlite3_open (filename: str, db: out CPtr) -> c_int
}

// Effective Ori signature:
@sqlite3_open (filename: str) -> Result<CPtr, FfiError> uses FFI("sqlite3")

Multiple out parameters become a tuple:

extern "c" from "mylib" #error(nonzero) {
    @get_size (handle: CPtr, width: out c_int, height: out c_int) -> c_int
}

// Effective Ori signature:
@get_size (handle: CPtr) -> Result<(int, int), FfiError> uses FFI("mylib")

[byte] Length Parameter Elision

When [byte] appears in an extern declaration, the compiler generates the length argument automatically. The extern declaration does not include a separate length parameter — the compiler inserts it at the C ABI level as an adjacent (ptr, len) pair:

// Deep FFI:
extern "c" from "z" {
    @compress (dest: mut [byte], source: [byte]) -> c_int
}

// C arguments generated (strict adjacency):
// compress(uint8_t* dest, size_t* destLen, const uint8_t* source, size_t sourceLen)
//          ^^^^^^^^^^^^^^^^^^^^^^^^^^^     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//          mut [byte] → (ptr, &len)        [byte] → (ptr, len)

Rules:

  1. Each [byte] parameter generates two adjacent C arguments: (ptr, len) in that order.
  2. mut [byte] generates (ptr, &len) where the length is an in/out parameter.
  3. The compiler inserts the length immediately after the pointer argument. There is no name matching — C has no named parameters at the ABI level.
  4. If the C function’s argument order does not match adjacent (ptr, len) pairs, fall back to explicit C types:
// Explicit fallback — no auto-marshalling
extern "c" from "unusual_lib" {
    @weird_func (data: CPtr, len: c_size) -> c_int
}

String Return Marshalling

Strings returned from C default to borrowed semantics — Ori copies the string immediately and does not free the C pointer. This is the safe default because the vast majority of C functions returning const char* return pointers to internal buffers (e.g., strerror(), sqlite3_errmsg(), getenv()).

extern "c" from "sqlite3" {
    @sqlite3_errmsg (db: CPtr) -> str            // Borrowed by default: Ori copies, does not free
    @alloc_string (len: c_int) -> owned str       // Ori takes ownership and frees
}

See §2 for the full owned/borrowed annotation system.

2. Ownership Annotations

Two keywords — owned and borrowed — specify memory ownership transfer at the FFI boundary.

Syntax

extern_param  = [ param_modifier ] identifier ":" [ ownership ] type .
extern_item   = "@" identifier extern_params "->" [ ownership ] type
                [ "as" string_literal ]
                [ "#" identifier "(" { attribute_arg } ")" ]
                [ where_clause ] .
ownership     = "owned" | "borrowed" .
param_modifier = "out" | "mut" .

Semantics

AnnotationOn Return TypeOn Parameter
ownedOri takes ownership; cleanup via #free (for CPtr) or frees (for str)Ori transfers ownership to C; drops its reference
borrowedOri copies immediately (str, [byte]) or creates non-owning view (CPtr)C borrows temporarily; must not store past call return
(none)str: borrowed (copy, don’t free). Primitives: by value. CPtr: warning → errorPrimitives: by value. CPtr: borrowed by default

Note: The borrowed annotation in extern declarations describes an ownership transfer protocol at the FFI boundary. It is distinct from the future Borrowed type category reserved by the low-level-future-proofing proposal, which describes a value’s storage semantics. Both involve “not owned,” but operate at different levels: FFI borrowed governs what happens at the C/Ori boundary; type-level Borrowed governs how a value lives in memory.

The #free Annotation

owned CPtr returns require a cleanup function. Specified at block level or per function:

// Block-level default: all owned CPtr returns freed with sqlite3_close
extern "c" from "sqlite3" #free(sqlite3_close) #error(nonzero) {
    @sqlite3_open (filename: str, db: out owned CPtr) -> c_int
    @sqlite3_close (db: owned CPtr) -> c_int  // this IS the free function
}
// Per-function override
extern "c" from "openssl" {
    @RSA_new () -> owned CPtr #free(RSA_free)
    @EVP_CIPHER_CTX_new () -> owned CPtr #free(EVP_CIPHER_CTX_free)
    @RSA_free (rsa: owned CPtr) -> void
    @EVP_CIPHER_CTX_free (ctx: owned CPtr) -> void
}

Integration with ARC and Drop

When a function returns owned CPtr with a #free function, the compiler generates an opaque wrapper type with a Drop impl:

// Conceptual expansion (not user-visible):
type __Owned_sqlite3_db = { ptr: CPtr }

impl __Owned_sqlite3_db: Drop {
    @drop (self) -> void uses FFI("sqlite3") = sqlite3_close(db: self.ptr)
}

The user sees an opaque value that automatically cleans up when it goes out of scope, just like any Ori value. No manual close() on every exit path. No leaks on early returns. ARC handles the rest.

// Before Deep FFI:
pub @query (path: str, sql: str) -> Result<[Row], DbError> uses FFI =
    {
        let db = sqlite3_open(filename: path)?
        let result = sqlite3_exec(db: db, sql: sql)
        sqlite3_close(db: db)   // Must remember this!
        result                   // And what if sqlite3_exec panics? Leak.
    }

// After Deep FFI:
pub @query (path: str, sql: str) -> Result<[Row], DbError> uses FFI("sqlite3") =
    {
        let db = sqlite3_open(filename: path)?   // owned CPtr, auto-freed on scope exit
        sqlite3_exec(db: db, sql: sql)           // if this fails, db is still freed
    }

Phased Enforcement

  • Phase 1: Ownership annotations are optional. No warnings.
  • Phase 2: Unannotated CPtr returns produce a compiler warning.
  • Phase 3: Unannotated CPtr returns are a compile error.

This gives library authors time to annotate while moving toward full safety.

3. Error Protocol Mapping

A block-level #error(...) attribute specifies how C return values map to Result<T, FfiError>.

Syntax

// POSIX: negative return → read errno
extern "c" from "libc" #error(errno) {
    @open (path: str, flags: c_int, mode: c_int) -> c_int as "open"
    @read (fd: c_int, buf: mut [byte]) -> c_int as "read"
    @strerror (errnum: c_int) -> str #error(none)  // opt out (str return is borrowed by default)
}

// SQLite: non-zero return → error code
extern "c" from "sqlite3" #error(nonzero) {
    @sqlite3_open (filename: str, db: out owned CPtr) -> c_int
    @sqlite3_exec (db: CPtr, sql: str) -> c_int
    @sqlite3_errmsg (db: CPtr) -> str #error(none)  // borrowed by default
}

// Specific success value
extern "c" from "libfoo" #error(success: 0) {
    @foo_init () -> c_int
}

// NULL return → error
extern "c" from "mylib" #error(null) {
    @create_thing () -> CPtr
}

Error Protocol Variants

ProtocolAttributeSuccess ConditionFailure Action
POSIX errno#error(errno)Return ≥ 0Read errno, strerror() for message
Non-zero is error#error(nonzero)Return = 0Return value is error code
Negative is error#error(negative)Return ≥ 0Return value is error code
NULL is error#error(null)Return ≠ NULLRead errno if available
Specific success#error(success: N)Return = NReturn value is error code
No protocol#error(none)(no check)(raw return value)

Per-function #error(...) overrides the block default. #error(none) opts out for functions that don’t follow the library’s convention (e.g., strerror returns a string, not an error code).

FfiError Type

// Defined in std.ffi
use std.ffi { FfiError }

type FfiError = {
    code: int,
    message: str,
    source: str,      // library name from `from` clause
}

The message field is populated from:

  • strerror(errno) for #error(errno) protocol
  • Custom lookup if #error_codes({...}) is provided (future extension)
  • "FFI error code: {code}" as fallback

FfiError lives in std.ffi, not the prelude. FFI-specific types should not pollute the prelude namespace.

Return Type Transformation

When #error(...) is active, the Ori-visible return type changes:

C ReturnProtocolOri Return Type
c_int#error(errno)Result<int, FfiError>
c_int with out CPtr#error(nonzero)Result<CPtr, FfiError>
CPtr#error(null)Result<CPtr, FfiError>
void#error(nonzero)Result<void, FfiError>
any#error(none)(unchanged)

When error protocol is active AND there are out parameters, the out values become the success payload and the raw return value is consumed by the error check.

4. Capability-Gated Testability

Deep FFI makes FFI a parametric, trait-based capability. Each extern "c" from "lib" block generates a compiler-internal trait, and extern function calls dispatch through this trait. This enables the existing with...in handler mechanism to intercept FFI calls for testing.

Trait-Based FFI Dispatch

Each extern block generates a compiler-internal trait:

// What the programmer writes:
extern "c" from "sqlite3" #error(nonzero) {
    @sqlite3_open (filename: str, db: out owned CPtr) -> c_int
    @sqlite3_exec (db: CPtr, sql: str) -> c_int
    @sqlite3_errmsg (db: CPtr) -> str #error(none)
}

// What the compiler generates (conceptual, not user-visible):
trait __FFI_sqlite3 {
    @sqlite3_open (filename: str) -> Result<CPtr, FfiError>
    @sqlite3_exec (db: CPtr, sql: str) -> Result<void, FfiError>
    @sqlite3_errmsg (db: CPtr) -> str
}

The generated trait uses the Ori-visible signatures (after marshalling, out folding, and error protocol transformation). Normal calls dispatch through the default implementation (the real C function). When with FFI("sqlite3") = ... is active, calls dispatch through the provided handler.

Parametric FFI Capability

FFI is no longer a single flat capability. It is parametric — FFI("sqlite3"), FFI("libc"), etc. — with each extern block’s from "..." clause defining a distinct capability namespace:

// This function uses both sqlite3 and libc FFI
@query (path: str, sql: str) -> Result<[Row], DbError> uses FFI("sqlite3"), FFI("libc") = ...

// This function uses unparameterized FFI (all extern blocks)
@legacy_wrapper () -> void uses FFI = ...

Unparameterized uses FFI is shorthand for “requires all FFI capabilities” — backward compatible with existing code.

The Problem

// This Ori function requires a real C library to test
pub @hash_password (password: str) -> str uses FFI("libsodium") =
    {
        let salt = crypto_random_bytes(count: 16)
        argon2_hash(password: password, salt: salt)
    }

Testing requires libsodium installed, linked, and available. This makes tests slow, non-deterministic, and non-portable.

The Solution: Handler-Based Mocking

Ori’s capability system already supports handler binding via with Cap = handler(...) in (see stateful-mock-testing proposal). Deep FFI uses the same mechanism. For stateless FFI mocks, handler { ... } (without state) is syntactic sugar for handler(state: ()) { op: (_, args...) -> ((), result) }:

@test tests hash_password {
    with FFI("libsodium") = handler {
        crypto_random_bytes: (count: int) -> [byte] =
            [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
        argon2_hash: (password: str, salt: [byte]) -> str =
            "mocked_hash_{password}",
    } in {
        let result = hash_password(password: "secret")
        assert_eq(result, "mocked_hash_secret")
    }
}

For stateful FFI mocks (e.g., counting calls), use the full handler(state: S) { ... } form from the stateful-mock-testing proposal:

@test tests call_counting {
    with FFI("libc") = handler(state: 0) {
        open: (s, path: str, flags: c_int, mode: c_int) -> (int, Result<int, FfiError>) =
            (s + 1, Ok(42)),
        close: (s, fd: c_int) -> (int, Result<void, FfiError>) =
            (s + 1, Ok(())),
    } in {
        let fd = open(path: "/tmp/test", flags: O_RDONLY, mode: 0)?
        close(fd: fd)?
    }
}

Dispatch Semantics

When with FFI("lib") = handler { ... } in { body }:

  1. Within body, calls to extern functions from the named library are redirected to the handler
  2. Extern functions from OTHER libraries fall through to the real C implementation
  3. Handler functions must match the Ori-visible signature (after marshalling and error protocol)
  4. Type checking validates handler signatures against the generated trait
  5. Unmocked functions within a mocked library call the real C function

Selective Mocking

Only mock specific extern blocks, not all FFI:

with FFI("sqlite3") = handler {
    sqlite3_open: (filename: str) -> Result<CPtr, FfiError> = Ok(CPtr.null()),
} in {
    // sqlite3 calls are mocked
    // libm calls (sin, cos, etc.) still go to real C
    test_database_logic()
}

5. Const-Generic Safety at Boundaries

Depends on: Const Generics (Section 18) — deferred to future phase.

With const generics, Ori can verify buffer sizes at compile time:

extern "c" from "openssl" {
    @SHA256 (
        data: [byte],
        digest: mut [byte, max $N],
    ) -> CPtr
    where N >= 32
}

The where clause enforces at compile time that the caller passes a buffer of sufficient size. The compiler rejects SHA256(data: input, digest: small_buffer) if small_buffer has capacity < 32.

This feature depends on:

  • Const generic type parameters being fully implemented
  • Fixed-capacity lists ([T, max N]) being functional
  • Const expressions in where clauses being evaluated by the type checker

Design direction documented here; implementation deferred to post-const-generics.


Interaction with Existing FFI

Every existing extern declaration continues to work unchanged:

Existing FFI FeatureDeep FFI Impact
extern "c" from "lib" { ... }Unchanged — new features are opt-in annotations
str paramsAlready auto-marshalled per spec §24 (no change)
CPtr params/returnsUnchanged without annotations; warnings in Phase 2
[byte] paramsUnchanged; explicit length still works alongside new auto-elision
Option<CPtr> for nullableUnchanged
C type aliases (c_int, etc.)Unchanged
#repr("c") structsUnchanged
Callbacks (CPtr, CPtr) -> intUnchanged
unsafe { } for variadicsUnchanged
uses FFI capabilityUnchanged — uses FFI (unparameterized) remains valid as shorthand

New features are additive. Nothing in the existing grammar or spec is modified — only extended.


Grammar Changes

Additive changes to grammar.ebnf:

(* Updated extern_param — adds optional modifier and ownership *)
extern_param    = [ param_modifier ] identifier ":" [ ownership ] type .
param_modifier  = "out" | "mut" .
ownership       = "owned" | "borrowed" .

(* Updated extern_item — adds optional ownership on return type *)
extern_item     = "@" identifier extern_params "->" [ ownership ] type
                  [ "as" string_literal ]
                  [ "#" identifier "(" { attribute_arg } ")" ]
                  [ where_clause ] .

(* Parametric FFI capability *)
ffi_capability  = "FFI" [ "(" string_literal ")" ] .

The #error(...) and #free(...) attributes use the existing attribute syntax — no grammar change needed.


Examples

SQLite (Full Deep FFI)

// Raw FFI declarations — library author writes this ONCE
extern "c" from "sqlite3" #error(nonzero) #free(sqlite3_close) {
    @sqlite3_open (filename: str, db: out owned CPtr) -> c_int
    @sqlite3_close (db: owned CPtr) -> c_int
    @sqlite3_exec (db: CPtr, sql: str) -> c_int
    @sqlite3_errmsg (db: CPtr) -> str #error(none)   // str returns are borrowed by default
}

// Safe public API — nearly boilerplate-free
pub type Database = { handle: owned CPtr }

pub @open (path: str) -> Result<Database, FfiError> uses FFI("sqlite3") =
    Ok(Database { handle: sqlite3_open(filename: path)? })

pub @exec (db: Database, sql: str) -> Result<void, FfiError> uses FFI("sqlite3") =
    sqlite3_exec(db: db.handle, sql: sql)

// User code — zero FFI awareness
use std.db { Database }
@main () -> void = {
    let db = Database.open(path: "app.db").unwrap()
    db.exec(sql: "CREATE TABLE users (name TEXT)").unwrap()
    // db auto-closed when scope exits (ARC + Drop + #free)
}

BLAS for ML (Motivation Use Case)

extern "c" from "openblas" #error(none) {
    @cblas_dgemm (
        order: c_int,
        transA: c_int, transB: c_int,
        m: c_int, n: c_int, k: c_int,
        alpha: float,
        a: [float], lda: c_int,
        b: [float], ldb: c_int,
        beta: float,
        c: mut [float], ldc: c_int,
    ) -> void as "cblas_dgemm"
}

// Safe, shape-checked API (with const generics)
pub @matmul<$M: int, $N: int, $P: int> (
    a: Matrix<M, N>,
    b: Matrix<N, P>,
) -> Matrix<M, P> uses FFI("openblas") = {
    let result = Matrix.zeros()
    unsafe {
        cblas_dgemm(
            order: 101,       // CblasRowMajor
            transA: 111, transB: 111,  // CblasNoTrans
            m: M as c_int, n: P as c_int, k: N as c_int,
            alpha: 1.0,
            a: a.data, lda: N as c_int,
            b: b.data, ldb: P as c_int,
            beta: 0.0,
            c: result.data, ldc: P as c_int,
        )
    }
    result
}

// Test — no BLAS library needed!
@test tests matmul {
    with FFI("openblas") = handler {
        cblas_dgemm: (
            order: c_int, transA: c_int, transB: c_int,
            m: c_int, n: c_int, k: c_int,
            alpha: float, a: [float], lda: c_int,
            b: [float], ldb: c_int,
            beta: float, c: mut [float], ldc: c_int,
        ) -> void = {
            // Naive O(n³) implementation for testing
            // ...
        },
    } in {
        let a = Matrix.from_rows([[1.0, 2.0], [3.0, 4.0]])
        let b = Matrix.from_rows([[5.0, 6.0], [7.0, 8.0]])
        let c = matmul(a, b)
        assert_eq(c.get(row: 0, col: 0), 19.0)
    }
}

POSIX File I/O (Error Protocol)

extern "c" from "libc" #error(errno) {
    @open (path: str, flags: c_int, mode: c_int) -> c_int as "open"
    @read (fd: c_int, buf: mut [byte]) -> c_int as "read"
    @close (fd: c_int) -> c_int as "close"
    @strerror (errnum: c_int) -> str #error(none)   // borrowed by default
}

// User code:
let fd = open(path: "/etc/hostname", flags: O_RDONLY, mode: 0)?
let buf = [byte].with_capacity(1024)
let n = read(fd: fd, buf: buf)?
close(fd: fd)?

Compare with today’s equivalent which requires manual errno checking on each call.


Design Decisions

Why are ownership annotations eventually required for CPtr?

Every CPtr has an ownership story. Forcing the programmer to state it prevents silent leaks. This is stricter than Rust (which allows raw pointers without annotation) but matches Ori’s philosophy of explicit effects. Phased enforcement (optional → warning → error) avoids breaking existing code.

Alternative considered: Always optional. Rejected: undiscoverable leaks are worse than annotation burden.

Why block-level error protocols with per-function override?

Most C libraries use a consistent error convention across all functions. Block-level captures this once. Per-function #error(none) handles the exceptions (e.g., strerror returns a string, not an error code). This is more ergonomic than annotating every function.

Alternative considered: Per-function only. Rejected: too verbose for libraries with 50+ functions sharing a convention.

Why does out convert parameters to return values?

Ori is expression-based. Side-effect-only parameters are an anti-pattern. Converting out params to return values is idiomatic Ori — the function returns all its outputs. This also enables ? propagation when combined with error protocols.

Alternative considered: Keep as mut parameters. Rejected: mut parameters for the sole purpose of returning a value through them is a C-ism that Ori should not propagate.

Why does str default to borrowed (copy immediately)?

The vast majority of C functions returning const char* return pointers to internal buffers (strerror(), sqlite3_errmsg(), getenv()). Making “owned” the default would cause beginners to accidentally free memory they don’t own. The safe default is borrowed — Ori copies the string immediately and does not free the C pointer.

Alternative considered: Default to owned. Rejected: dangerous — most C string returns are borrowed. owned str is the opt-in for the less common case where C allocates a string for the caller to free.

Alternative considered: Require annotation always. Rejected: too verbose for the common case.

Why does borrowed str copy immediately?

Ori has no lifetime system. Borrowed string views would require tracking how long the C string remains valid — which requires lifetime annotations Ori deliberately avoids. Copying is safe and consistent with the existing marshalling behavior. When/if borrowed views are implemented, this can evolve to zero-copy.

Alternative considered: Lifetime-bounded views. Rejected: Ori has no lifetimes. The slot is reserved but unimplemented.

Why trait-based FFI dispatch for testability?

Each extern block generates a compiler-internal trait. This enables the existing with...in handler mechanism (from the stateful-mock-testing proposal) to intercept FFI calls — no new dispatch mechanism needed. The generated traits also enable IDE autocompletion for mock implementations and compile-time verification that mocks are complete.

Alternative considered: Name-based redirection (compiler maintains function lookup table). Rejected: less type-safe, doesn’t leverage existing handler infrastructure.

Alternative considered: #[mock] attribute on extern blocks. Rejected: test infrastructure should not require language syntax changes.

Why handler-based mocking rather than a new mock framework?

Ori’s capability system already supports with Cap = handler { ... } in for stateful effect handling (stateful-mock-testing proposal). FFI is a parametric capability. Using the same mechanism maintains conceptual consistency. The stateless handler { ... } form (without state) is sugar for handler(state: ()) { op: (_, args...) -> ((), result) } — no new syntax needed.

Why parametric FFI capabilities?

FFI("sqlite3") and FFI("libc") are distinct capabilities because they represent distinct trust domains. Mocking sqlite3 should not affect libc calls. Parametric capabilities enable selective mocking and fine-grained capability tracking.

Alternative considered: Single flat FFI capability with name-based routing in handlers. Rejected: coarser granularity, less type-safe.

Why not auto-import C headers (like Zig’s @cImport)?

Zig’s approach is magical — it hides the FFI boundary entirely. Ori’s design principle is “explicit boundaries”: FFI calls are clearly marked, not hidden. The boundary should be visible but low-friction. Deep FFI reduces friction (less boilerplate) without hiding the boundary (extern blocks are still explicit).

Alternative considered: ori bindgen header.h tool. Deferred to future work — useful but orthogonal to the language design.

Why is borrowed not conflicting with the Borrowed type category?

The borrowed annotation in extern declarations describes an ownership transfer protocol at the FFI boundary — it says “C owns this pointer; Ori copies immediately.” The Borrowed type variant reserved by the low-level-future-proofing proposal describes a value’s storage semantics — a view with a lifetime constraint. The two concepts are related (both involve “not owned”) but operate at different levels: one is a marshalling directive, the other is a type system property. Different syntactic positions (extern parameter annotation vs type constructor) prevent ambiguity.


Prior Art

Language/ToolWhat They DoWhat Ori Learns
SwiftAuto-bridging of Stringconst char*; __bridge_retained / __bridge_transfer for ARC-FFI ownershipOri’s str marshalling is similar. Swift’s bridge keywords inspired owned/borrowed.
RustManual CString/CStr; Box::from_raw/into_raw; bindgen for header parsingToo verbose. Ori can do better with compiler-assisted marshalling.
Go CGoAuto errno capture; C.CString/C.GoString; GC handles most cleanupGo’s errno capture inspired #error(errno). GC makes ownership easy — Ori needs explicit annotations since it uses ARC.
CXX (Rust)Shared type definitions; UniquePtrBox ownership transfer; compile-time checkedCXX’s UniquePtr model directly inspired owned CPtr + #free.
Python CFFIDeclarative C declarations; ffi.gc() for destructor attachmentCFFI’s ffi.gc() is conceptually what #free(fn) does.
ZigDirect C header import; automatic type translationToo magical — hides the boundary. Ori wants visible but low-friction.

Implementation Phases

Phase 1: Error Protocols + out Parameters

Scope: The features that eliminate the most boilerplate with the least change.

  1. #error(errno | nonzero | null | negative | success: N | none) block/function attributes
  2. FfiError type in std.ffi
  3. Automatic Result<T, FfiError> wrapping when error protocol is active
  4. out parameter modifier (parser → IR → codegen)
  5. out params folded into return type
  6. Errno reading infrastructure (get_errno() as compiler intrinsic)

Exit criteria: Can write the SQLite example above with #error(nonzero) and out CPtr.

Phase 2: Ownership Annotations

Scope: Memory safety across the boundary. Depends on Drop trait.

  1. owned / borrowed annotations (parser → IR → type checker)
  2. #free(fn) attribute (block-level and per-function)
  3. Auto-generated Drop impls for owned CPtr with #free
  4. Compiler warnings for unannotated CPtr returns
  5. str returns default to borrowed (copy, don’t free)
  6. owned str returns: Ori takes ownership and frees

Exit criteria: The OpenSSL RSA example auto-frees on scope exit with zero manual cleanup.

Phase 3: Declarative Marshalling Extensions

Scope: Automatic type conversion beyond what the base spec provides.

  1. [byte] length elision — adjacent (ptr, len) pair insertion
  2. mut [byte] parameter handling — adjacent (ptr, &len) pair insertion
  3. intc_int automatic narrowing/widening with bounds checks
  4. boolc_int conversion

Exit criteria: The zlib compress example works with [byte] params and no explicit length.

Phase 4: Capability-Gated Testability

Scope: Trait-based FFI dispatch and mock infrastructure.

  1. Compiler generates internal traits from extern blocks
  2. Extern function calls dispatch through generated traits
  3. Parametric FFI("lib") capability in type checker
  4. with FFI("lib") = handler { ... } in dispatch routing
  5. Handler signature validation against generated traits
  6. Stateless handler { ... } as sugar for handler(state: ()) { ... }
  7. Fall-through to real implementation for unmocked functions

Exit criteria: Can test the BLAS matmul wrapper without linking OpenBLAS.

Phase 5: Const-Generic Safety (Future)

Scope: Depends on const generics (Section 18) being complete.

  1. Where clauses on extern items with const expressions
  2. Buffer size validation at compile time
  3. Fixed-capacity list ([T, max N]) integration at FFI boundary

Exit criteria: SHA256 example rejects buffers smaller than 32 bytes at compile time.


Future Work

Explicitly deferred:

  1. ori bindgen header.h — auto-generate extern blocks from C headers (tool, not language feature)
  2. C++ interop (extern "c++") — name mangling, vtables, exceptions
  3. WIT integration — generate extern blocks from WebAssembly Interface Types
  4. Callback ownership — ownership annotations on callback parameters
  5. Struct-level marshalling — automatic conversion between Ori record types and C structs (beyond #repr("c"))
  6. Arena-scoped FFI — allocate FFI temporaries in an arena, free all at once
  7. Custom error code maps#error_codes({ 5: "SQLITE_BUSY", 7: "SQLITE_NOMEM" }) for rich error messages
  8. Compile-time layout verification — verify #repr("c") structs match actual C layout

Verification

After implementation, verify with:

  1. Existing FFI tests pass unchanged — backward compatibility
  2. SQLite example compiles and runs#error(nonzero) + out + owned + #free
  3. POSIX file I/O example#error(errno) protocol
  4. Mock test examplewith FFI("lib") = handler { ... } in successfully mocks extern calls
  5. Parametric capabilityuses FFI("sqlite3") tracks per-library, uses FFI is shorthand for all
  6. Spec conformance — update spec/24-ffi.md with new syntax and semantics
  7. Grammar sync — update grammar.ebnf with new productions
  8. Syntax reference sync — update .claude/rules/ori-syntax.md