August 20, 2026
6 pull requests merged across 1 repo
Offline-Protocol/offline-protocol-sdk
Cuts v0.23.0. Every file the recipe in CONTRIBUTING.md names, plus the archive step.
Version files
Cargo.toml ([workspace.package] + all eight internal-dependency versions), Cargo.lock, bindings/python/pyproject.toml, bindings/react-native/package.json + package-lock.json, and all three THIRD-PARTY-NOTICES.md copies. The notices list our own crates by version, so the bump makes them stale and the CI drift gate would fail without the regeneration; cargo-about 0.9.1, matching the pin in the script.
Why a minor, and what the header says
Breaking on every binding: #375 deletes the learned-route API (eight methods, four types, ProtocolConfig.path). The release header now names a second thing a reader has to act on, which the [Unreleased] header did not: the data layer arrives on by default. data.enabled is true, the capability is advertised in key packages, and protocol state gains three sealed categories, so this build tells its peers it speaks document sync whether or not the application ever opens a DataStore. Applications running a custom storage backend gain a logout obligation wipePersistedState() cannot discharge, since it only clears the default provider's account directory.
Binary size, measured
The release-gate debt from the data-layer workstream, discharged with a number rather than an estimate. liboffline_protocol_uniffi, minisize, aarch64-apple-ios, built at both ends:
| Bytes | |
|---|---|
| v0.22.0 | 3,127,616 |
| this branch | 4,447,160 |
| delta | +1,319,544 (+42.2%) |
The document engine is all of it. Deleting the routing layer gives back 18,584 bytes, and F3 through F5 (1:1 sync, group spaces, attachments, media carriage) added only 133,976 bytes on top of the F2 measurement, so the cost is the engine's arrival and not its subsequent stages. Under the +1.5 MB the F1 spike predicted. Dependency count went 143 to 201 crates, which is Loro's family.
CHANGELOG archive
0.22.0 moves to docs/changelog/0.22.md. Verified lossless: the section was re-extracted from git show HEAD:CHANGELOG.md by heading markers and diffed against the archived body, with only the ./docs/UPGRADING.md to ../UPGRADING.md rewrite differing. Both archive tables updated. No empty [Unreleased] left behind.
The recurring cut-time failure was checked explicitly and is absent: the [0.22.0] section as-tagged is byte-identical to the one at HEAD, so no entry was written into an already-shipped release this cycle, and there is one heading set under the new release.
UPGRADING
v0.23.0 added to both run-throughs (the breaking one for §15, the compiles-fine one for §16 and the transport-choice change from #376), rows 15 and 16 added to the at-a-glance table, the current-line reference moved to v0.23.x, and §16 labelled with the release that introduced it. All relative links and anchors in the touched docs resolve.
Verification
cargo clippy --workspace --locked -- -D warnings, cargo test --workspace --lib (902 passing, 0 failing across 9 crates), cargo fmt --all -- --check, scripts/check-license-consistency.sh, npx tsc --noEmit in bindings/react-native, and the notices script re-run to confirm it is idempotent. CI was green on 167ed6ef before the cut.
Why
The data layer's reference material is complete: a 549-line wire chapter, two ADRs, a threat-model entry, a full API-reference section, and crate rustdoc. Its guide material did not exist. An application author could read every frame on the wire and still have nowhere to learn whether their state is a message or a document, and the two native platform guides mentioned replicated documents nowhere at all.
This closes the seven gaps found in that audit.
What is here
New: docs/data.md (299 lines). When a document is the right tool versus a message, the model (spaces, documents, collections, values), how each collection type resolves concurrent edits, durability and compaction, what happens at each size limit, attachments, the storage swap and its logout obligation, the six events to handle, and what this version deliberately does not do. Linked from the Features table, and from the API reference.
New: docs/state-machines/data-replication.md (204 lines). The sixth state machine: five invariants, the local document states (including the parked/HistoryIncomplete case that switches compaction off), the anti-entropy exchange with its five triggers and the 30-second offer window, the catch-up ladder, the six ends of an attachment fetch with the bounds on both sides, and the quarantine.
New: two examples that run.
cargo run -p offline-protocol --example replicated_notes # store, edit, persist, reopen
cargo run -p offline-protocol-data --example offline_merge # two replicas, partitioned, converged
replicated_notes writes its in-memory ProtocolStateStorage out in full, so it doubles as the shape of the bring-your-own-backend seam, and it reuses both stores across the reopen (dropping only the secure half looks exactly like data loss). It is declared with required-features = ["data"] so --no-default-features skips it rather than failing to compile a target that cannot exist there.
Fixed: the React Native guide. It still required data: { enabled: true }, the fifth restatement of a default that changed before it ever shipped, and the one cc9af4c9's sweep missed while declaring the API reference "the last one". Its DataStore table had none of the F5 attachment methods, the DataValue union it referred readers to was never defined, and its event-type list named none of the six data_* events.
Fixed: the native guides. iOS and Android each gain a Replicated Documents section with Swift/Kotlin code, the withStorage constructor and the wipeAll() obligation it carries, and the two events an application must handle.
Fixed: the small stale bits. The docs index called the ADR set fifteen decisions (it holds nineteen) and had no route to the data layer. CLAUDE.md's routing table now sends a change in this area to the spec chapter, the state machine and the two ADRs. docs/bridges/typescript.md gains T9: the DataValue union is hand-maintained, and the Rust guard that pins it is named, because the one time it drifted both cargo test and tsc stayed green.
One correction the writing produced
The guide's first draft said a collection's type is fixed by the call that first writes to it. Probing the engine says otherwise: **a collection is identified by name *and* type**, so text_insert("notes", ...) and map_set("notes", ...) create two independent containers. Both hold data, both replicate, both read back through their own accessors, and docJson() shows only one of them. Nothing reports the overlap. Documented as it behaves; worth a look at whether the store should refuse the second type instead, which would be a code change rather than a doc one.
Verification
cargo fmt --all -- --checkclean.- Both examples compile and run;
cargo build --workspace --examplesgreen;cargo build -p offline-protocol --no-default-features --examplescorrectly matches no targets. cargo clippy -p offline-protocol-data --example offline_merge -- -D warningsclean. Theoffline-protocolexample cannot be linted locally because--all-targetspulls the transport crate's test-utils mock, which failsunwrap_usedon main today; CI's clippy jobs do not build examples, and itscargo test --workspacedoes.cargo test -p offline-protocol-data: 66 passed.- Link and anchor check over all 82 markdown files: every link in the changed and new files resolves. The 13 pre-existing failures are all
docs/UPGRADING.mdself-anchors, untouched here. - No em dashes introduced (the ones the scanner reports in the three platform guides are pre-existing lines).
No behaviour changes. The only non-documentation edits are the two example files and the [[example]] stanza that gates one of them.
What this is
A document can now name a blob it does not contain, and a document too large
for a sync frame can finally reach the other replica. Those turn out to be the
same problem, which is why they land together.
Attachments
A new document value holds a reference: a SHA-256, a size, and enough to
display the thing. The bytes never enter the document and never enter protocol
state, because a document is bounded by one sealed record and a layer that
inlined blobs could not carry the blobs people actually send. They ride the
media path instead, which already does windowing, per-transport chunk sizing,
ACK and retry, and whole-file SHA-256.
Two shape decisions are load-bearing:
- The reference is a map, not an encoded string. A build predating this
has no arm for a map value and reads it as *absent*. A string would have
replicated to that build as text and shown a person a hash.
- It is one whole value, replaced rather than edited. Two members
attaching different blobs to one key resolve like any other value, so
"no multi-writer attachment mutation" (a v1 non-goal) is true by
construction rather than by a rule somebody has to remember.
Fetching is pull, and your app is in the loop, because the SDK never kept the
bytes. A peer's request arrives as DataAttachmentRequested; answer it with
provideAttachment or refuse with declineAttachment. **Please answer either
way.** A reference outlives the bytes it names, so a peer holding a reference
and no blob is ordinary, and without a refusal the asking side cannot tell that
from a slow radio.
Arriving bytes are checked against the hash that asked for them, never against
what the sender says about them, and bytes nobody asked for are refused at the
door.
The hole this also closes
The one-line item said "attachments". The F3 and F4 records both park
snapshot-over-media *here*, by decision. Before this, a document whose catch-up
exceeded 32 KiB was warned about and dropped: two replicas both accepting
edits, diverging, with a log line as the only evidence. That is the worst kind
of bug, because it has no symptom.
Such documents now travel the same media path. One that genuinely cannot be
replicated raises DataDocUnsyncable instead of vanishing into a log.
The spec chapter
__DATA_V1__ had outgrown its host. It is now its own chapter,
docs/spec/data-sync.md, with an invariants-first
opening, the attachment material, and the media path; control-messages keeps
the registry row and a pointer.
Frozen conformance vectors sit beside it at
crates/offline-protocol/tests/data/data-sync-v1.vectors.json. They were
computed in Python from the spec prose rather than by printing what the Rust
produces, and every one matched on the first run. That separation is the whole
value: two copies of the same mistake agree with each other perfectly.
Negotiation and compatibility
Additive throughout. A third data_versions entry (DATA_MEDIA_V1), appended
rather than bumped, because a peer without it replicates perfectly well and has
no idea what a data-purposed transfer is — it would hand its user a CRDT
snapshot as a downloaded file. Media envelope v3 carries the marking, which the
module's own rule requires: flag bits are not additively safe, so a decoder
that does not know the field must reject at the version check rather than read
its length as chunk content.
The marking is crate-private and no public send surface can set it. Please keep
it that way: a caller who could would be able to feed bytes of its choosing to
a peer's document engine while that peer's user saw nothing arrive.
Blob carriage is 1:1 only in this release. A transfer needs a confirmed
pairwise session and two group members need not have one; requiring it would
make an attachment depend on a handshake that may never happen. References
themselves replicate in group spaces like any other value, and the gap is
evented rather than silent.
Reviewing this
The interesting part is not the feature, it is the nine defects found *after*
it worked, two by self-review and seven by a fresh-context review. They share
one shape, and it is the thing worth checking me on:
A rule enforced at one point is enforced at one point. The
"a data-layer transfer is invisible to the application" rule alone needed seven
enforcement sites (four on send, three more on receive) and my first fix sat on
one. The leaks included MediaSent firing for an attachment, which in an app
that renders those would put a file into a conversation that nobody attached.
Others worth knowing about:
- The branch broke
cargo clippy --no-default-features, which is a real CI
job. New calls into #[cfg(feature = "data")] modules from ungated files
compile fine by default.
- Crash recovery stripped the marking, so an interrupted attachment surfaced as
MediaResendRequired — and the only surface that answers it forces the
marking off. A well-behaved app doing exactly what we asked would have
delivered a snapshot to the peer's user as a file, through our own recovery
path.
need_blobwas a memory-exhaustion primitive. I had rate-limited it on a
sender-chosen hash in a map with no cap and no sweep, where every other key
is roster-bounded. One frame bought one permanent entry plus one application
callback.
data_decline_attachmenthad no gate at all: no kill switch, no capability,
and therefore no block check.
Several of my own tests were vacuous and only mutation testing caught it,
usually because an outer check fired first and the gate under test was never
reached. Every fix here is mutation-checked; the details are in the design
record.
Verification
- 1536 Rust unit tests, full
cargo test --workspaceincluding doctests cargo clippy --workspace -- -D warnings, and `-p offline-protocol
--no-default-features` (the job this branch broke once)
cargo fmt --all --check,RUSTDOCFLAGS="-D warnings" cargo doctsc --noEmiton the React Native bindings- All four binding sets regenerated together and confirmed drift-free
Not covered. The bridges have no attachment tests, per
C9:
cargo test proves nothing about Swift or Kotlin. Nothing here has run on real
devices. Both are the usual gaps for this layer, not new ones.
Deliberately out of scope
Blob carriage in group spaces (needs a way to move bytes under a group key and
a fourth capability entry; the reference format would not change). SDK
auto-fetch, which spends somebody's bandwidth without consent and is the seed
of interest-based partial replication, a v1 non-goal. Blob garbage collection,
which needs a cross-space reference query the SDK does not have. Deletion
tombstones remain absent, unchanged from F3 and F4.
Workstream F4 (todo item 17): documents now replicate across MLS groups.
Design record: .plans/data-layer-f4-implementation.md. Follows F3 (#382,
residuals in #386), which shipped 1:1 replication.
What this is
A space named after a group id replicates with that group: the roster is the
replica set, and a change is encrypted once for the whole roster rather
than once per member. The frames, the version exchange, the catch-up ladder
and the import quarantine all carried over from F3 untouched. Most of the
diff is teaching the exchange to tell apart three things F3 could treat as
one value: the space, the peer, and the reply address.
The decision worth reviewing
Group replication is a second data_versions entry, not a version bump.
An install shipping only the 1:1 layer advertises data_versions = [1], holds
group sessions perfectly happily, and has no group interception at all: every
group receive path emits whatever decrypts as a chat message. Send one a
replication frame and its user gets a wall of literal __DATA_V1__ JSON in
the conversation.
So DATA_GROUP_V1 = 2 is appended, read independently of entry 1, and the
gate is all-members rather than per-recipient, because one group ciphertext
reaches the whole roster. Members of a group never exchange key packages with
each other, so the inviter attests the capability on the Add commit and in the
Welcome, exactly as it already attests rich_versions (roster-bounded,
admin-bounded, evicted by any direct exchange).
Two rules that keep the traffic bounded
- A change received from the group is never pushed back into it. The
ciphertext already reached every member; re-broadcasting turns one edit into
N^2 frames and gets worse as the group grows.
- Offers and answers are addressed to the member that asked. Reconciling
with a peer is a conversation between two devices even inside a group, so
F3's terminating 1.5-round-trip exchange keeps working. Only a local commit
goes to the roster.
Directed frames are still sealed under the group key: two members need not
share a 1:1 session, and requiring one would make replication depend on a
handshake that may never happen.
What the mutation testing found
Nine mechanisms were negative-controlled (mutate, watch the test fail with its
own message, restore). Seven passed first time. **Two did not, and both were
real gaps:** deleting the interception in the *relay* receive path, or in the
*buffered-drain* path, broke no test at all. Only the live mesh path was
covered.
That is the same class as #366, and it was invisible to a green suite. Both
now have tests driving the real entry points, and both assert two things: the
change applied, and no GroupMessageReceived reached the application. The
second assertion is the one that names the failure, because a missed
interception does not lose data. It shows it to the user as text.
Smaller notes
- Group ids are
group:<uuid>, andvalidate_namerejected:. Space names
got their own validator that admits it; document and collection names keep
the narrow charset, because a peer names those on the wire and this one is
never wire-supplied. Verified first that all three shipped storage providers
digest (key_type, key_id) into a hex filename. This also made an existing
api-reference.md claim ("a space id is a peer address or a group id") true
for the first time.
- Subscriptions needed no new surface.
DataChangedalready fires from
flush_doc after the durable write for every apply, so that half of the item
is tests and docs rather than code. No new API was invented for it.
- No relay broadcast for replication frames: that path arms a delivery-report
correlation and emits app-facing events, and a replication frame has no
app-facing identity to report on.
- No
stage_outbox_resealon the group path (unlike the 1:1 sender): reseal
stages plaintext to re-encrypt *for a recipient*, but this ciphertext belongs
to a group epoch, so it would re-seal a group frame as a 1:1 payload. Group
messaging makes the same trade.
- No cold-start sweep for group spaces, deliberately, with the skip commented
at the loop so it reads as a decision.
Known gap
A group with one member on an old build stops replicating for everyone
until they upgrade. The SDK keeps probing them and UPGRADING.md §16 says to
check for it, but there is no event surfacing it. Recorded as open in the F4
record; an event is the honest fix if it turns up in practice.
Contracts
Additive only. Welcome/commit JSON gains optional fields (no
deny_unknown_fields), the capability list is append-only, the frame body
schema is unchanged (v:1). **No UDL change, so no binding regeneration and
no bridge surface to re-pin.** 1:1 replication is behaviourally untouched and
its suite is green.
Gates
fmt, clippy --workspace -D warnings, `clippy --package offline-protocol
--no-default-features` (the step the F3 record flags as missed-locally,
caught-by-CI), RUSTDOCFLAGS="-D warnings" cargo doc, and the full
cargo test --workspace: 2,382 passed, 0 failed.
Docs updated: both spec files, ADR 0019, threat model R11, the group message
lifecycle state machine, UPGRADING.md §16, api-reference.md, and
CHANGELOG.md (including correcting the F3 entry's now-false "1:1 only"
claim).
Closes the two non-blocking residuals filed at review pass 5 of #382.
#385: a transient pre-flush failure can refold a local edit, and the warn text is wrong for DocTooLarge
4a71422e drains local edits before applying a remote change, so they leave on their own delta instead of being folded into the imported change and suppressed as an echo toward the one peer they were owed to. That pre-flush was reported and stepped over for every error alike, but the two errors say opposite things about what is still pending.
DocTooLarge: the delta record was written, pushed and announced, and only the size verdict that follows failed. Nothing is pending. The old message told an operator the opposite, on the one document they are most likely to be reading about.- Anything else: the commit may be un-exported. The delta-write failure at
data.rs:1094rewinds it back into the pending set, anddata_apply_remote's internal flush (origin = Some(space)) then performs exactly the fold the pre-flush exists to prevent. A storage write that fails once and succeeds a moment later reproduces the original bug for one window, with no compensating announcement.
The two are now told apart, both messages corrected, and a failed pre-flush followed by an import that applied offers versions so the peer asks for what it is missing.
Why the Applied gate is also the termination argument: the fold needs the import's own flush to have succeeded. A storage failure still in force fails that one too, which makes the import answer Err and sends no offer. So every offer costs a fresh transient failure that recovered inside a single frame, and the offers cannot sustain each other. origin is None rather than the space, because what may be stranded is this device's own edit and naming the space would suppress the announcement toward the only peer that could act on it.
Documented as a MAY in the spec's frame table and the module doc's "Every leg ends", alongside the existing pre-flush delta exemption.
Tests
Both negative-controlled, and the first version of the second one was vacuous until the mutation caught it.
a_local_edit_stranded_by_a_failed_pre_flush_is_announced_to_the_peerfails oneDATA_DELTA_LOGwrite and then behaves. One write rather than all of them: a store that never recovers fails the import's flush too, and there is no fold to compensate for.a_document_over_its_cap_still_accepts_a_remote_changecovers the other arm, where refusing the import would close the only door back under the cap. It asserts the push as well as the commit, because the push is the half of the corrected message no local read can see.
Two staging notes worth keeping: the cap is measured over an export that includes un-folded delta history, so a document nudged over the line by its history drops back under as soon as compaction runs and the next flush answers Ok. The content itself has to exceed the cap. And a deletion large enough to matter brings the document back under before the flush under test runs, so the pending edit has to be the small seed.
Removing the pre-flush entirely fails all three pre-flush tests, each with its own message; each new test fails only for its own mutation.
#384: wipeAll() on a running engine is undone by the peer's next sync offer
Option 1 from the issue, which is owed regardless. wipeAll() does its job correctly and every document comes back if the engine is still running with live sessions: no tombstones means nothing distinguishes a wiped space from one this device has never seen, and both directions refill. There is no error and no event.
Every mention framed the call as the logout path, where the engine is being torn down anyway, and none said what happens anywhere else. The caveat now sits next to the deleteDoc one it generalises, and is restated at all 13 sites: API reference, upgrade guide, configuration guide, React Native and Python bridge docs, bridge contracts, storage adapter references, the three reference adapters, the TypeScript surface, the UDL comment, and the method's own docs on both the core and FFI side. Three of those (the reference adapters, the TypeScript JSDoc, the UDL comment) were not in the issue's list and came out of the repo-wide grep.
Option 2 (self-enforcing) considered and not taken. wipeAll() is the logout path, and an error returned during teardown ordering is its own hazard, as the issue says. A runtime warning is worse than it sounds: nothing clears the sync capability at stop(), so it would fire on the documented happy path too and train operators to ignore it. Tombstones (option 3) are out of scope and would need a new v.
Verification
cargo fmt --all --check, cargo clippy --workspace -- -D warnings, RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps, npx tsc --noEmit, and the full cargo test --workspace: 2,363 passed, 0 failed (tallied from the log, not a piped exit code).
The UDL change is comment-only, so it produces no binding drift. Proved rather than assumed: ./scripts/generate-bindings.sh regenerated Swift, Kotlin and Python, and the UDL is the only modified file.
Fixes #384
Fixes #385
Workstream F, stage F3 (todo item 14). Documents now replicate between two peers. The local half landed in #381; this is the other half, and it closes the gap the F1 design record opened.
Design record: .plans/data-layer-f3-implementation.md (local-only, includes the implementation record and the deviations).
What this does
Two devices with a secure session converge on the documents they share. Edits made offline on both sides merge on reconnect; an edit made while connected arrives immediately.
There is no sync protocol and no second delivery path. A sync frame is an ordinary message, so it inherits the retry ladder, the durable outbox, deduplication, park-and-probe, and Tier-2 re-seal without a line of new code. That is not a lucky fit: document changes are idempotent and commutative, which makes at-least-once and unordered delivery exactly sufficient rather than something to work around.
Four commits, each standing on its own:
1. Engine primitives — inspect, import_remote, export_since in offline-protocol-data. No engine type crosses the seam.
2. Capability — data_versions, fourth in the family after wire_versions / env_versions / rich_versions.
3. Replication — frames, send, receive, triggers, quarantine.
4. Release surface — data.enabled defaults to true, all bindings regenerated together, ADR 0019, threat model R11, spec and doc sweep.
The thing worth reviewing carefully
Remote bytes are not our bytes. ADR 0018 said replication "cannot simply hand remote deltas to import: that is a different threat model, and it needs its own answer." ADR 0019 is that answer.
MLS establishes who sent a blob. It says nothing about its shape. And the engine has open defects where a malformed change aborts the process rather than erroring — with panic = "abort" on mobile, there is no unwinding to catch.
I verified this rather than trusting the tracker. With the trim gate removed, a change forked below a compacted replica's trim point panics in loro 1.13.9 and then panics again on the mutex the first panic poisoned. That is loro #1068, and the shape is not exotic: a replica compacts, a partitioned peer sends a change built on what was trimmed. Ordinary reconnect traffic.
Containment is layered: MLS scopes the surface, frames are bounded at 32 KiB, blobs are judged before the engine sees them, and the digest of a blob about to be imported is written to disk *first* — so one that does end the process is refused when the sender retries it. That ordering is the mechanism; written after, it would only record the imports that already worked.
Two things I want a second opinion on:
- The trim predicate has to be narrow as well as safe. Refusing every delta a synced peer sends after a compaction would be sound and useless;
an_ordinary_delta_after_a_compaction_still_appliespins that half. Is the predicate right at the edges? - The residual (R11) is that a space member can abort the process once per unique crafted blob. Bounded and attributable, but real.
Design calls
- A space replicates with the peer whose address names it. Frames never carry a space name; the receiver derives it from the authenticated sender. A peer therefore structurally cannot reach a document shared with someone else — no authorization table, so no authorization table to get wrong. It also meant no sharing API and no scope metadata were needed at all.
- The version exchange is the recovery mechanism, so nothing persists about how far a peer got. A parked change (held behind a missing predecessor) is answered with a version exchange rather than stored, which dissolved the "parked deltas are not durable" gap instead of solving it with a second reconcilable structure.
replystops the exchange. An offer provokes an answer; an answer provokes nothing. Without it two replicas converge perfectly and then talk until the battery dies.edits_made_on_both_sides_while_apart_converge_on_reconnectasserts the exchange goes quiet, not just that it converges.- Triggers hang off the confirmation transition, not the four call sites that reach it — that is the "one call site forgets" shape the wire-codec stamp already fell to. The reconnect hook sits *above* the key-package guard, which returns early for exactly the established peers whose documents drifted apart during the partition.
Testing
Two-peer tests run real MLS ciphertext through a real transport, because a test that shortcuts the ladder tests something other than the claim.
One test was vacuous and I would rather flag it than bury it. a_change_applied_from_a_peer_is_not_echoed_back passed with the echo suppression deleted — it asserted after a full settle, by which point the flush it relied on had nothing left to export. It asserts one hop earlier now, and it then correctly caught the delivery acknowledgement, so it filters ACK_FOR_KEY and names what it is actually looking for.
Six mechanisms negative-controlled (mutate, watch it fail with its own message, restore): trim gate, duplicate gate, capability restore arm, receive interception, push-on-commit hook, echo suppression.
Breaking
data.enabled flips to true. Both halves are in the same unreleased range, so no application has seen the old default — the existing CHANGELOG entry that promised the flip is amended rather than contradicted. Three guards fired on the flip and all three were doing their job; the Kotlin one had a comment written in advance for this exact day.
Gates
fmt, clippy -D warnings, 2,331 workspace tests, rustdoc -D warnings, cargo-deny, license/readme scripts, notices idempotent, generate-bindings.sh + staleness diff, tsc + 28 JS tests, 213 Python tests, 252 iOS Swift tests, MSRV 1.87. Android needs gradle (not installed locally) — CI covers it; the Kotlin parser tests were updated for the flip.
Not in scope
Group spaces (F4), the media path for documents too large to catch up in one frame (F5, by decision), and query/partial replication (v1 non-goals).