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

  1. 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.
  2. 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.
  3. 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).

AreaFilesState
Common bridge contractsrc/commonMain/.../NativeBridge.ktexpect fun nativeHermesHello(): String
FlexBuffer call envelope.../payload/FlexPayload.ktprotocol fields done; typed argument<T> decode TODO
Module registry.../switch/Switch.ktmoduleName -> Invokable map
Sync/async invocation.../Invokable.ktsync returns Long; Flow<Long> async unwired
Transport abstraction.../transport/Transport.ktskeleton; ByteBufferTransport all TODO
Android bridge.../FlexInvokable.kt, cpp/droid/AndroidInvokable.*works: bidirectional JNI/fbjni round trip
iOS bridgeNativeBridge.ios.kt, cpp/bindings.def, cpp/darwin/cinterop exposes Reaktor_HermesHello
JS/JVM actualsjsMain / jvmMainstub strings only
Hermes JSIcpp/common/Engine.{h,cpp}, HermesHello.*boots Hermes on Android+iOS, evaluates a bundled StateFlow JS sample, exercises a JSI callback
TS Flow runtimejs/types/Flow.tssingle-observer, queue-buffered Flow/StateFlow
TestsandroidInstrumentedTest/KotlinToCppTest.ktJNI 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:

ConceptWhat it isGrows out of
FrameA versioned FlexBuffer message for framed bindings; direct native bindings may use qualified typed ABI callsFlexPayload envelope
CellA typed Service implementation registered on a switch with an explicit execution policyInvokable + dispatch
SwitchPer-runtime router: local cell registry + attached trunks + a schedulerobject Switch
TrunkA bidirectional frame link between two switchesper-platform bridge code
WireOne in-flight call/session (callId) under a declared interaction protocolsequenceNumber

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):

RuntimeLanguageRoleMechanism
PrimaryKotlin (commonMain)business logic, services, data, authin-process on all Kotlin targets
PerformanceC++hot kernels, media, physics, numericscells behind JNI/cinterop/loopback trunks
DynamicTypeScriptcode push, rapid iteration, Workers, webHermes 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.shape declares 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, TS FfiError, 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 — Channel semantics chosen in the service definition). Kotlin Flow collection maps to credits mechanically; the C++ and TS Flow runtimes honor the same counter, so conflate() 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 problemAnswered by
No version fieldprotocol byte + HELLO ABI version + reaktor_abi_version()
sequenceNumber overloaded as call and stream idcallId + verb-per-frame stream grammar
Sync returns scalar Long, no typed resultRESULT frames with typed payload; handles only as trunk internals
Missing module/function throws across the boundaryERROR frames, route category
argument<T> decode incompletegenerated positional coders + keyed fallback (§6)
No cancellationCANCEL verb, liveness-tracked wires
No capability negotiationHELLO 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 decodeAt dispatch — 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 both collect() and Symbol.asyncIterator (for await (const p of t.transcode(job))). Sealed classes map to discriminated unions — the mapping the early Flow.ts comments 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: .pyi stubs + 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:

  1. Method identity is numeric. methodId is 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.
  2. 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.
  3. The untyped surface survives as the escape hatch. cells.dynamic("name") + DESCRIBE covers REPLs, tooling, and languages without codegen yet — it is today's Invokable, kept honest by structured errors.

7. Sync and asynchronous models across languages

ShapeKotlinTypeScriptC++Python
sync unaryfundirect returnordinary functionordinary function
async unarysuspend funPromise<T>Task<T>async def
server-streamFlow<T>Flow<T> / for awaitFlowPtr<T>async for
client-streamsuspend fun(Flow<T>)fn(AsyncIterable<T>)Task<R>(FlowSource<T>&)async def(aiter)
bidiFlow<T> → Flow<R>bothbothboth

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:

RuleContract
Request bufferscaller-owned; released after the trunk accepts the frame
Response bufferscallee-owned, returned as a handle; reaktor_release(handle) is the single disposal path
Shared readsrefcount tracks outstanding readers across languages; free on last release
Batch arenasburst call sites (UI commit, list render) open an arena; all response frames share it; one release frees everything — no per-call allocation
Streamsframes carry wire id + per-wire sequence; emitters obey credits, buffer bounded, or fail explicitly
Threadingcallbacks declare their dispatcher; trunks marshal
Exceptionsnever 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:

TrunkBoundaryTransportCopies today → target
loopbacksame runtimefunction call0 → 0
JNIKotlin/ART ⇄ C++fbjni; DirectByteBuffer/critical arrays2 → 0-1
JSIC++ ⇄ Hermes TSexternal ArrayBuffer in, copy outn/a → 0-1
cinteropKotlin/Native ⇄ C++pinned pointer handoff3 → 0-1
C ABIC++ ⇄ anythingfunctions below1
gRPCprocess/networkframes over bidi stream, or native shape mappingserialization
kotlinx.rpcprocess/networkkRPC transport SPI over framesserialization

