Reaktor FFI — Switchboard, the universal language bridge
:::info One document
This is the canonical FFI document. It merges three lineages: the April 2026 spec's
Ch 10.4 "Universal Language Bridge" (the aspiration), the 2026-06-02 code-grounded module
doc (the reality check), and the 2026-07-13 Switchboard design (the refined architecture).
/docs/reaktor-ffi-design now redirects here. The FlexBuffer mechanics it builds on are in
the FlexBuffer performance guide —
reviewed on the host, Chrome and physical devices on 3 October.
Refreshed 4 October: the codec review does not measure production FFI hop latency.
The complete mutable-memory book
provides ownership/runtime design input; LiveFlex is not implemented here.
:::
1. Goals and requirements
- Kotlin modules usable from React Native, Flutter, and any host UI shell through one unified platform layer — write a module once in Kotlin, consume it from TS/Dart/Swift hosts without per-framework glue.
- Seamless multi-way interop between C++, TypeScript (Hermes), and Kotlin on every reaktor platform. Three languages cover the application space — TypeScript for dynamic code and code push, C++ for absolute performance and systems work, Kotlin as the general-purpose spine — and any of the three can define, implement, or consume a service.
- Python and C-object interop through the same substrate: one stable C ABI that CPython (and anything else with an FFI — Dart, Swift, C#, Erlang, Rust) binds in an afternoon.
The FFIs we refuse to inherit limits from: React Native TurboModules/Nitro constrain
argument types to a JSI-friendly subset and tie modules to the RN runtime; Flutter's
MethodChannel serializes through a ~12-type standard codec with no streaming contract;
Pigeon generates per-platform glue but no backpressure, no cancellation, no cross-language
streams. The proposed typed binding aims to cover supported @Serializable Kotlin
models and the four gRPC call shapes. Today this is a design goal: generated codec
shapes are limited, some unsupported forms still reach runtime failures, and the typed
frame/Flow transport is unimplemented. Code generation must reject unsupported
models and target/lifetime combinations rather than promise arbitrary arguments.
The durable abstraction — unchanged since the first code-grounded revision — is neither "Hermes" nor "C++":
caller (any language)
-> FlexBuffer frame
-> trunk (JNI, JSI, cinterop, C ABI, gRPC, loopback)
-> target switch -> cell (any language)
-> result / error / stream frames back
2. Current state: what exists in code today
Everything below this section is design; this section is the reality inventory
(from the live reaktor-ffi source tree).
| Area | Files | State |
|---|---|---|
| Common bridge contract | src/commonMain/.../NativeBridge.kt | expect fun nativeHermesHello(): String |
| FlexBuffer call envelope | .../payload/FlexPayload.kt | protocol fields done; typed argument<T> decode TODO |
| Module registry | .../switch/Switch.kt | moduleName -> Invokable map |
| Sync/async invocation | .../Invokable.kt | sync returns Long; Flow<Long> async unwired |
| Transport abstraction | .../transport/Transport.kt | skeleton; ByteBufferTransport all TODO |
| Android bridge | .../FlexInvokable.kt, cpp/droid/AndroidInvokable.* | works: bidirectional JNI/fbjni round trip |
| iOS bridge | NativeBridge.ios.kt, cpp/bindings.def, cpp/darwin/ | cinterop exposes Reaktor_HermesHello |
| JS/JVM actuals | jsMain / jvmMain | stub strings only |
| Hermes JSI | cpp/common/Engine.{h,cpp}, HermesHello.* | boots Hermes on Android+iOS, evaluates a bundled StateFlow JS sample, exercises a JSI callback |
| TS Flow runtime | js/types/Flow.ts | single-observer, queue-buffered Flow/StateFlow |
| Tests | androidInstrumentedTest/KotlinToCppTest.kt | JNI round trip + Hermes boot |
The proven paths: Kotlin → C++ → FlexBuffer ["FlexInvokable","add",0,1,2] → back into
Kotlin invokeSync → 3 (bidirectional JNI); Hermes evaluating a Metro bundle and driving
a JS Flow from C++ via JSI; cinterop symbol linking on iOS. The BestBuds /dev screen and
Maestro exercise these on both platforms. Everything else — general dispatch, typed
responses, streams, ownership, versioning — is the work this document specifies.
The predecessor experiments (flatInvoker submodule, since removed) contributed the
Invokable/Switch/FlexPayload naming and KSP suspend-function scans that inform §6.
3. Design principles
- One Service contract, qualified bindings. FFI uses the operation identity, schemas, effects and interaction protocols of Reaktor Service. ABI, memory, runtime handles and callbacks stay in the FFI implementation module.
- Execution policy is explicit. Stateful cells may use serialized actor ownership; pure/native synchronous methods can run directly. Affinity and reentrancy are binding requirements, not reasons to put every call through a mailbox.
- Stable identity across locations. An operation can move only where the new binding preserves its contract. A browser sync method cannot silently become remote async RPC; network failures, lifetime and authority boundaries remain visible.
- Sync plus asynchronous protocols. Unary sync, unary async, streams and duplex are FFI call forms. Publication/subscription and durable jobs use the common Service protocols when a supporting runtime is present; transport frames alone do not make an invocation durable.
- The fast path is the typed path. Generated positional coders (the exact-layout tier that wins every measured benchmark) are safe here by construction: both ends of a trunk are built from the same schema, and the trunk handshake proves it with fingerprints before the first positional decode. FFI is the exact-layout tier's canonical consumer.
- The graph stays Kotlin-first. Use FFI for native execution, hosted JavaScript, platform libraries, and hot compute kernels behind the typed byte protocol. A parallel C++ graph runtime is built only if profiling proves a specific graph operation must cross the boundary — until then C++ participates as cells, and the graph sees foreign runtimes rather than being reimplemented in them.
- Expressivity and composability over one-shot convenience. The primitive API is small (frames, cells, trunks); typed stubs, React hooks, and RPC bridging are generated or composed on top, never baked in.
4. Conceptual model
Kotlin runtime C++ runtime Hermes runtime (TS)
┌─────────────────────────┐ ┌──────────────────────────┐ ┌─────────────────────┐
│ Switch "app" │ │ Switch "native" │ │ Switch "js" │
│ ┌─────┐ ┌────────────┐ │ JNI │ ┌──────────┐ ┌───────┐ │ JSI │ ┌───────────────┐ │
│ │Cells│ │ChatService │◄├──────┤► │Transcoder│ │Physics│ ◄├──────┤► │FeedViewModel │ │
│ └─────┘ │ProfileStore│ │trunk │ └──────────┘ └───────┘ │trunk │ │(code-pushed) │ │
│ └────────────┘ │ │ │ │ └───────────────┘ │
└───────────┬─────────────┘ └────────────┬─────────────┘ └─────────────────────┘
│ gRPC trunk │ C-ABI trunk
┌──────┴───────┐ ┌───────┴────────┐
│ reaktorServer│ │ Python / Dart /│
│ (JVM, k3s) │ │ anything-FFI │
└──────────────┘ └────────────────┘
Five nouns, and only five:
| Concept | What it is | Grows out of |
|---|---|---|
| Frame | A versioned FlexBuffer message for framed bindings; direct native bindings may use qualified typed ABI calls | FlexPayload envelope |
| Cell | A typed Service implementation registered on a switch with an explicit execution policy | Invokable + dispatch |
| Switch | Per-runtime router: local cell registry + attached trunks + a scheduler | object Switch |
| Trunk | A bidirectional frame link between two switches | per-platform bridge code |
| Wire | One in-flight call/session (callId) under a declared interaction protocol | sequenceNumber |
A switch runs in every participating runtime — one per Hermes instance, one per Kotlin runtime, one per C++ host, one per Python interpreter. Switches peer through trunks. Routing is transitive: TS calling a Kotlin cell on Android flows JSI-trunk → C++ switch → JNI-trunk → Kotlin switch, without either endpoint knowing the path. Each hop is at worst one frame copy, zero-copy where the trunk supports buffer handoff (§8).
Three first-class runtimes (the spec's framing, kept — it is the reason this layer exists):
| Runtime | Language | Role | Mechanism |
|---|---|---|---|
| Primary | Kotlin (commonMain) | business logic, services, data, auth | in-process on all Kotlin targets |
| Performance | C++ | hot kernels, media, physics, numerics | cells behind JNI/cinterop/loopback trunks |
| Dynamic | TypeScript | code push, rapid iteration, Workers, web | Hermes cells (mobile), in-process (V8/Workers) |
A stateful cell can serialize messages on its owning scheduler. Direct and concurrent bindings declare their own ownership, affinity and reentrancy rules. Mutable state and borrowed memory cannot escape the agreed lifetime. Managed failures can terminate an open wire with a typed error; native memory faults or process crashes are not universally recoverable by restarting a cell. Runtime supervision and effect reconciliation remain explicit.
5. Frame protocol v1
One layout, nine verbs. The original envelope ([module, function, seq, args…]) is kept in
spirit — positional FlexBuffer vector, trivial to produce from any language — and hardened:
Frame = FlexBuffer vector:
[0] u8 protocol — 0x01
[1] u8 verb — HELLO | DESCRIBE | CALL | RESULT | ERROR | EMIT | COMPLETE | CANCEL | CREDIT
[2] u32 callId — wire id, unique per (trunk, direction); 0 for HELLO
[3] u16 targetId — interned cell id (HELLO exchanges intern tables; 0 = by-name in meta)
[4] u16 methodId — stable per-service method id (sorted-signature hash, build time)
[5] blob payload — nested FlexBuffer: positional args / return / error detail
[6] map? meta — optional: shape, deadlineMs, traceId, priority, byName strings
- HELLO (once per trunk, both directions): switch name, ABI version, cell descriptors — service name, method table, schema fingerprint per method — and capabilities (max frame size, zero-copy support, default credit window). Fingerprint mismatch downgrades that method to the keyed-compat codec or refuses it; positional decode never runs unverified.
- DESCRIBE answers with the same descriptor payload on demand — runtime reflection for
dynamic callers (
cells.dynamic("name")), REPLs, Python, and the desktop workbench, without re-handshaking the trunk. - CALL opens a wire;
meta.shapedeclares unary/server-stream/client-stream/bidi so middleboxes (gRPC trunk) can map natively. RESULT answers unary. - EMIT / COMPLETE / ERROR are the stream grammar. ERROR carries
{code: u16, category: app|codec|route|cancelled|deadline, message, detail: blob}— exceptions never unwind across a boundary; they are values that re-throw idiomatically on the far side (Kotlin exception, TSFfiError, C++expected, Python exception). - CREDIT is the piece limited FFIs lack: credit-based backpressure with
Reactive-Streams
request(n)semantics. A collector grants item-credits; emitters stop at zero credit and buffer at their own switch under a per-cell policy (suspend / drop-oldest / error —Channelsemantics chosen in the service definition). KotlinFlowcollection maps to credits mechanically; the C++ and TS Flow runtimes honor the same counter, soconflate()on a hot state stream is a wire-level behavior, not a per-framework hack. - CANCEL propagates both ways; caller death cancels all its wires (trunks track liveness).
Ordering is guaranteed per wire, not across wires; a slow stream never head-of-line-blocks a fast one on the same trunk.
Every defect in the original envelope maps to a specific answer:
| Original protocol problem | Answered by |
|---|---|
| No version field | protocol byte + HELLO ABI version + reaktor_abi_version() |
sequenceNumber overloaded as call and stream id | callId + verb-per-frame stream grammar |
Sync returns scalar Long, no typed result | RESULT frames with typed payload; handles only as trunk internals |
| Missing module/function throws across the boundary | ERROR frames, route category |
argument<T> decode incomplete | generated positional coders + keyed fallback (§6) |
| No cancellation | CANCEL verb, liveness-tracked wires |
| No capability negotiation | HELLO capabilities + DESCRIBE descriptors |
6. Typed services: the only API most code sees
The developer-facing surface is a typed Service contract.
@FfiService is an FFI binding/code-generation declaration over that contract, not a second
service abstraction. The following is proposed generated syntax; current Target.kt is
only an early sketch:
@FfiService("media/Transcoder")
interface Transcoder {
fun estimate(frame: FrameInfo): CostEstimate // synchronous native call
suspend fun probe(source: MediaRef): MediaInfo // unary
fun transcode(job: TranscodeJob): Flow<TranscodeProgress> // server-stream
suspend fun ingest(frames: Flow<VideoFrame>): IngestReceipt // client-stream
fun chat(outbound: Flow<ChatTurn>): Flow<ChatTurn> // bidi
}
@Struct @Serializable data class TranscodeJob(
val source: MediaRef, val preset: String, val trim: Trim? = null,
)
Any @Struct @Serializable class, primitives, collections, ByteArray (blob — zero-copy
slice on capable trunks), and streams in declared protocol positions. Code generation must reject unsupported
shapes or lifetime/target combinations. Proposed KSP outputs:
- Kotlin: client stub (speaks frames to a switch) and server skeleton (positional
decodeAtdispatch — the measured fast tier; method ids, key blocks, and fingerprints computed at build time). - TypeScript:
.d.ts+ runtime —const t = cells.get<Transcoder>("media/Transcoder"); sync unary → a direct return where target-qualified, async unary →Promise, streams →Flow<T>implementing bothcollect()andSymbol.asyncIterator(for await (const p of t.transcode(job))). Sealed classes map to discriminated unions — the mapping the earlyFlow.tscomments already called for. - C++: a header per service —
Task<MediaInfo> probe(const MediaRef&)(C++20 awaitable),FlowPtr<TranscodeProgress> transcode(const TranscodeJob&); structs as plain aggregates with generated positional coders over the same bytes. Implementing the interface in C++ and registering it as a cell is symmetric. - Python:
.pyistubs + ctypes/CPython binding over the C ABI —async def probe(...),async for p in transcoder.transcode(job)via an asyncio adapter.
The spec's Schema-DSL arc extends this per binding: idiomatic wrappers per language
(async/await for C#, GenServer for Erlang, Result<T> for Rust, numpy views for Python)
are generated over the same frames when those bindings become real (§8 matrix).
Three commitments:
- Method identity is numeric.
methodIdis a stable hash of the normalized signature; names ride only in HELLO/DESCRIBE and debug frames. Hot-path dispatch is an array index — the same reasoning that took the generated tier past raw keyed decode by 4-9x. - Schema evolution is fingerprint-gated, not guessed. Same fingerprint → positional
codec. Known-older → generated keyed-compat fallback. Unknown → ERROR
codec. This makes the FFI the forcing function for the flexbuffer roadmap's fingerprint item, and makes code-pushed TS bundles safe: a stale bundle degrades to compat, never to misread bytes. - The untyped surface survives as the escape hatch.
cells.dynamic("name")+ DESCRIBE covers REPLs, tooling, and languages without codegen yet — it is today'sInvokable, kept honest by structured errors.
7. Sync and asynchronous models across languages
| Shape | Kotlin | TypeScript | C++ | Python |
|---|---|---|---|---|
| sync unary | fun | direct return | ordinary function | ordinary function |
| async unary | suspend fun | Promise<T> | Task<T> | async def |
| server-stream | Flow<T> | Flow<T> / for await | FlowPtr<T> | async for |
| client-stream | suspend fun(Flow<T>) | fn(AsyncIterable<T>) | Task<R>(FlowSource<T>&) | async def(aiter) |
| bidi | Flow<T> → Flow<R> | both | both | both |
The C++ Flow runtime grows the observer sketch from Transport.kt into a small
header-only type (no framework dependency): emit/complete/error on the producer side,
collect(observer) → Subscription with request(n)/cancel() on the consumer side, plus
coroutine adapters (co_await flow.first(), generator view). Thread-affine delivery: a
collector receives on the scheduler it subscribed from.
The TS Flow upgrades the existing Flow.ts (single observer, unbounded queue) to
multi-observer with per-observer credit windows, replay(1) StateFlow, SharedFlow
semantics; delivery always on the JS thread (Hermes is single-threaded — trunks marshal).
Schedulers. Bindings declare direct, actor-serialized, concurrent or thread-affine execution. Scheduled calls marshal onto the declared runtime executor; a direct sync call runs inline only if its affinity and reentrancy contract permits it. Async facades over sync native work preserve ownership through completion. Async-to-sync blocking requires an explicit eligible-runtime policy and is not universally available. A transport callback must not accidentally execute arbitrary application code on an unsafe thread.
The React edge. A StateCell<T> is a cell whose contract is one StateFlow<T> plus
typed mutations. Generated bindings give each UI runtime its native idiom over the same
wire: useCell(ProfileStore) in React/TS (subscribe on mount, re-render per EMIT,
conflated), collectAsState() in Compose, an @Observable projection for SwiftUI. One
store, defined once in any language, consumed reactively everywhere. The
React×Compose __ReaktorHost UI ABI becomes one privileged cell on
this substrate — its branded-numeric-id + ArrayBuffer commit batches are exactly a cell
method with a blob payload on the zero-copy JSI trunk, not a parallel mechanism.
8. Memory, frames, and trunks
Proposed frames are refcounted immutable buffers (single-writer, then sealed). The ownership contract below is a design; current scalar handles do not implement it. The book's chapters 24–30 describe a separate mutable execution heap with cells, descriptors, generation-checked handles and snapshots. Mutable cells must not outlive a borrow or silently invalidate an immutable wire view. Snapshot/transfer boundaries need explicit lifetime and release rules across JNI, cinterop and JSI.
The intended frame contract is:
| Rule | Contract |
|---|---|
| Request buffers | caller-owned; released after the trunk accepts the frame |
| Response buffers | callee-owned, returned as a handle; reaktor_release(handle) is the single disposal path |
| Shared reads | refcount tracks outstanding readers across languages; free on last release |
| Batch arenas | burst call sites (UI commit, list render) open an arena; all response frames share it; one release frees everything — no per-call allocation |
| Streams | frames carry wire id + per-wire sequence; emitters obey credits, buffer bounded, or fail explicitly |
| Threading | callbacks declare their dispatcher; trunks marshal |
| Exceptions | never unwind through JNI/cinterop/JSI — ERROR frames only |
Today's Invokable.invokeSync(...): Long was the handle idea in embryo; it becomes this
explicit contract. The current Reaktor_HermesHello() static char[256] return stays a
hello-world-only pattern.
Trunk catalog and copy ledger (current hello-path source accounting → proposed target). Copy counts are not latency measurements, and proposed transports are not implemented production paths:
| Trunk | Boundary | Transport | Copies today → target |
|---|---|---|---|
| loopback | same runtime | function call | 0 → 0 |
| JNI | Kotlin/ART ⇄ C++ | fbjni; DirectByteBuffer/critical arrays | 2 → 0-1 |
| JSI | C++ ⇄ Hermes TS | external ArrayBuffer in, copy out | n/a → 0-1 |
| cinterop | Kotlin/Native ⇄ C++ | pinned pointer handoff | 3 → 0-1 |
| C ABI | C++ ⇄ anything | functions below | 1 |
| gRPC | process/network | frames over bidi stream, or native shape mapping | serialization |
| kotlinx.rpc | process/network | kRPC transport SPI over frames | serialization |
(The "today" counts are the measured post-builder copy ledger from the FlexBuffer FFI section.)
The C ABI is the keystone — C89-shaped, versioned, tiny. Six functions, merging the
module doc's proposal (version/describe/invoke/release) with the trunk model:
typedef struct reaktor_switch reaktor_switch;
typedef void (*reaktor_receiver)(void* ctx, const uint8_t* frame, size_t len);
uint32_t reaktor_abi_version(void);
reaktor_switch* reaktor_attach(const char* name, reaktor_receiver recv, void* ctx);
void reaktor_send(reaktor_switch*, const uint8_t* frame, size_t len);
/* synchronous unary fast path: request frame in, response handle out */
reaktor_frame* reaktor_invoke(reaktor_switch*, const uint8_t* frame, size_t len);
void reaktor_release(reaktor_frame*);
void reaktor_detach(reaktor_switch*);
reaktor_send + receiver callbacks carry everything (including streams); reaktor_invoke
exists so a synchronous unary — a JNI-style call — needs no async hop. DESCRIBE over
reaktor_invoke gives any binding runtime reflection with zero extra ABI surface.
Lifetime contract. Request buffers borrowed by reaktor_invoke expire at return.
Async sends must copy, take ownership, or retain buffers until terminal completion;
cancellation request alone does not end a callback's lifetime. Handles include runtime
identity and generation, have explicit release rules, and cannot become raw pointers in
network payloads. Detach must drain/cancel calls and prevent callbacks into a destroyed
runtime. The exact ownership/error contract must be specified and tested before this ABI
is considered supported; the C signatures alone do not establish it.
Language bindings ride the C ABI, not Kotlin (spec matrix, sober statuses):
| Language | Mechanism | Status | Primary use |
|---|---|---|---|
| Kotlin / C++ / TS | first-class switches (JNI/JSI/cinterop/loopback trunks) | this design | everything |
| Python | ctypes or CPython ext + asyncio pump | P4 | ML pipelines, notebooks, sidecars |
| Dart (Flutter) | dart:ffi plugin installing the trunk | P4 consumer proof | Flutter hosts |
| Swift | C interop (nicer surface than today's cinterop hop) | after P4 | SwiftUI hosts |
| C# / Erlang / Rust | P/Invoke / NIF / extern "C" | when a consumer exists | Unity, telecom, systems |
Host-framework packaging: React Native gets one TurboModule whose only job is
installing the JSI trunk (globalThis.__reaktor) — after that, no native module is ever
written again. Flutter gets one plugin. Native apps get the switch from reaktor-core
bootstrap. Hermes bundles (code push) register TS cells on load, fingerprint-gated per §6.
9. Hermes runtime
Hermes is the dynamic runtime, deliberately hosted without adopting React Native's module
system (js/README direction, kept). Today Engine::start() boots Hermes and evaluates
an embedded Metro bundle exercising the JS StateFlow from C++ via JSI — the proof. The
production shape is the module doc's runtime API, renamed for the cell model:
HermesCellRuntime
loadBundle(bundleId, bytecodeOrSource) // signed artifact, not an embedded string
describe() -> cell descriptors // answers DESCRIBE for its namespace
attach(switch) // registers bundle cells, JSI trunk
close()
Bundles are signed (the SDUI mechanism in React×Compose); a
bundle's cells register under a namespace bound to its signature, and HELLO capabilities
restrict which namespaces a trunk may address — a code-pushed bundle cannot call
payments/* unless granted.
10. Graph representation and observability
The direct module API is graph-neutral. Its operation/schema identities and effects are the same definitions installed into the main Reaktor graph. Graph installation is optional for invocation; it can drive composition, capability checks, placement and data lineage as well as observability. Native code that reads/writes data declares those dependencies or remains explicitly opaque to planning and caching.
Moved. The FFI node and edge kinds (FfiRuntime, FfiModule, FfiFunction,
FfiBinding, HermesBundle, FfiCall, FfiFinding), their edges and capability
facets, and the ProxyNode port-pair sugar now live in
Reaktor Graph Protocol §6, alongside the rest
of what the graph renderer is owed. They were graph-protocol content sitting in the FFI
doc; keeping one copy there keeps the vocabulary consistent with inspection, modification
and versions.
What stays here is the mechanism those nodes describe: frames (§5), cells and switches
(§4), trunks (§8). Switchboard is the transport the graph protocol rides —
graph.inspect and graph.edit are typed operations. @FfiService binds the same
Service contracts, including sync methods where qualified.
This is also where the spec's composition pipeline lands: Schema (@Struct) → FlexBuffer
codecs → typed Service interaction contracts → shared graph ports/effects → qualified
local/FFI/remote bindings. Mesh is one possible remote session transport; no mandatory
serialization or mesh hop is inserted into an eligible direct call.
11. gRPC and kotlinx.rpc are trunks, not competitors
- gRPC trunk: a generic bidi
rpc Exchange(stream Frame)carries frames to a remote switch; where a proto schema is mandated, the generated skeleton maps shape↔shape natively instead (a@FfiServiceserved as ordinary gRPC, flexbuffer payloads inbytesfields or transcoded at the edge). - kotlinx.rpc: its transport SPI implemented over Switch/Trunk —
@Rpcservices ride the same rails in-process without pretending to be network calls, and keep their own wire format when networked. - Deadlines, cancellation, and credits map 1:1 onto gRPC deadlines, cancellation, and HTTP/2 flow control — a wire that spans processes degrades gracefully instead of changing semantics.
12. Security
Hard requirements (the code-grounded list, restated against the design):
- ABI version query before any non-test call (
reaktor_abi_version, HELLO). - Schema fingerprint on every generated method; positional decode only after a match.
- Structured ERROR frames for missing cell/method, decode failure, deadline, native-crash proxy, unsupported target — never a thrown crash across the boundary.
- Frames from untrusted ingress (network trunks, downloaded bundles) need bounded structural validation and independent layout/fingerprint checks at the trunk. The October correctness defects, including valid-empty-map rejection and required-field/layout misreads, must be fixed first. Validation can be reused only while the bytes remain immutable; it does not prove schema compatibility, handle lifetime or present-null/default semantics.
- Signed Hermes bundles; namespace grants in HELLO (capability gate for code push and for user-authored visual graphs).
- OpenTelemetry span per wire (cell, method, shape, payload sizes, credits, duration, error code) attached to the caller's graph node ids; payload previews redacted.
- Native fuzzing of frame parsing; ASan/UBSan on native CI.
- The C ABI is a process-trust boundary by definition; document it as such.
13. Performance position
The 3 October codec review measures generated
encode, decode, partial access, validation, allocation and bytes on JVM, actual Chrome,
physical Android and physical iOS. Example generated encode/decode costs are
0.487 / 0.276 µs for JVM UserProfile, 170.600 / 67.923 µs for Android
ApiResponse, and 26.339 / 14.932 µs for iOS ApiResponse. The protocols differ:
JVM is JMH, Android is a warmed instrumentation APK, and iOS is an optimized release
executable. Browser object encode can lose to JSON; full-model and native-JSON
representation results are separate.
There is no end-to-end production frame/trunk benchmark in that evidence. A frame adds envelope work, crossing, copying/pinning, dispatch, scheduling and possibly queues. MethodChannel or handwritten JSI comparisons require matched implementations and payload semantics; no competitive-latency claim is established today.
These are proposed budgets to test, not achieved or regression-tested guarantees:
| Path | Proposed budget |
|---|---|
| loopback unary, small payload | ≤ 2x generated round trip |
| JNI / JSI / cinterop hop | ≤ 1 copy + ≤ 5 µs overhead (physical device) |
| stream EMIT steady state | amortized ≤ 1 frame allocation + payload encode |
| C ABI hop | 1 copy, no allocation beyond the frame |
| burst call sites | batch arena: 1 release per batch |
Measure end-to-end cold/warm latency, p95/p99, throughput, allocations, retained bytes and cancellation/lifetime behavior per trunk. Include structural/schema checks at ingress and actual output consumption. The Android bulk A/B and UTF-8/JS metadata work are codec opportunities; C++/SIMD should be selected for a profiled batch or kernel after including crossing and ownership costs. Native kernels, compiler specialization and mutable memory remain options, not measured FFI wins.
14. Migration from current code
| Today | Becomes | Notes |
|---|---|---|
FlexPayload + extension props | Frame v1 | envelope gains version/verbs/ids; ext-prop style stays |
object Switch + ModuleMap | Switch instance per runtime | registry survives; gains trunks, scheduler, HELLO; a Reaktor.switch accessor keeps app code simple |
Invokable (sync Long) | generated cell skeletons | untyped surface survives as cells.dynamic() |
Transport fun-interfaces | the four wire shapes | same quartet, protocol-level; "transmission vs transport" resolves as trunk vs wire |
Engine / Hermes boot | HermesCellRuntime | loads signed bundles instead of embedded strings |
Target.kt sketch | @FfiService codegen | flexcoder infra already exists |
fbjni JAVA_DESCRIPTOR bridge | JNI trunk | keep; the proven piece |
js/types/Flow.ts | TS Flow runtime | multi-observer + credits + async iterator |
15. Roadmap
Merged phases (each ships something usable alone; exit signals from the code-grounded doc kept). Before P0 may serve production consumers, fix the codec review defects and establish an enforced exact-layout contract. Independent deployment requires the fingerprint/keyed gate; P4's binding rollout does not permit postponing that gate for earlier production trunks.
| Phase | Deliverable | Exit signal |
|---|---|---|
| P0 — Frame v1 + loopback + JNI trunk | versioned frames, typed RESULT/ERROR, generated unary via existing flexcoders; kill scalar Long returns | Kotlin⇄C++ typed unary passes on the S23; missing-cell is an ERROR frame, not a crash |
| P1 — Streams + credits | C++ Flow runtime, Kotlin Flow mapping, EMIT/COMPLETE/CANCEL/CREDIT | ordered, cancellable, credit-respecting stream test passes Android + iOS (the sequence() example from Transport.kt) |
| P2 — Hermes/JSI trunk + TS codegen | TS Flow upgrade, .d.ts stubs, one RN install module, HermesCellRuntime | BestBuds /dev verification runs on typed cells; a code-pushed bundle serves a cell |
| P3 — C ABI + graph descriptors | six-function ABI, DESCRIBE, FfiRuntime/FfiModule/FfiFunction/FfiBinding nodes | desktop workbench shows per-target availability; ABI version mismatch blocks calls |
| P4 — Fingerprint gate + Python/Flutter bindings | keyed fallback wired (joint with flexbuffer roadmap), ctypes binding, dart:ffi plugin proof | stale-schema bundle degrades to compat; Python async for collects a Kotlin Flow |
| P5 — Observability + fuzzing | OTel spans, credit/stall metrics, frame fuzzers, sanitizer CI | FFI regressions produce graph-linked findings |
| P6 — gRPC trunk + kotlinx.rpc SPI | remote switches, shape↔shape mapping | one @FfiService served in-process and over gRPC with identical semantics |
| P7 — ProxyNode + optional native compute | port-level graph sugar; first measured hot kernel as a C++ cell (SIMD only if profiling justifies) | Blueprint shows a cross-language port edge backed by a wire |
16. Acceptance tests
These are required future acceptance gates, not a list of tests already passing. Include the four codec defect regressions, unsupported-shape compile failures, concurrent cache initialization, retained-capacity limits, borrowed-view expiration, generation-checked handles and immutable snapshot ownership before trunk rollout.
| Test | Proves |
|---|---|
jni_typed_unary_round_trip | Kotlin ⇄ C++ generated coder path, no scalar returns |
missing_cell_is_error_frame | route errors are data |
abi_version_mismatch_blocks_call | HELLO gate works |
fingerprint_mismatch_downgrades_to_keyed | stale schema cannot misread bytes |
stream_sequence_order + stream_credit_backpressure | ordered frames; emitter stalls at zero credit |
cancel_stream_releases_native_state | CANCEL frees resources both sides |
batch_arena_single_release | burst path allocates once, frees once |
hermes_bundle_cell_invoke | signed bundle registers and serves a typed cell |
python_async_iterator_collects_kotlin_flow | C ABI + asyncio pump end-to-end |
ffi_span_has_graph_ids | wires land in telemetry with caller node ids |
js_jvm_report_stub_capability | unsupported targets stay graph-visible, never pretend |
17. Open decisions
- Credit default: initial window 16 items; are byte-credits needed for large-blob streams, or do item-credits + max-frame-size suffice (v1 position)?
- Intern-table lifecycle: per-trunk (v1) vs global epochs.
- C++ struct codegen source: KSP from Kotlin schema (chosen) vs neutral IDL — revisit only if a non-Kotlin team must author schemas.
- Python packaging: ctypes-only vs compiled extension for pump-loop latency — measure.
- Hermes long-term: runtime backend (current bet) vs proof-only; revisit at P2 exit.
Switchscoping: instance-per-runtime with aReaktor.switchdefault (chosen) — does the desktop workbench need to operate foreign cells or only inspect them?
18. Non-goals
Carried forward deliberately: a full C++ reaktor-graph runtime (C++ participates as
cells); Highway/SIMD claims without a measured hot path (the spec's mesh-compute vision —
federated learning, SIMD kernels on phone NEON — stays recorded as aspiration gated on P7
evidence); a WASM trunk before a web consumer exists; React Native runtime compatibility
(the adapter installs into Hermes without adopting RN’s module system); wire-format stability
promises before P4 ships the fingerprint gate; six speculative language bindings (the C ABI
makes them cheap when a consumer appears).
19. Implementation notes from live code
build.gradle.ktsusesdev.shibasis.dependeasy.library, configuresandroidNative { fbjni(); hermes("0.81.4") }, depends onreaktor-core+reaktor-flexbuffer; Android linksfbjni,jsi,libhermes; iOS buildsReaktorFFIstatic and linkshermesvm+jsi.JAVA_DESCRIPTOR("dev.shibasis.reaktor.ffi.Tester")converts a Kotlin class name into JNI descriptor form at compile time (consteval) — keep for the JNI trunk.Engine::start()embeds a Metro bundle string in C++ today; production loads versioned signed artifacts (§9).ByteBufferTransportis unimplemented; do not treat it as a transport.Target/Structare shape examples, now formalized as §6.- JS/JVM actuals return stub strings; their graph capability state is
StubTargetuntil P6 gives JVM a loopback/gRPC story and web gets an in-process TS switch.
20. Doc lineage
April 2026 spec Ch 10.4 ("Universal Language Bridge") set the vision: C-ABI bridge, three
first-class runtimes, arenas, broad bindings. The 2026-06-02 revision grounded it against
the live module and cut aspiration from fact. The 2026-07-13 Switchboard design added the
frame protocol, cells/trunks, cross-language Flow with credits, and the typed codegen
surface, informed by the physical-device FlexBuffer verification. This document merges all
three; /docs/reaktor-ffi-design redirects here.
Related docs
- October codec review — current measured evidence and correctness blockers; no production FFI latency claim.
- From Bytes to Mutable Memory — full wire-format book and proposed runtime ownership/handle/snapshot design.
- FlexBuffer — wire format, generated codec tiers, benchmarks, ownership, and the FFI copy ledger this design builds on.
- React x Compose — the
__ReaktorHostUI ABI that becomes a privileged cell; signed-bundle SDUI. - Interop Roadmap — UI batch-commit sequencing over the JSI trunk.
- Service Layer — Request/Response contracts a
@FfiServiceis shaped to serve. - Performance and Observability — where FFI spans and native timing land.
- Unified System Graph — where FFI runtime/cell/trunk nodes merge into the whole-product graph.
- Capability — target availability facets and fallbacks.