logs.gokuls.in

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

GateResult
cargo fmt --all -- --checkclean
cargo clippy --workspace --locked -- -D warningsclean
cargo test --workspace --locked2,303 passed, 0 failed
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-depsclean
cargo deny checkadvisories / bans / licenses / sources ok
MSRV rustup run 1.87.0 cargo check --workspace --all-targetsclean — the only proof loro 1.13.9 is 1.87-compatible
licence + README + NIP-44 + release-packaging scriptspass
generate-bindings.sh (all three) + idempotenceclean
NOTICES regenerationidempotent
React Native npm run build + test:jspass (28 JS tests, 10 new)
Android :offlineprotocol:testDebugUnitTestpass (40 parser tests, 5 new)
iOS swift testpass (252 tests)
iOS bridge typecheck of OfflineProtocolModule.swiftclean, harness negative-controlled first
Python pytestpass (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.