(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):

LanguageMechanismStatusPrimary use
Kotlin / C++ / TSfirst-class switches (JNI/JSI/cinterop/loopback trunks)this designeverything
Pythonctypes or CPython ext + asyncio pumpP4ML pipelines, notebooks, sidecars
Dart (Flutter)dart:ffi plugin installing the trunkP4 consumer proofFlutter hosts
SwiftC interop (nicer surface than today's cinterop hop)after P4SwiftUI hosts
C# / Erlang / RustP/Invoke / NIF / extern "C"when a consumer existsUnity, 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 @FfiService served as ordinary gRPC, flexbuffer payloads in bytes fields or transcoded at the edge).
  • kotlinx.rpc: its transport SPI implemented over Switch/Trunk — @Rpc services 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:

PathProposed budget
loopback unary, small payload≤ 2x generated round trip
JNI / JSI / cinterop hop≤ 1 copy + ≤ 5 µs overhead (physical device)
stream EMIT steady stateamortized ≤ 1 frame allocation + payload encode
C ABI hop1 copy, no allocation beyond the frame
burst call sitesbatch 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

TodayBecomesNotes
FlexPayload + extension propsFrame v1envelope gains version/verbs/ids; ext-prop style stays
object Switch + ModuleMapSwitch instance per runtimeregistry survives; gains trunks, scheduler, HELLO; a Reaktor.switch accessor keeps app code simple
Invokable (sync Long)generated cell skeletonsuntyped surface survives as cells.dynamic()
Transport fun-interfacesthe four wire shapessame quartet, protocol-level; "transmission vs transport" resolves as trunk vs wire
Engine / Hermes bootHermesCellRuntimeloads signed bundles instead of embedded strings
Target.kt sketch@FfiService codegenflexcoder infra already exists
fbjni JAVA_DESCRIPTOR bridgeJNI trunkkeep; the proven piece
js/types/Flow.tsTS Flow runtimemulti-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.

PhaseDeliverableExit signal
P0 — Frame v1 + loopback + JNI trunkversioned frames, typed RESULT/ERROR, generated unary via existing flexcoders; kill scalar Long returnsKotlin⇄C++ typed unary passes on the S23; missing-cell is an ERROR frame, not a crash
P1 — Streams + creditsC++ Flow runtime, Kotlin Flow mapping, EMIT/COMPLETE/CANCEL/CREDITordered, cancellable, credit-respecting stream test passes Android + iOS (the sequence() example from Transport.kt)
P2 — Hermes/JSI trunk + TS codegenTS Flow upgrade, .d.ts stubs, one RN install module, HermesCellRuntimeBestBuds /dev verification runs on typed cells; a code-pushed bundle serves a cell
P3 — C ABI + graph descriptorssix-function ABI, DESCRIBE, FfiRuntime/FfiModule/FfiFunction/FfiBinding nodesdesktop workbench shows per-target availability; ABI version mismatch blocks calls
P4 — Fingerprint gate + Python/Flutter bindingskeyed fallback wired (joint with flexbuffer roadmap), ctypes binding, dart:ffi plugin proofstale-schema bundle degrades to compat; Python async for collects a Kotlin Flow
P5 — Observability + fuzzingOTel spans, credit/stall metrics, frame fuzzers, sanitizer CIFFI regressions produce graph-linked findings
P6 — gRPC trunk + kotlinx.rpc SPIremote switches, shape↔shape mappingone @FfiService served in-process and over gRPC with identical semantics
P7 — ProxyNode + optional native computeport-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.

TestProves
jni_typed_unary_round_tripKotlin ⇄ C++ generated coder path, no scalar returns
missing_cell_is_error_frameroute errors are data
abi_version_mismatch_blocks_callHELLO gate works
fingerprint_mismatch_downgrades_to_keyedstale schema cannot misread bytes
stream_sequence_order + stream_credit_backpressureordered frames; emitter stalls at zero credit
cancel_stream_releases_native_stateCANCEL frees resources both sides
batch_arena_single_releaseburst path allocates once, frees once
hermes_bundle_cell_invokesigned bundle registers and serves a typed cell
python_async_iterator_collects_kotlin_flowC ABI + asyncio pump end-to-end
ffi_span_has_graph_idswires land in telemetry with caller node ids
js_jvm_report_stub_capabilityunsupported 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.
  • Switch scoping: instance-per-runtime with a Reaktor.switch default (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.kts uses dev.shibasis.dependeasy.library, configures androidNative { fbjni(); hermes("0.81.4") }, depends on reaktor-core + reaktor-flexbuffer; Android links fbjni, jsi, libhermes; iOS builds ReaktorFFI static and links hermesvm + 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).
  • ByteBufferTransport is unimplemented; do not treat it as a transport. Target/Struct are shape examples, now formalized as §6.
  • JS/JVM actuals return stub strings; their graph capability state is StubTarget until 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.

  • 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 __ReaktorHost UI 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 @FfiService is 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.