August 19, 2026
1 pull request merged across 1 repo
Offline-Protocol/offline-protocol-sdk
The three properties this had to get right
Zero storage setup. Documents persist through the seam the SDK already
runs on, and every binding already ships a default provider, so the layer
works out of the box like any embedded database.
The backend is swappable in one line, at runtime. DataConfig::storage
in Rust, DataStore.withStorage(protocol, provider) over FFI. No rebuild,
no build flag, no change to any data API. Protocol secrets stay where they
are; only documents move. Sealing sits *above* that seam, so an adapter is
handed sealed bytes and never sees document content — the security property
does not depend on the adapter author's care.
Swappability is verified, not asserted. runStorageConformance(provider)
ships with the layer: one suite defined in Rust, reachable from every
binding, and green is the definition of "this backend is supported". It is
tested against a deliberately broken backend too, because a suite that
passes everything proves nothing. Reference SQLite adapters ship per
binding under examples/storage-adapters/.
Three things a reviewer should look at first
The engine's collection library is unmaintained. im, bitmaps and
sized-chunks were archived by the same upstream and flagged together in
2026 (RUSTSEC-2026-0248 / -0247 / -0251). Unlike the atomic-polyfill
waiver, these are compiled and shipped — im is what loro-internal is
built on — so the waiver says that plainly instead of filing them next to
the unreachable ones. They are maintenance notices, not vulnerabilities,
and no upgrade exists: the successor fork only helps once loro adopts it.
This is a fact about the engine rather than about our lockfile, and belongs
in the engine-bump review and in any future reconsideration of the engine
choice. The F1 spike did not surface it.
Documents only ever import bytes that came out of a sealed record. Loro
has open issues where a malformed import panics and poisons the document's
lock, and minisize ships panic = "abort", so catch_unwind is not a
defence on mobile. The containment is structural: corruption at rest fails
the AEAD tag and lands in the existing Unreadable path before the engine
sees a byte. This is also why F3 cannot simply hand remote deltas to
import — that is a different threat model and needs its own answer.
A custom backend brings a logout obligation. wipePersistedState()
clears the account directory of the *default* provider, which a custom
backend is not inside. DataStore.wipeAll() exists for exactly this, and
the obligation is documented in C11 and UPGRADING §16. Skipping it leaves
documents behind after the account that made them is gone — a privacy
failure with no symptom inside the app.
Two bugs found during implementation, both negative-controlled
- A commit whose write failed was lost at the next restart.
commit()
advances the document's export marker, so a delta that never reached
storage was never offered again: visible in memory, gone after a relaunch,
on an ordinary transient error. rewind_last_commit() puts the marker
back. With the fix removed, the new test fails with "a change whose first
write failed did not survive the restart".
- A reopened document forgot how big it was, so the cheap cap check
never fired no matter how large it actually was, and a document near the
cap would sail past it. restore_bookkeeping() carries the record sizes
and replayed commit count across the open.
Decisions that changed against the plan
1. FFI values cross as JSON, not a kind plus six parallel optional scalars.
2. DocTooLarge does not reject the breaching write — refusing it would
lose work the user believed they had made. The change stays durable,
growth is refused, and deletions keep working so a document can be
brought back under the cap.
3. The space record is a cache; list_keys is the truth. No crash can
leave bookkeeping that disagrees with the store.
4. The backend swap is a second DataStore constructor, not a config
field, because storage does not travel on ProtocolConfig today.
5. Category registration is four edits, not three: nothing forces the
from_key_type arm, so a category can compile and fail at runtime on
its first write. Closed with an exhaustiveness test.
Size
liboffline_protocol_uniffi, minisize, aarch64-apple-ios:
3,109,032 → 4,313,184 B (+1,204,152 B, +38.7%), against the spike's
predicted +1.5 MB / +48%. The difference is shared dependencies already
linked. Native crates.io consumers can drop the engine entirely with
default-features = false; the mobile artifact carries it either way,
because two binding flavors would mean a runtime FFI checksum mismatch
rather than a build error.
Verification
Everything CI runs, plus two gates CI cannot:
| Gate | Result |
|---|---|
cargo fmt --all -- --check | clean |
cargo clippy --workspace --locked -- -D warnings | clean |
cargo test --workspace --locked | 2,303 passed, 0 failed |
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps | clean |
cargo deny check | advisories / bans / licenses / sources ok |
MSRV rustup run 1.87.0 cargo check --workspace --all-targets | clean — the only proof loro 1.13.9 is 1.87-compatible |
| licence + README + NIP-44 + release-packaging scripts | pass |
generate-bindings.sh (all three) + idempotence | clean |
| NOTICES regeneration | idempotent |
React Native npm run build + test:js | pass (28 JS tests, 10 new) |
Android :offlineprotocol:testDebugUnitTest | pass (40 parser tests, 5 new) |
iOS swift test | pass (252 tests) |
iOS bridge typecheck of OfflineProtocolModule.swift | clean, harness negative-controlled first |
Python pytest | pass (212 tests, 6 new) |
The iOS bridge typecheck matters because ci.yml's probe cannot reach that
file (it needs real React headers) and this PR adds 21 methods to it. Run by
hand with the ios/BRIDGE_MAINTENANCE.md symlink-farm recipe, and the
harness was proven non-vacuous first — a deliberate call to a non-existent
DataStore method was reported, then removed.
Three existing guards fired during the run and each was doing its job: the
C2 append-only error-order pin, the telemetry name catalogue, and the
exhaustive privacy classifier. The new DataStore drift guard caught real
naming drift between the UDL's map_get_json and a bridge named
dataMapGet.
What is deliberately not here
Sync (F3), roster-driven space lifecycle (F4), golden vectors and
attachments (F5), any hosted component, a query language, and the ESP32
line. data_versions negotiation is F3's opening move.
Android binary size has no baseline to compare against — the F1 spike could
not measure that target for want of an NDK, so the "before" number was never
taken.