4.9 KiB
ADR-0005: FFI Bridge via Flutter Rust Bridge v2
- Status: Accepted
- Date: 2026-08-20
- Task: 1.5 — Generate flutter_rust_bridge FFI bindings bridging Rust storage methods to Dart
Context
Tasks 1.1–1.4 established the Rust core engine (chronogrid_core with SQLCipher + Automerge CRDT) and the cryptographic primitives (chronogrid_crypto with Argon2id KDF + OS keychain). The Flutter presentation layer needs a type-safe, zero-copy FFI bridge to invoke these Rust functions from Dart.
The project's architectural mandate (.clinerules) requires: "FFI Bridge: Use flutter_rust_bridge C-ABI generation ONLY."
Decision
1. New Crate: chronogrid_ffi
A dedicated bridge crate (crates/chronogrid_ffi/) was created with:
[lib] crate-type = ["lib", "cdylib", "staticlib"]— produces a shared library for Flutter's FFI loader on all platforms, plus a static library for iOS.- Dependencies:
chronogrid_core,chronogrid_crypto,flutter_rust_bridge = "=2.7.0",anyhow = "1".
2. API Surface Design — Serialized CRDT Bytes + Engine Singleton
Rather than passing opaque Rust pointers across FFI, the API uses a serialized-bytes pattern:
- CRDT documents are serialized to/from
Vec<u8>(Automerge binary format). - Mutation functions accept
doc_bytes: Vec<u8>, apply the edit, and return updatedVec<u8>. - Read functions (
task_crdt_read,note_crdt_read) deserialize bytes into FFI-safe DTO structs (FfiTaskData,FfiNoteData). - Merge functions are stateless — they accept two document byte arrays and return the merged result.
This avoids opaque pointer lifetime management, ensures Dart never holds stale Rust references, and leverages Automerge's natural serialization boundary.
3. Engine Singleton (Mutex<Option<Engine>>)
A global Engine struct holds:
db: Database— the open SQLCipher connection.hlc: Hlc— the node's Hybrid Logical Clock.
The engine is stored in a static Mutex<Option<Engine>> with helper functions with_engine() and with_engine_mut() for safe concurrent access from FRB's thread pool.
init_engine(path, key, node_id)/init_engine_in_memory(key, node_id)— opens the database and seeds the HLC.close_engine()— drops the connection.is_engine_initialized()— status check.
4. Module Structure
| Module | Functions | Category |
|---|---|---|
engine |
4 | Lifecycle (init/close/status) |
database |
3 | CRDT doc persistence (save/load/delete) |
crypto |
5 | Key derivation + keychain (platform-gated) |
task_crdt |
18 | Task CRDT create/read/set_/clear_/merge |
note_crdt |
8 | Note CRDT create/read/set_/clear_/merge |
types |
3 structs | FfiDerivedKey, FfiTaskData, FfiNoteData |
Total: 38 public FFI functions + 3 DTO structs.
5. Error Handling
All fallible functions return anyhow::Result<T>, which FRB v2 automatically maps to Dart exceptions with the error message string. Internal CoreError and CryptoError variants are converted via their Display impls.
6. Platform Gating
Keychain functions (crypto_store_credential, crypto_retrieve_credential, crypto_delete_credential) compile on all platforms but return an error on Android, where credential storage is delegated to the Flutter KeyStore bridge.
7. Code Generation Config
flutter_rust_bridge.yaml at workspace root configures the codegen:
- Rust input:
crates/chronogrid_ffi/src/api/ - Rust output:
crates/chronogrid_ffi/src/frb_generated.rs - Dart output:
apps/chronogrid_flutter/lib/src/rust/
After running flutter_rust_bridge_codegen generate, the frb_generated module must be uncommented in lib.rs.
8. Flutter Side
pubspec.yaml updated with flutter_rust_bridge: ^2.7.0, ffi: ^2.1.0, and meta: ^1.11.0.
Testing
- 25 unit tests covering: engine lifecycle (init/close/double-init/bad-key), database CRDT doc persistence (save/load/delete/missing), task CRDT (create/read/set-all-fields/clear-optionals/merge/stateless-read/no-engine-error), note CRDT (create/read/set-all-fields/clear-optionals/merge/stateless-read/no-engine-error), and crypto (derive/deterministic/invalid-salt/generate-salt).
- All 25 tests pass with
--test-threads=1(required due to global engine singleton). - Verification pipeline (
scripts/verify_agent_work.ps1) passes all checks.
Consequences
- The Dart presentation layer can invoke all core Rust functionality via generated type-safe bindings after a single
flutter_rust_bridge_codegen generaterun. - CRDT documents flow as opaque byte arrays across FFI, maintaining the local-first zero-cloud mandate.
- The engine singleton pattern means only one database can be open at a time per process, which matches the single-user design.
- Future phases can extend the API by adding new
pub fnentries to theapi/modules and re-running codegen.