FFI (C-ABI)

rift-ffi exposes a stable C-ABI (v2) so any language with C interop — the JVM, Node, Go, Python, … — can embed Rift in-process without shelling out to the binary. It builds as a cdylib (and an rlib for in-crate tests): crate-type = ["cdylib", "rlib"].

A cbindgen-generated header ships in the repo at crates/rift-ffi/include/rift_ffi.h (regenerate/verify with scripts/verify-ffi-cdylib.sh). Do not hand-edit it.


Handle lifecycle

The ABI is built around an opaque RiftHandle (a Tokio runtime + an Arc<ImposterManager>, plus an optional in-process admin/metrics plane).

RiftHandle* h = rift_start();     // create
// ... drive it ...
rift_stop(h);                     // stop and free
Function Signature Purpose
rift_start RiftHandle* rift_start(void) Create a handle (owns a runtime + manager).
rift_stop void rift_stop(RiftHandle* h) Stop the handle’s servers and free it.

Imposter operations

Function Signature Returns
rift_create_imposter uint16_t rift_create_imposter(RiftHandle* h, const char* json) The imposter port, or 0 on any error (0 is never a live port).
rift_replace_stubs int rift_replace_stubs(RiftHandle* h, uint16_t port, const char* json) 0 on success, -1 on error.
rift_delete_imposter int rift_delete_imposter(RiftHandle* h, uint16_t port) 0 on success, -1 on error. Returns only after the imposter is fully torn down (listener unbound, connections drained — issue #596), so an immediate re-rift_create_imposter on the same port never races the old generation.
rift_delete_all int rift_delete_all(RiftHandle* h) 0 on success, -1 on error. Each imposter is torn down (as rift_delete_imposter) before this returns.
rift_recorded char* rift_recorded(RiftHandle* h, uint16_t port) Recorded requests as a JSON string (caller frees with rift_free), or NULL on error.
rift_verify char* rift_verify(RiftHandle* h, uint16_t port, const char* body_json) Server-side verification: given {"predicates":[…],"flowId"?,"includeRequests"?,"includeClosest"?} (the POST /verify body), returns {"matched","total","requests"?,"closest"?} as JSON (caller frees), or NULL on error. Unlike the HTTP endpoint, inject predicates are not gated — the in-process embedder is trusted.
rift_stub_warnings char* rift_stub_warnings(RiftHandle* h, uint16_t port) Stub-analysis warnings (duplicate/shadowed/catch-all) as a JSON array (caller frees), or NULL on error.
rift_apply_config char* rift_apply_config(RiftHandle* h, const char* json) Reconcile the full imposter set (like POST /admin/reload); returns the apply report JSON (caller frees).

Admin long tail over FFI

The admin “long tail” — scenario/flow-state, the correlated per-space stub plane, imposter list/get, stub surgery, and scenario management — has direct C-ABI entry points, so an embedder can drive it with zero loopback HTTP (no rift_serve_admin). Each mirrors the corresponding admin-HTTP handler exactly (same ImposterManager/Imposter calls, same JSON):

Function Signature Returns
rift_flow_state_get char* rift_flow_state_get(RiftHandle* h, uint16_t port, const char* flow_id, const char* key) JSON envelope {"found","flowId","key","value"} (caller frees); found:false (with value:null) is an absent key, NULL is returned only on error.
rift_flow_state_put int rift_flow_state_put(RiftHandle* h, uint16_t port, const char* flow_id, const char* key, const char* value_json) 0 on success, -1 on error. value_json is the bare JSON value.
rift_flow_state_delete int rift_flow_state_delete(RiftHandle* h, uint16_t port, const char* flow_id, const char* key) 0 on success, -1 on error.
rift_space_add_stub int rift_space_add_stub(RiftHandle* h, uint16_t port, const char* flow_id, const char* stub_json) 0 on success, -1 on error. The stub’s space is set from flow_id.
rift_space_list_stubs char* rift_space_list_stubs(RiftHandle* h, uint16_t port, const char* flow_id) JSON {"space","stubs":[…]} (caller frees), or NULL on error.
rift_space_delete int rift_space_delete(RiftHandle* h, uint16_t port, const char* flow_id) 0 on success, -1 on error. One-call per-space teardown (scoped stubs + recorded + scenario state).
rift_space_recorded char* rift_space_recorded(RiftHandle* h, uint16_t port, const char* flow_id) The requests recorded for that space (header-filtered received) as a JSON array (caller frees), or NULL on error.

rift_flow_state_get never conflates “absent” with “failed”: an absent key returns the envelope with found:false (a normal outcome, rift_last_error untouched), and NULL is reserved strictly for a genuine error — so a consumer treats found:false as “unset” and NULL as a read failure, with no need to parse rift_last_error.

The rest of the admin long tail — imposter list/get, stub surgery (add/get/update/delete, by index or id), clearing recorded/proxy recordings, enable/disable, and scenario list/set-state/reset — is likewise direct C-ABI, each calling the same ImposterManager/Imposter method the corresponding admin-HTTP handler calls:

Function Signature Returns
rift_list_imposters char* rift_list_imposters(RiftHandle* h, const char* options_json) JSON {"imposters":[...]} (caller frees), or NULL on error. options_json (NULL = defaults): {"replayable":false,"removeProxies":false}. replayable returns full ImposterConfigs (the same removeProxies projection the admin ?replayable=true route serves); otherwise a summary shape {"protocol","port","name"?,"numberOfRequests","enabled"} per imposter (imposters with no assigned port are skipped).
rift_get_imposter char* rift_get_imposter(RiftHandle* h, uint16_t port, const char* options_json) Same options_json shape as rift_list_imposters. Replayable returns the single ImposterConfig; otherwise a detail object {"protocol","port","name"?,"numberOfRequests","enabled","recordRequests","stubs","requests"} (caller frees), or NULL on error.
rift_add_stub int rift_add_stub(RiftHandle* h, uint16_t port, const char* stub_json, int32_t index) 0 on success, -1 on error. index < 0 appends; otherwise inserts at that position. No stub id is auto-generated; no injection gating (the direct C-ABI is the trusted embedder, like rift_replace_stubs).
rift_get_stub char* rift_get_stub(RiftHandle* h, uint16_t port, const char* ref_json) The bare Stub JSON (caller frees), or NULL on error (out-of-range index, unknown id, or malformed ref). ref_json is {"index":N} or {"id":"..."}.
rift_update_stub int rift_update_stub(RiftHandle* h, uint16_t port, const char* ref_json, const char* stub_json) 0 on success, -1 on error. Replaces the stub addressed by ref_json with stub_json.
rift_delete_stub int rift_delete_stub(RiftHandle* h, uint16_t port, const char* ref_json) 0 on success, -1 on error.
rift_clear_recorded int rift_clear_recorded(RiftHandle* h, uint16_t port) 0 on success, -1 on error. Clears all recorded requests for the imposter.
rift_clear_proxy_recordings int rift_clear_proxy_recordings(RiftHandle* h, uint16_t port) 0 on success, -1 on error. Clears saved proxy responses.
rift_set_imposter_enabled int rift_set_imposter_enabled(RiftHandle* h, uint16_t port, int32_t enabled) 0 on success, -1 on error. enabled != 0 enables; 0 disables.
rift_scenarios char* rift_scenarios(RiftHandle* h, uint16_t port, const char* flow_id) JSON {"flowId","scenarios":[{"name","state"}]} (caller frees), or NULL on error. flow_id may be NULL for the imposter’s default flow.
rift_set_scenario_state int rift_set_scenario_state(RiftHandle* h, uint16_t port, const char* name, const char* state_json) 0 on success, -1 on error (including a missing state field). state_json: {"state":"...","flowId":"..."?} (flowId optional → default flow).
rift_reset_scenarios int rift_reset_scenarios(RiftHandle* h, uint16_t port, const char* flow_id) 0 on success, -1 on error. Resets every scenario for flow_id (NULL → default flow) back to its initial state.

Errors set rift_last_error like the data-plane functions. Together with the data plane (rift_create_imposter/rift_replace_stubs/rift_recorded/rift_delete_imposter), these cover the whole SPI over C-ABI — an embedded consumer needs no admin HTTP server and no loopback client.

In-process admin plane (optional)

rift_serve_admin starts the real admin API (and, if metricsPort is set, the metrics server) on the handle’s runtime, serving the handle’s manager — so external tooling can talk to an embedded Rift over HTTP. It is optional: the direct C-ABI above already covers the admin long tail; use rift_serve_admin only when you want an actual HTTP admin surface.

char* result = rift_serve_admin(h, "{\"port\":0}");
// result: {"adminPort":49321,"adminUrl":"http://127.0.0.1:49321","metricsPort":null}
rift_free(result);
  • Options JSON (pass NULL or {} for all defaults; every field optional): {"host":"127.0.0.1","port":0,"apiKey":null,"metricsPort":null,"configFile":null,"noParse":false,"config":null,"allowInjection":false,"requireAdminAuth":false,"upstreamCaFile":null,"upstreamCaPem":null,"upstreamTlsSkipVerify":false}. port: 0 binds an ephemeral port; host must be an IP literal — IPv4, or IPv6 bare (::1) or bracketed ([::1]), a numeric scope id (fe80::1%2) kept — and a DNS name such as localhost is refused (#1137; before that fix a bare IPv6 host could never bind). The metrics server, if asked for, binds the same IP and the same scope id (#1150). configFile is loaded as the reload source (like --configfile); config is an inline {"imposters":[...]}. configFile and config do not compose — pass one. Since 0.17.0 an unknown key is a hard error naming the key (NULL + rift_last_error), not a silent drop — so a typo fails loudly instead of leaving the option quietly inert.
  • apiKey: a blank string is rejected — rift_serve_admin returns NULL and records the reason in rift_last_error. A blank key would enable the admin auth gate and then authenticate every request, and the realistic way to send one is plumbing rather than intent (apiKey(getProperty("rift.apikey", "")), a config value that renders empty). Pass a real token, or omit the field to leave the admin plane unauthenticated.
  • noParse (default false, issue #1107): load configFile verbatim, skipping EJS preprocessing, as --no-parse does for --configfile. A tag the loader does not evaluate — a literal <% in a body or predicate included — otherwise fails the serve (NULL + rift_last_error) naming the tag, and there is no per-tag escape. POST /admin/reload re-reads the file with the same setting. noParse: true without configFile is refused, since it would change nothing.

    Feature-detect it — see Detecting which options an engine accepts below. An engine from 0.17.0 on that does not list "noParse" refuses the key as unknown. An older engine publishes no serveOptions, ignores the key, and strips or blanks the tag with only a log line, so check the list rather than relying on an error.

  • requireAdminAuth (default false, issue #863): refuse to serve when the admin plane would bind a non-loopback address with no apiKey, instead of logging a warning. host defaults to 127.0.0.1 for this door, so the check is silent unless you ask for an off-host bind. It gates on authentication, not on the address — a real apiKey satisfies it on any bind. Under a refusal rift_serve_admin returns NULL with the reason in rift_last_error, and nothing has been bound.

    Since issue #1149 it also covers the intercept listener: after this call, rift_start_intercept and a POST /intercept on the embedded admin plane refuse an off-host bind with no auth instead of only warning. Call rift_serve_admin first — the policy is whatever the most recent serve stated, and a serve that omits the key returns the handle to warn-only. If an exposed listener is already running when you serve with requireAdminAuth, the serve is refused naming that listener rather than reporting a strictness it cannot deliver; the listener is left running for you to stop or re-start with auth.

    Feature-detect it — see Detecting which options an engine accepts below. Against an engine released before 0.17.0 this field is silently ignored and the keyless off-host admin plane is served anyway, with a normal {"adminPort":…} and nothing to catch.

    The default Warn posture writes through the tracing facade. rift-ffi installs no subscriber — correct for a library, but it means an embedding host that has not installed one of its own sees nothing at all. Install a tracing subscriber if you want the warning to be visible.

  • upstreamCaFile / upstreamCaPem (default null): an extra trust anchor for every outbound TLS connection this engine makes — proxy stub upstreams, an https:// configFile, and the intercept listener’s WebSocket relay. A path or the PEM text; supplying both is refused, since an embedder setting both has not decided which is authoritative. Appended to the OS trust store, not replacing it. Read and checked during this call, so a bad anchor fails here rather than surfacing later as a per-request proxy error.
  • upstreamTlsSkipVerify (default false): accept any upstream certificate. Development only; it logs a warning whenever a client is built with it.

    The relay reads the trust when the listener binds, so start the intercept listener after the rift_serve_admin that sets it.

  • allowInjection (default false): whether Rift admits script/inject imposters (inject/decorate/shellTransform/JS-function wait/_rift.script), mirroring the --allowInjection CLI flag. Leave it false unless you intend to permit them.

    What the gate protects. allowInjection gates config documents that cross a trust boundary, not untrusted hosts. A config the embedding host hands over in-process is trusted; one that arrives over HTTP or is read off disk is not:

    Door Gated by allowInjection? Why
    Admin plane (POST /imposters, …) yes (#492) Arrives over HTTP, possibly from a less-trusted caller
    configFile yes (#616) A path dereferenced to disk content the host may not have authored — ops-provisioned, mounted, or edited out-of-band. Same class as the CLI’s --configfile/--datadir
    POST /admin/reload (re-reads configFile) yes (#612) Same file, same flag — a file edited to add scripting after a clean start is refused on reload
    Inline config no (#492) Authored in-process by the host
    rift_create_imposter, rift_replace_stubs, rift_apply_config, … no (#492) Authored in-process by the host

    The in-process doors are ungated by design, not an oversight: a caller who can reach the C-ABI can already execute code in this process, so gating its own JSON would restrict nobody while breaking hosts that legitimately drive script imposters over the C-ABI. If you load config from a file you do not fully control, keep allowInjection: false — a scripted configFile then fails the serve outright (NULL + rift_last_error) rather than loading, so nothing is applied. A behaviors block the engine cannot read as an object (for example an array _behaviors) is refused at every door, gated or not, before the gate runs (#1101).

  • Returns (caller frees): {"adminPort":...,"adminUrl":"...","metricsPort":...}, or NULL on error (bad JSON, bind failure, or already serving — one admin plane per handle). adminUrl is built from the bound address, so an IPv6 bind reads http://[::1]:49321.

    For a scoped bind (fe80::1%2) adminUrl — and interceptUrl on rift_start_intercept — carry the socket-address spelling, http://[fe80::1%2]:49321, which is not a portable URL. No spelling is: Go’s url.Parse rejects the raw %, Java’s URI accepts it and rejects the RFC 6874 %25 form (it reads the zone as literal digits and connects to the wrong scope), and the WHATWG parser Node uses rejects a zone id either way. RFC 6874 has itself since been obsoleted (RFC 9844) for this reason. The engine therefore leaves the field as std’s spelling rather than picking a form that breaks a different runtime. For a scoped bind, build the URL from adminPort / interceptPort plus the host you passed in, which you already have.

    The rift save and rift healthcheck subcommands cannot reach a scoped host at all: they go through reqwest, whose URL parser rejects a zone in either spelling.

Detecting which options an engine accepts

rift_build_info() publishes the accepted option keys as serveOptions (issue #877), so an SDK can check before sending one:

{"version":"…","commit":"…","builtAt":"…","features":["javascript"],
 "serveOptions":["host","port","apiKey","metricsPort","configFile","noParse","config",
                 "allowInjection","requireAdminAuth","upstreamCaFile","upstreamCaPem",
                 "upstreamTlsSkipVerify"]}

0.17.0 published the first eight keys without noParse; noParse and the three upstream* keys (outbound TLS trust, #974) arrived after it. Check for the specific key you are about to send.

Absence of the key is the signal. An engine older than 0.17.0 reports no serveOptions at all — treat that as “no serve option can be relied upon” and fall back to the behaviour you would have had without the option. This is the only check that works against already-released engines, and it is why you must not feature-detect by sending the option and watching for an error:

  • an engine older than 0.17.0 accepts the JSON, ignores the key, and returns success — there is no error to catch, and for requireAdminAuth specifically the silent outcome is fail-open;
  • an engine 0.17.0 or newer does reject an unknown key, but relying on that means probing by deliberately provoking a failure, and it still tells you nothing about the older engines.

serveOptions is deliberately separate from features, which lists compiled cargo features (redis-backend, javascript) — different question, different array. The same list is published over HTTP at GET /config for consumers that do not link the C-ABI.

Intercept proxy over FFI

Start the intercept/TLS-MITM proxy on the handle and drive its whole control plane over C-ABI — no in-process admin HTTP needed. One intercept listener per handle; rift_stop_intercept stops it, and rift_stop shuts it down with the handle.

Function Signature Returns
rift_start_intercept char* rift_start_intercept(RiftHandle* h, const char* options_json) JSON {"interceptPort","interceptUrl"} (caller frees), or NULL on error (bad JSON, bind failure, half-configured CA pair, both CA pairs supplied, CA load failure, already started). options_json: {"host":"127.0.0.1","port":0,"caCertPath":null,"caKeyPath":null,"caCertPem":null,"caKeyPem":null,"returnCaKey":false,"auth":null,"rules":[]} (port 0 = OS-assigned; host an IPv4 or IPv6 literal, bare or bracketed); NULL/{} for defaults. rules (issue #655) installs rules — the /intercept/rules shape — before the listener accepts a connection, so no traffic races a follow-up rift_intercept_add_rules; exceeding the rule-store capacity fails the start. Supply the CA as files (caCertPath/caKeyPath) or inline PEM bytes (caCertPem/caKeyPem, issue #593 — each pair both-or-neither, mutually exclusive). "returnCaKey":true (only with no CA source) mints a fresh CA and adds "caCertPem"/"caKeyPem" to the response once — CA private-key material, treat as secret.
rift_stop_intercept int rift_stop_intercept(RiftHandle* h) 0 on success (including the idempotent nothing-running case), -1 only on a null handle / caught panic. Stops the listener, releases its port, and drops its rules + CA — parity with DELETE /intercept. A later rift_start_intercept without CA paths mints a fresh CA.
rift_intercept_add_rules int rift_intercept_add_rules(RiftHandle* h, const char* rules_json) 0/-1. One rule (object) or many (array), same shape as /intercept/rules.
rift_intercept_list_rules char* rift_intercept_list_rules(RiftHandle* h) The current rules as a JSON array (caller frees), or NULL on error.
rift_intercept_clear_rules int rift_intercept_clear_rules(RiftHandle* h) 0/-1.
rift_intercept_ca_pem char* rift_intercept_ca_pem(RiftHandle* h) The CA cert PEM (caller frees), or NULL on error.
rift_intercept_export_truststore int rift_intercept_export_truststore(RiftHandle* h, const char* format, const char* password, const char* out_path) Writes a truststore to out_path (format = "pkcs12"/"jks", password may be NULL"changeit"). 0/-1. A truststore is binary, so it is written to a file — the form a JVM trustStore consumes directly.

An embedder calls rift_start_intercept, reads interceptPort, adds rules and fetches the CA / truststore (all over FFI), then points a CA-trusting SUT’s HTTPS proxy at interceptPort — with no loopback HTTP. Forward { port } rules reach any imposter on that localhost port, including FFI-created ones. Errors set rift_last_error. A handle that never calls rift_start_intercept is unaffected.

The intercept listener and the handle’s embedded admin plane share one slot (#493): if you also call rift_serve_admin, its /intercept* routes operate on the same listener the C-ABI functions drive. rift_start_intercept then GET /intercept reports it; POST /intercept feeds rift_intercept_add_rules; and a double-start across the two surfaces conflicts consistently (409 / -1). (Previously rift_serve_admin served no /intercept routes at all.)

By default (no CA source) rift_start_intercept generates a fresh ephemeral intercept CA, unchanged from earlier releases. As of v0.11.3 (#429), passing both caCertPath and caKeyPath (PEM file paths) loads that committed CA instead — letting independent embedded instances share one trust anchor rather than each minting its own. Passing only one of the pair is a hard error (both or neither).

Since issue #593 the CA may instead be supplied inline as caCertPem/caKeyPem (the PEM bytes, not paths) — the same both-or-neither rule, and mutually exclusive with the path pair — so a remote or containerized engine can be handed a CA over the C-ABI without staging a file. And "returnCaKey":true (valid only when no CA source is given) has the engine mint a fresh CA and return its cert and key once in the response (caCertPem/caKeyPem), for a bootstrap flow: start with returnCaKey, persist the pair, then supply it back via caCertPem/caKeyPem on later starts. The returned caKeyPem is CA private-key material — treat it as secret. Combining returnCaKey with any supplied CA source is a hard error.

auth (issue #878) requires Proxy-Authorization: Basic <base64(user:pass)> on every CONNECT: {"auth":{"username":"ci","password":"s3cr3t"}}. Omit it and the proxy is open, which is what it has always been. A blank username or password is rejected at start — a blank secret would switch the gate on and then admit everyone. Bear in mind what this listener is: a TLS-MITM proxy serving certificates forged by a CA your clients are asked to trust, so an unauthenticated one reachable off-host is worth more care than an unauthenticated admin plane, not less.

Unlike rift_serve_admin’s options (see #877), this struct has always used deny_unknown_fields — so sending auth to an engine that predates it is a hard error naming the field, not a silent ignore. That makes it deterministically detectable; state the required engine release (0.17.0+) in any SDK that offers it.

interceptUrl/interceptPort in the response are derived from the listener’s actual bound address (v0.11.2, #425/#426) — not hardcoded to 127.0.0.1. A loopback host (the default) reports loopback as before; a 0.0.0.0 (or other non-loopback) bind surfaces that address verbatim, so dial a concrete interface rather than assuming 127.0.0.1.

Build identity

rift_build_info is a static JSON string (never freed) — probe it to detect a v2 library and read which engines are compiled in:

const char* info = rift_build_info();
// {"version":"<release>","commit":"<sha>|null","builtAt":"<iso8601>|null",
//  "features":["redis-backend","javascript"],"serveOptions":[...]}
// Do NOT call rift_free on this pointer.

commit/builtAt are null unless stamped at build time (via build.rs). serveOptions (0.17.0+) is described in Detecting which options an engine accepts.

ABI contract version

rift_abi_version returns the C-ABI contract version as a uint32_t — the runtime-queryable form of the abi field in ffi-manifest.json. Same contract, two encodings: the manifest carries the version-tagged string "v2", the symbol returns the bare integer 2, so map between them rather than string-comparing:

uint32_t v = rift_abi_version(); // 2  (the "v2" contract in ffi-manifest.json)

It is bumped only on a breaking ABI change (a symbol removed, a signature changed, or a documented semantic break) — releases that merely add symbols or fix bugs keep the number, and new symbols are discovered by presence rather than a version floor. This is what an SDK should gate on: unlike rift_build_info’s version (the release/marketing string, which reads as the workspace placeholder 0.1.0 on a locally-built engine), it tracks the C-ABI itself, so a compatibility check never misfires on a dev or vendored build. Adopt it behind feature detection — if the symbol is present, gate on it; if absent (an older cdylib), fall back to probing the symbol set.


Error handling & ownership conventions

  • Error signaling. Every operation returns a sentinel on failure — 0 (rift_create_imposter), -1 (the int-returning ops), or NULL (the string-returning ops).
  • rift_last_error. char* rift_last_error(void) returns the last error message for the current thread (caller frees), or NULL if none. Every operation entry clears the thread-local error first and sets it on failure, so read it immediately after a sentinel return.
  • Panic safety. A Rust panic inside any operation is caught at the boundary and turned into that function’s sentinel plus a rift_last_error message (never unwinds across the C ABI, never crashes or wedges the host runtime) — so an engine bug degrades to a normal error return you handle exactly like any other failure.
  • String ownership. Every char* the ABI returns must be released with void rift_free(char* p)except rift_build_info’s pointer, which is static and must not be freed. rift_free(NULL) is a safe no-op.
  • Handle ownership. Free a handle exactly once with rift_stop.

Cargo features

rift-ffi forwards engine features rather than hard-coding them, so a per-platform build can drop engines it doesn’t need: default = ["redis-backend", "javascript", "quamina-matching"]. (rift_build_info().features reports only redis-backend and javascript.) The mimalloc allocator feature is deliberately never forwarded — a cdylib must not impose a global allocator on its host process.

# Full-featured cdylib (default)
cargo build -p rift-ffi --release

# Minimal cdylib — no scripting engine, no Redis, no Quamina prefilter
cargo build -p rift-ffi --release --no-default-features

Prebuilt cdylibs & the release manifest

Every release ships the librift_ffi cdylib as a standalone, classifier-named asset per platform (librift_ffi-<classifier>.{so,dylib,dll}, e.g. librift_ffi-linux-x86_64.so, librift_ffi-darwin-aarch64.dylib, librift_ffi-windows-x86_64.dll) alongside a matching .sha256. The x86_64 musl target ships a cdylib too (librift_ffi-linux-x86_64-musl.so) — built with crt-static disabled so the .so links dynamically against Alpine’s musl libc (the platform’s static binaries stay statically linked), for embedded SDK tests on Alpine CI images.

Each cdylib is self-contained: it links only stock-host system libraries (the C runtime and, on macOS, system frameworks) — no third-party native dependency to install. A release gate asserts this per platform (scripts/check-ffi-selfcontained.sh, issue #469), so a consumer can dlopen the library on a bare host without extra packages.

To let SDK consumers (rift-java’s natives packaging, rift-go’s fetcher/loader, spawn-transport binary downloads) resolve these assets without hardcoding release-URL patterns, each release also publishes ffi-manifest.json — a platform → asset map generated by scripts/gen-ffi-manifest.sh from the per-platform .sha256 assets:

{
  "version": "v0.12.0",
  "abi": "v2",
  "artifacts": [
    {
      "platform": "linux-x86_64",
      "file": "librift_ffi-linux-x86_64.so",
      "sha256": "…",
      "url": "https://github.com/achird-labs/rift/releases/download/v0.12.0/librift_ffi-linux-x86_64.so"
    }
  ]
}

Fetch it at https://github.com/achird-labs/rift/releases/download/<version>/ffi-manifest.json, pick the artifacts[] entry matching the host’s platform, download url, and verify against sha256. abi is the C-ABI major version (v2) the cdylibs export — the same contract a loaded library reports at runtime via rift_abi_version.