ADR-0012: Engine boundary — JSON message protocol, C ABI and WebAssembly
- Status: Accepted
- Date: 2026-10-03
- Deciders: Planner
- Related: ARC-03, ARC-07, ARC-08, specs/engine-protocol.md, CORE-007, DESK-002, WEB-002
Context
Two shells in different languages (C++/QML and TypeScript) must drive the same engine with identical behavior. On the web, the engine must run in a Web Worker (to keep the interface responsive), so calls cross a postMessage boundary anyway. On desktop, it runs on its own thread. We want the boundary narrow, versioned, easy to log and replay, and light on dependencies in all three languages.
Decision
- A message protocol is the only interface between shells and engine: commands, queries and input events in; responses and events out; plus host-service requests from engine to shell. Messages are JSON, defined once as Rust types (serde) in
bayan-engine, with a JSON Schema generated for every release and TypeScript types generated for the web shell. QML consumes messages natively as JavaScript objects; C++ uses Qt’s built-in JSON classes, so the desktop needs no extra serialization library. - Binary channels carry bulk data outside JSON: file bytes, font bytes, images, clipboard payloads and tile pixels, referenced from messages by blob identifiers. On the web, blobs are transferable
ArrayBuffers and tiles are transferableImageBitmaps. - Desktop binding: a small C ABI in
bayan-ffi(header generated by cbindgen) with roughly ten functions: create and destroy an engine, post a message, register an event callback, put and get blobs, render a tile into a caller-provided buffer, and version queries. The engine owns its thread; callbacks run on the engine thread and the shell marshals them to the Qt main thread. - Web binding:
bayan-wasm(wasm-bindgen) plus a small worker host script that relays messages between the main thread and the engine. The main thread never calls the engine directly. - The engine never touches the outside world itself: system fonts, clipboard, network transport, timers, opening URLs, AI inference and file access beyond provided blobs are host-service requests the shell fulfils or refuses.
- Versioning: an integer protocol version negotiated in a
hello/welcomehandshake, plus the core’s semantic version. Within a protocol version, changes are additive only. - Record and replay: the engine can record every inbound message and host response (with timestamps) to a local file, and replay it on any platform to reproduce a session exactly. Recordings contain document content, so they are created only on explicit request and never uploaded automatically.
Consequences
- Both shells program against one contract; parity is easier to verify.
- No serialization dependency is added to the desktop or web shells.
- JSON costs microseconds per message at interactive rates; bulk data never goes through JSON.
Alternatives considered
- Protocol Buffers: strong schema tooling and an LGPL Qt module exists, but adds code generation and runtime libraries in three languages for little gain at our message rates.
- FlatBuffers or Cap’n Proto: zero-copy, but awkward ergonomics and extra dependencies.
- Typed foreign-function bindings (Diplomat, cxx, UniFFI) instead of messages: good for direct calls, but the Web Worker boundary still needs serialization, and record/replay is harder.
- CXX-Qt (Rust objects exposed to QML): attractive, but version 0.10 is still in early development with frequent API changes.
Amendment 2026-10-08: Loro’s randomness and clock, a temporary exception
Decided by the owner on 2026-10-08 during CORE-004 (BayanDocs/bayan-core pull request 13), answering question 6 of its report.
Context. Decision 5 says the engine never touches the outside world itself. CORE-004 found two platform calls that Loro 1.16.2, the CRDT library of ADR-0008, makes on its own. Every new Loro document draws a random peer identifier from the platform’s random-number generator (natively the operating system’s; in the browser build crypto.getRandomValues), and every import draws one more, for the throwaway document into which Loro decodes an update to count its changes; the adapter replaces the identifier of every document it keeps with the one the host passes. And Loro’s undo manager reads the wall clock on every recorded local change, to group changes into undo steps, which the adapter switches off because of the report’s finding F12 (undo steps merge when the clock steps backwards, even with a merge interval of 0). Neither call changes a document’s content.
Decision. The two calls are accepted as a temporary, documented exception to decision 5, until Loro lets the host supply randomness and time; asking for that is one of the upstream requests the owner sends (BayanDocs/bayan-core issue 14). The exception covers only these two calls of Loro: any other platform call, by Loro or by another dependency, needs its own amendment. F12 is tracked with it: when Loro takes time from the host, the adapter’s undo grouping is reviewed.
Consequences. The work package that first links bayan-crdt into bayan-engine and bayan-wasm checks that the engine’s WebAssembly imports contain these two calls and nothing more (CORE-004 report §8.4). The dependency session adopts the Loro release that lets the host supply randomness and time, and the exception then ends.
Revisit when
Profiling shows JSON encoding or decoding above 1 ms per frame on a hot path; then switch the encoding to CBOR (supported natively by Qt and by Rust’s serde ecosystem) without changing message shapes.