Everything an application team has to change to move off v0.16.x and onto the
current v0.25.x line.
The breaking changes all landed in the storage-split release, v0.17.0 —
initialize_mls changes shape, three config updaters become fallible, and
several previously-accepted inputs are now rejected at the boundary. Sections
1–12 below cover that release and are the ones that can stop your build.
Since v0.17.0 four releases can break a build. v0.20.0 enables iOS
autolinking, so a manual pod 'MeshSdk' line left in your Podfile now fails
pod install — React Native on iOS only; it is a one-line deletion, and
§12.1 has the
full list of Podfile leftovers to remove, and the same release is what makes iOS
simulator builds link correctly. v0.21.0 is breaking on every surface:
ProtocolConfig.userId is replaced by profile, and the device's identity on
the wire becomes a self-certifying off1… address derived from an identity key
it mints for itself. §14 is the
migration guide — read it before bumping, because there is deliberately no
in-place migration of existing sessions. v0.23.0 breaks every binding once
more, in the one place nothing was reading: the learned-route API is deleted,
eight methods and four types that wrote into a table the delivery path never
consulted, along with ProtocolConfig.path.
§15 lists them; delete the calls,
because there is nothing to replace them with. v0.24.0 breaks nothing and
still needs reading before you deploy it: a key package claiming more than 90
days is now refused, a control frame is judged against the device's clock, and
rotating a session for post-compromise security became something your
application schedules rather than something that happens on its own.
§17 covers
all three. v0.25.0 is the narrowest of the four: it breaks the
offline-protocol-transport crate only, where ReticulumConfig and
everything built around it is deleted and ReticulumTransport::new is the one
constructor left
(§19). No
binding surface goes with it, though a TypeScript application that exhaustively
switches on PresenceSource or SecurityWarningCode gets a compile error
from that release, because both unions widen, by one member and two
respectively.
Otherwise, where a later section documents an
addition or a behaviour change, it is labelled inline with the release that
introduced it (for example, wipePersistedState in
§10 arrived in v0.18.2). If
you are already on v0.17.0, those labelled paragraphs are the only parts you
still need — coming from v0.18.x that means
§1.10 and
§1.11, two
receive-side behaviour changes in v0.19.0 that compile fine and can still
surprise you at runtime.
v0.20.0 adds one more of that kind, and it needs no section because it needs
no code change: React Native's exported ProtocolState enum now holds the
strings getState() has always resolved ("Stopped" / "Running" /
"Paused") rather than 0 / 1 / 2, so state === ProtocolState.Running —
which could never be true before — now works, and state === 'Running' stops
failing tsc. Nothing read back from getState() changes. The single hazard is
a ProtocolState your app persisted itself (AsyncStorage, redux-persist):
that value returns as a number and now matches nothing, so treat an
unrecognised persisted value as Stopped.
v0.20.1 adds one more, and this one does need a change if your UI settles a
message on it: messageDecryptionFailed is now advisory and fires once per
failed attempt rather than once per message, because a receiver that cannot
decrypt or parse a frame now withholds the delivery ACK so the sender's resend
can deliver.
§11.1 has the
full contract and the events to settle on instead.
v0.21.0's changes are otherwise compile-breaking rather than quiet, but it
adds one more of these: secure_session_failed can now fire while the session
with that peer stays live
(§11.2),
so an app that tears down session state on that event alone must stop.
v0.22.0 adds two more. The first is the larger: the battery-aware relay policy
was complete but unreachable, because nothing ever wrote the charge that all of
it reads. relay_promoted and relay_demoted could not fire, and the
forwarding battery floor was decorative. The feed is now wired through, so the
policy starts deciding behaviour the moment your app calls setBatteryState
(§11.3),
and on React Native two relay config fields that were parsed by nothing begin
taking effect. The second needs no code change and is pure relief: React
Native's updateDorsConfig rebuilt the entire DorsConfig from literals on
every call, so a payload naming one field silently reset everything the caller
did not mention. Both bridges now merge onto the live config read back from the
engine, so if you were restating every field to survive that, you can stop.
v0.23.0 adds two that compile fine, and the first is a whole layer.
Replicated documents arrive on by default
(§16): this
build advertises document sync to its peers even if your app never opens a
DataStore. Nothing is stored until you write a document and nothing is sent
until you share one, and data.enabled: false refuses the layer outright, but
one obligation follows a custom storage backend, which is that
DataStore.wipeAll() joins wipePersistedState() on your logout path. The
second is quieter: transport choice now asks where the recipient is, so a
device holding a live mesh link to the recipient sends over that link even when
an internet carrier is up and scoring would have put the relay first. What is
delivered does not change; which radio carries it does, and so does how fast it
settles when the recipient is standing next to you.
v0.24.1 adds one more, and it is React Native on iOS only. Eight bridge
methods were unreachable there, so configuration your application already
passes was being discarded and the engine kept its own permissive defaults.
The relay block has been dropped since v0.22.0, which means an iOS device
you configured not to relay has been carrying other people's traffic anyway,
and stops on this release, and wipePersistedState since v0.21.0, which
means logging out could not erase the account it had just signed out of. Seven
more methods resolved but read their arguments as the wrong bits, so message
priority and presence status collapsed to their defaults on every iOS send.
§18 covers what
changes and what to check. Nothing changes shape, and Android was never
affected in any of it.
v0.25.0 adds one, and it is the largest of this kind so far, though it
reaches only deployments that set transports.reticulum and
reticulum_enabled: true, since Reticulum is off by default. A device on
Reticulum has never been able to receive anything. Both mobile managers
attached to a daemon without completing the bind that
contract v1 requires, so the daemon never
registered the device as a recipient: nothing addressed to it arrived, while
the core settled every outbound frame as sent the instant the socket write
returned. Delivery over Reticulum starts working on this release, and sends
settle on the gateway's verdict instead of on the write, which means a send
that used to report success can now report failure. That is the correction,
not a regression. The other direction matters if you run your own daemon: one
speaking the pre-contract shape now yields a transport that connects and never
becomes available, because the attach times out after ten seconds.
§19 has both
halves, the frames a daemon owes, and the two new SecurityWarningCode values
that name a refused or mismatched attach.
Work through it in order. §0 is a release-engineering decision, not a code change, and it is the one that cannot be undone later.
| # | Change | Rust | Swift/Kotlin (UniFFI) | React Native | Python |
|---|---|---|---|---|---|
| 0 | Downgrade is not a rollback | ||||
| 1 | initialize_mls takes two providers |
breaking | breaking | no code change | breaking |
| 2 | Protocol-state values are bytes |
n/a | breaking (custom providers) | no code change | breaking (custom providers) |
| 3 | Config updaters now throw | breaking | breaking (Swift needs try) |
no code change | breaking |
| 4 | Zero dedup values rejected | breaking | breaking | check your config | breaking |
| 5 | Recipient validation everywhere | breaking | breaking | breaking | breaking |
| 6 | 256 KiB content cap | breaking | breaking | breaking | breaking |
| 7 | Pending-queue lifetime + caps | new config | new config | new config | new config |
| 8 | RN bridge fallbacks realigned | n/a | n/a | behaviour change | n/a |
| 9 | Synthesized relay frames | n/a | n/a | only if you wrote native relay code | n/a |
| 10 | Per-account storage namespaces | your providers | your providers | behaviour change | behaviour change |
| 11 | Events you must handle | all | all | all | all |
| 12 | Build & packaging | — | regenerate bindings | rebuild native | — |
| 12.1 | iOS: delete your manual pod 'MeshSdk' line |
n/a | n/a | breaking (pod install fails) |
n/a |
| 13 | Upgrade test checklist | — | — | — | — |
| 14 | Your identity is derived, not chosen | breaking | breaking | breaking | breaking |
| 15 | The learned-route API is gone | breaking | breaking | breaking | breaking |
| 16 | Replicated documents | new API, on by default | new API, on by default | new API, on by default | new API, on by default |
The first launch on this release moves pre-split delivery state out of the credential store into the app container and deletes the credential-store copy once the move is durable. An older build reads the old location and finds none of it.
A downgraded install therefore comes up with:
- an empty outbox (queued messages gone),
- an empty pending queue,
- an empty block list — every previously blocked peer silently unblocked.
Blocking is a safety control. Treat a downgrade as a decision to reset it, not as an undo.
What to do:
- Ship this as an explicitly breaking release in your own versioning.
- Do not stage it behind a rollback plan that reverts the binary. Roll forward — keep a hotfix lane on top of this release instead.
- If you run phased rollout, make sure your halt procedure is "stop promoting", not "roll back the previous version to devices that already updated".
Secure key material and restartable protocol state are now two different storage contracts with two different lifecycles.
MlsStorageProvider |
ProtocolStateStorageProvider |
|
|---|---|---|
| Holds | MLS identity, sessions, groups, peer capability records, install secrets, the record-sealing key | Outbox, pending messages, session/Welcome lifecycles, peer snapshots, media descriptors, block list, Lamport clock |
| Backing store | Platform credential store (Keychain, Keystore-backed encrypted prefs, Secret Service) | App container — must be removed when the app is deleted |
| Value type | sequence<u8> (unchanged) |
bytes (see §2) |
The one-provider API is gone, and so is the public enable_message_persistence
path — persistence is now wired by initialize_mls alone.
Why split at all? Credential stores can outlive an app container. Before the split, uninstalling the app could leave message plaintext and cloud-media
encryption_key/ivvalues in the Keychain with nothing that ever read or deleted them.
// Before
protocol.initialize_mls(storage)?;
// After
use offline_protocol::{MlsStorage, ProtocolStateStorage};
protocol.initialize_mls(
secure_storage, // Arc<dyn MlsStorage>
protocol_state_storage, // Arc<dyn ProtocolStateStorage>
)?;New public exports from offline_protocol:
pub use protocol_state_storage::{
ProtocolStateError, ProtocolStateResult, ProtocolStateStorage,
MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES, // 8 MiB
};ProtocolStateStorage deliberately does not reuse the MLS crate's error
type — it returns ProtocolStateResult<T> / ProtocolStateError. The UniFFI
callback surface is unchanged (ProtocolStateStorageProvider still throws
MlsStorageError; an adapter maps it), so this only affects Rust
implementations.
// Before
try mesh.initializeMls(storage: keychainStorage)
// After
try mesh.initializeMls(
secureStorage: keychainStorage,
protocolStateStorage: appContainerStorage
)// Before
protocol.initializeMls(encryptedStorage)
// After
protocol.initializeMls(encryptedStorage, appContainerStorage)You must regenerate bindings — see §12.
No application code change. The bridge constructs both providers itself and
initializeMlsWithSecureStorage() keeps its signature (as does the automatic
initialization inside start()).
Locations the bridge uses:
| Platform | Secure store | Protocol state |
|---|---|---|
| iOS | Keychain, per-account service suffix | Application Support/…/protocol-state-v1, isExcludedFromBackup = true |
| Android | EncryptedSharedPreferences, per-account prefs file |
noBackupFilesDir/offline-protocol/protocol-state-v1 |
What does change for RN apps is covered in §8, §9, §10, and §11.
ProtocolManager now requires an explicit state root. Python has no portable
uninstall-scoped container — Application Support, LOCALAPPDATA, and XDG data
directories commonly survive package removal — so the SDK refuses to guess one.
# Before
pm = ProtocolManager(config, event_handler=print)
# After — pass state_root=...
pm = ProtocolManager(
config,
event_handler=print,
state_root="/app/install-owned-data/offline-protocol",
)
# ...or set OFFLINE_PROTOCOL_STATE_ROOT in the environment.Neither present raises:
protocol state has no safe process-wide default; pass root=... or set
OFFLINE_PROTOCOL_STATE_ROOT to an application-owned directory that is removed
when the application is uninstalled
Your installer must remove that directory on uninstall. That is the whole contract the split exists to enforce; the SDK cannot enforce it for you.
Passing a custom state_storage= bypasses state_root entirely, and the custom
provider then owns both account isolation and uninstall cleanup.
Directories are created 0700 and an existing store is tightened on open.
Also note: the keyring credential store now holds
protocol_state_record_key, the per-install key that seals delivery state. On a
host where keyring resolves to a null or plaintext backend, that key sits in a
readable file — you get separation of lifecycle but not of confidentiality.
Install a real secret service (gnome-keyring, kwallet) anywhere that matters.
If you supply your own ProtocolStateStorageProvider, these are obligations,
not suggestions. Each one exists because something breaks on a device without
it.
Store bytes verbatim. Sensitive categories arrive already sealed. Do not inspect, re-encode, compress, or truncate.
Never use a store that can outlive the app container. Not Keychain, not
EncryptedSharedPreferences backed by a surviving Keystore namespace.
Writes must be atomic and durable before store returns. The SDK treats a
successful store as persisted and immediately writes state that depends on it
— most sharply the record-sealing key, after which sealed records start landing
in the container. A rename that commits ahead of its data blocks, or an
apply() that only staged in memory, can crash into a container full of records
whose key was never written. The built-in providers use AtomicFile on Android,
commit() for encrypted prefs, and fsync of the file and its parent directory
on iOS (F_FULLFSYNC) and Python — including on delete, since an unflushed
unlink can resurrect an entry the SDK already settled.
load must bound its read. Stat the entry first; never materialize or hand
back more than 8 MiB (MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES). The SDK
refuses to write anything near that, so a larger entry is corrupt or tampered.
This cannot live in the SDK: by the time it can check a length, you have already
allocated the bytes. Keep list_keys bounded for the same reason.
Report destruction as Corrupted, not as absence. Three-state reads are
load-bearing:
| You return | SDK reads it as | Consequence |
|---|---|---|
Ok(None) / null |
nothing was ever here | nothing restored, nothing settled |
Corrupted / CorruptedData |
a record existed and is permanently gone | record dropped and settled — message_failed for an outbox entry, pending_state_lost for a pending queue |
LoadFailed (or any other error) |
this read failed, retry later | record left on disk, nothing settled |
Returning null for a record you destroyed is accepted, but it costs the
settlement: the app is told nothing and the message id it holds never resolves.
Reporting a transient failure as Corrupted is worse — the app is told a
message failed terminally and then the next launch delivers it.
NotFound from load or from list_keys is read as absence / empty
category, so a backend that can only spell emptiness that way is safe.
(UniFFI's MlsStorageError.KeyNotFound maps onto it.)
Do not encode the key into a filename. Key ids are peer and message ids, so
an encoding is case-unsafe (AAG and AAa are the same file on APFS's macOS
default and on Windows — one record silently overwrites the other) and unbounded
(a valid long id overruns the 255-byte NAME_MAX). Use a fixed-length lowercase
digest and put the exact key inside the record. The built-in providers share one
format across iOS, Android, and Python:
bytes 0..4 magic "OPS1"
bytes 4..6 key_type length, big-endian u16
bytes 6..8 key_id length, big-endian u16
then key_type UTF-8, key_id UTF-8, value bytes
Serialize on a process-wide lock, not a per-instance one. Two providers over
one root are not hypothetical — the RN bridge constructs a fresh one on every
initializeMls call. Per-instance locking lets one provider's stale-temporary
sweep unlink a temporary another's atomic write is about to rename into place.
Sweep your own write temporaries. A crash between "write temp" and "rename" orphans a file that enumeration filters out, so nothing ever looks at it again and it accumulates for the life of the install.
If you ship your own MlsStorageProvider, upgrading installs will not inherit
their pre-split delivery state.
The one-shot adoption sweep enumerates records through the
MlsStorageProvider it is handed. The built-in providers find pre-split records
because they read through to the pre-namespace store they replaced. A custom
provider has no such fallback, so the sweep finds nothing.
The state is not deleted — it is simply never picked up, and the install comes up with an empty outbox, an empty pending queue, and an empty block list.
Fix before shipping: either have your provider read through to wherever your
previous version wrote, or migrate that data yourself before calling
initialize_mls. The key types to move are in the table below.
Protocol-state key types (the closed set the sweep moves out of secure storage):
| Key type | Keyed by | Sealed? |
|---|---|---|
pending_message_entries |
message id | ✅ |
pending_messages (legacy, read-only) |
recipient | ✅ |
outbox |
message id | ✅ |
media_descriptors |
file id | ✅ |
session_states |
peer id | ❌ |
welcome_lifecycles |
peer id | ❌ |
peer_key_packages |
peer id | ❌ |
peer_capabilities |
peer id | ❌ |
blocked_users |
peer id | ❌ |
both_create_awaiting_decrypt |
peer id | ❌ |
lamport_clock |
current |
❌ |
protocol_state_adoption |
v1 (sweep marker) |
❌ |
Stays in secure storage: all MLS/OpenMLS material,
encryption_capable_peers, scrub_secret, nostr_signing_secret, and protocol_state_record_key.
Sensitive record values are sealed with ChaCha20-Poly1305 under a per-install
key in secure storage (protocol_state_record_key), with each record's
associated data binding it to its (key_type, key_id) slot — so records cannot
be moved between peers or categories by anyone with container write access.
Sealing fails closed: with the key unavailable, those categories are not persisted at all rather than written in the clear. Delivery still works from memory; only crash recovery for them is lost. Records already on disk are left alone, so a later launch that can read the key recovers them.
What sealing does not hide — and what you should now treat as exposed to anyone who can read the app container of an unlocked device:
| In the clear | What it reveals |
|---|---|
blocked_users |
which peers you have blocked |
both_create_awaiting_decrypt |
which peers are mid-handshake |
session_states, welcome_lifecycles |
which peers you have sessions with, and their delivery state |
peer_key_packages, peer_capabilities |
which peers you have exchanged with |
| sealed categories' keys | which peers you have queued messages for, and their message ids |
And because unsealed categories carry no integrity protection, container write
access degrades safety and liveness controls, not just privacy: deleting a
blocked_users marker silently unblocks that peer; writing Confirmed into
session_states promotes a still-pending session. On stock iOS/Android the
container is app-private, so this matters on rooted/jailbroken devices. If your
threat model includes that, re-derive the block list from a source you trust —
sealing blocked_users is not the fix, because a block list that stops
persisting whenever the seal key is unavailable is a worse failure than a
readable one.
Note that sealing protects against edits, not deletions. The AEAD tag binds a record to its slot, so a modified or relocated record will not open — but nothing binds it to a version or to the set it belongs to, so removing a record, or restoring an older copy of it, is undetectable. Two controls that used to depend on a deletable record have been moved off it (see 1.10); if you build your own, do not assume a sealed category can notice something going missing.
The full discussion lives in MLS Integration → Protocol-State Confidentiality.
Handle a failed initialize_mls explicitly. It got more forgiving about
individual bad records and less forgiving about one specific thing.
| Situation | Before | Now |
|---|---|---|
One record in session_states or welcome_lifecycles won't read or won't decode |
failed init, on every launch, forever — and with require_encryption on by default that install could send nothing, with no in-app recovery |
record dropped, restore continues; a session whose confirmation can't be read is re-bootstrapped as Pending, never Confirmed |
A store reports a record Corrupted |
left in place, re-read on every boot, never settled | dropped and settled |
| A transient persistence failure on the restore path (Welcome-lifecycle repair, missing-session bootstrap) | failed init | logged, continues; the repair holds in memory for the run |
A blocked_users listing failure |
swallowed — came up with an empty block list and told no one | fails init and rolls back |
That last row is the intentional new hard failure. A listing error is indistinguishable from an empty store, so swallowing it means every blocked peer silently unblocked from a transient error. Blocking is a safety control, so it fails closed.
What to do: if initialize_mls (or RN's
initializeMlsWithSecureStorage() / the automatic init inside start()) fails,
do not proceed as if the SDK is usable and do not present the user as unblocked.
Surface it and retry — initialization is transactional, so a rolled-back attempt
leaves no partial state and a retry is safe.
New in v0.19.0. Only affects encryption.enabled = true with
requireEncryption: false — the mixed-mode opt-out. Every other configuration is
unchanged.
The gate that rejects inbound cleartext used to ask whether a confirmed MLS
session existed with the claimed sender, which it answered from the
session_states protocol-state record. That record lives in the app container,
so deleting it made the peer look unconfirmed on the next launch and re-opened
the gate for them. It now asks whether the peer is known to run MLS at all,
sourced from the MLS session list and the durable encryption-capability records
— both in the credential store.
What changes in practice:
- Cleartext from a peer you hold an MLS session for, or who has signed a control message this install verified, is now rejected even when the session was never confirmed. No honest peer sends cleartext in that state (a sender with a pending session queues rather than downgrading), so this should only ever fire on an injection or a real downgrade.
- Capability is not forgotten when a session is torn down, because teardown can be triggered remotely by an injected frame. It is never forgotten at all (see §14): under derived addresses a peer that re-keys is a different address, so nothing learned about the old one stops being true.
- A peer that genuinely loses its MLS state — a failed
initialize_mls, a reinstall — and then sends plaintext will have it rejected, with the usual once-per-peerPLAINTEXT_RECEIVE_REJECTEDwarning, and no delivery ACK, so they will retry. Under derived addresses that peer comes back as a new address, so it reaches you as a new contact rather than as a downgrade. - Peers that have never shown any MLS signal are unaffected, so plaintext-only interop keeps working.
- Capability is learned from unauthenticated signals as well as authenticated
ones: a well-formed
__MLS_WELCOME__marks its sender even if the join fails, deliberately, so a peer whose handshake is breaking does not keep the gate open. The trade-off is that an injected frame naming a plaintext-only peer can mark that peer capable and suppress their cleartext for the rest of the run. It does not persist — a restart clears it, since restore seeds only from sessions and durable capability records. If a legacy peer goes unreadable withPLAINTEXT_RECEIVE_REJECTEDand you hold no session for them and have verified no signed control frame from them, that is the case you are looking at. The set is also capped; past the cap new peers fall back to the old session-state check rather than displacing anyone already in it.
New in v0.19.0. peer_key_packages is now a sealed category, and every use
of a key package is checked against the peer's identity.
Superseded by §14. In
v0.19.0the check compared the key package against a TOFU-pinned signature key. The pin store is gone; the check now re-derives the address from the key package's own signature key and compares it to the peer id the package arrived under. The observable behaviour below is unchanged — a key package that does not match its peer is still refused — but there is no pin to manage, reset, or lose, and the error code isSENDER_ADDRESS_MISMATCHrather thanTOFU_KEY_MISMATCH.
- Cached key packages written by earlier builds are unsealed and will be dropped on first launch after upgrade. This is not an error and does not fail initialization; it costs one key-package re-exchange with those peers, which the SDK performs automatically on next contact.
mlsImportKeyPackagenow applies the same check, and returns an error if the package's leaf signature key does not correspond to that peer id. Apps driving the low-level MLS API against a different identity than the one the peer signed with will start seeing that error — which is the point, but it is a behaviour change for that entry point. The FFI signature is unchanged, so no bindings regeneration is needed.inviteToGroupnow also verifies the invitee's key package identity, which that path previously did not check at all.
ProtocolStateStorageProvider uses bytes, so a custom provider receives and
returns:
| Language | Type |
|---|---|
| Kotlin | ByteArray |
| Swift | Data |
| Python | bytes |
MlsStorageProvider is unchanged — still sequence<u8> (List<UByte> /
[UInt8] / list[int]). It carries key material a few hundred bytes at a time,
where representation does not matter. Protocol-state records reach megabytes,
and List<UByte> boxes every element with no valueOf cache — on the order of
two million short-lived objects per call for a 2 MiB record, inbound and
outbound.
Python custom providers:
class MyStateStorage(ProtocolStateStorageProvider):
def store(self, key_type: str, key_id: str, data: bytes) -> None: ...
def load(self, key_type: str, key_id: str) -> bytes | None: ...
def delete(self, key_type: str, key_id: str) -> None: ...
def list_keys(self, key_type: str) -> list[str]: ...update_ack_config, update_retry_config, and update_dedup_config all
return Result in Rust and are [Throws=ProtocolError] over UniFFI.
They now validate by building the candidate configuration and running the real
ProtocolConfig::validate on it, so a runtime update cannot install something
OfflineProtocol::new would have rejected. What they refuse:
- zero ACK timeout, zero max pending ACKs
- zero retry delays, non-finite or
< 1.0backoff multiplier outbox_max_lifetime_msorpending_message_max_lifetime_msof0, or abovei64::MAX- zero
max_tracked_messagesorretention_time_secs(see §4)
On rejection the previous configuration is kept — nothing is partially applied.
// Rust
protocol.update_retry_config(retry_config)?;// Swift — all three now need `try`
try mesh.updateAckConfig(config: ackConfig)
try mesh.updateRetryConfig(config: retryConfig)
try mesh.updateDedupConfig(config: dedupConfig)Kotlin and Python surface the throw through their normal exception path; the declaration change still requires regenerated bindings.
No ProtocolError variants were added — the append-only FFI error taxonomy is
unchanged. Rejections arrive as InvalidConfiguration.
React Native: no code change. The bridge already wraps all three in try/catch. Two behaviours to know:
- A
reliabilityblock passed to theOfflineProtocolconstructor is applied duringstart(), where a rejection isconsole.warned and swallowed — the SDK keeps its defaults. A silently-defaulted reliability block looks like it worked. Grep your logs forFailed to apply … configuration. - A direct
protocol.updateDedupConfig(...)call rejects the promise (ERROR_CONFIG). Handle it.
ProtocolConfig::validate now constrains the two dedup fields that are not
behind use_bloom_filter — which is what makes the change reachable from a
binding caller at all, since the UniFFI DedupConfig carries only those two.
Neither failed safe. At max_tracked_messages == 0 the exact-match tracker
evicts on every insert, holding a single id, so duplicate suppression — a replay
defence — was effectively off for a configuration the SDK accepted in silence.
retention_time_secs == 0 expires every entry immediately for the same result.
This rejects only the degenerate value. A floor of 1 is not a floor on how
well duplicates are suppressed (1 behaves indistinguishably from 0, and a
retention shorter than the link's retry backoff suppresses nothing either).
Sizing the window for your deployment stays your call.
Two places this binds:
- Runtime update —
update_dedup_configreturnsInvalidConfigurationand keeps the previous configuration. Both RN bridges read these straight from JSON, so an app passing0is affected. - Construction (Rust only) —
OfflineProtocol::newnow fails outright for aProtocolConfigwith either field at0. The FFI init surface carries no dedup fields, so no binding caller can hit this form.
What to do: grep your config for maxTrackedMessages: 0 /
retentionTimeSecs: 0 (and the snake_case forms) and pick real values. Defaults
are 1000 and 3600.
Every user-targeted send API now validates the recipient as a UserId before
any queue, outbox, clock, or transport side effect. An app-owned placeholder
can no longer become indefinitely-retried durable protocol state.
UserId rejects: empty strings, . and .., ASCII control characters, /,
\, :, and anything over 256 bytes.
That : is the behavioural break. These APIs previously accepted any
non-empty string:
send_presence_updatesend_typing_indicatorsend_read_receiptsend_service_requestrespond_to_service_request
So namespaced identifier forms that used to work now fail with
InvalidArgument:
unresolved:token ❌
did:key:z6Mk… ❌
npub:abc… ❌
Apps carrying such tokens must resolve them before calling the SDK. There is no compatibility mode — the whole point is that an unresolvable address cannot enter durable state.
Full list of validating entry points:
| API | Notes |
|---|---|
send_message, send_message_with, send_message_rich |
|
forward_message |
|
send_media, send_media_with |
|
send_connection_request, accept_connection_request, reject_connection_request, cancel_connection_request |
|
send_presence_update, send_typing_indicator, send_read_receipt |
previously unvalidated |
send_service_request, respond_to_service_request |
previously unvalidated |
invite_to_group |
admission is where the gate belongs |
Deliberately exempt: remove_from_group and group role/admin mutation. A
gate there would turn "a member with a stale-format id is on the roster" into
"that member can never be removed or demoted" — the wrong direction for a
moderation control.
Restore also applies this: a persisted pending queue whose recipient does not
validate is settled with the reason
Recipient is not a valid user ID; queued message cannot be delivered. Expect
some of these on the first launch after upgrading if you ever queued to a
placeholder.
send_message, send_message_with, and forward_message now fail with
InvalidArgument for content over 256 KiB:
Message content too large: N bytes (max 262144); use send_media for large payloads
The cap sits at the send boundary rather than at transmit time because a message waiting on MLS session establishment is queued — in memory and on disk — long before it reaches the transport's own 1 MiB check, so a transmit-time cap would never run for exactly the messages that accumulate. It sits well under 1 MiB to leave room for MLS ciphertext expansion, base64, and the JSON wire envelope, so anything accepted can actually be delivered.
Large payloads belong on send_media / sendMedia, which chunks and is not
subject to this limit.
Group sends are exempt from this cap — a group send encrypts to group state that already exists, so it has no durable pre-session queue behind it and the boundary check does not run. That is unchanged behaviour. (It is exempt from the cap, not from delivery tracking: as of the per-member fan-out default, each recipient's copy of a group message carries its own outbox entry, ACK, and retry ladder. See Group sends.)
What to do: if your app can produce large text bodies (pasted logs, embedded
data URIs, JSON blobs in message content), add a length check at your composer
and route over media instead. Surface the rejection — it arrives as a thrown
error / rejected promise, not as a message_failed event.
| Field | Default |
|---|---|
pending_message_max_lifetime_ms / pendingMessageMaxLifetimeMs |
604800000 (7 days) |
Mirrored through UniFFI (RetryConfig) and React Native (RetryConfig in
types.ts). An entry that exceeds it is removed from memory and from
protocol-state storage and settled with
message_failed / reason Pending session lifetime exceeded.
The lifetime is absolute — measured from when the message first entered
the queue. A flush that finds the session still unavailable carries the original
timestamp forward, so reconciliation cannot keep a message alive past its
window. The flush path also refuses to dispatch an entry past its deadline, so
an expired message can never settle MessageSent.
| Bound | Value | At capacity |
|---|---|---|
| Messages per peer | 64 | oldest settled message_failed, then the new message is admitted |
| Messages globally | 4096 | globally oldest settled the same way |
| Bytes per peer | 2 MiB | oldest evicted until the new message fits |
| Bytes globally | 16 MiB | globally oldest evicted until it fits |
| Single protocol-state record | 4 MiB | refused on write, dropped on read |
All four evictions emit message_failed with reason
Pending session queue capacity exceeded. Restore applies both budgets too, so
a record written by an older build cannot re-inflate memory on boot.
Byte bounds exist because an entry count alone bounds neither memory nor durable storage — message content is application-supplied, so four very large messages sit there reporting 4/64 and looking fine.
- Expiry work is scheduled from the earliest queued deadline instead of scanning
the queue every 100 ms
process()tick. - Expiry is bounded to 64 entries per tick; whatever a pass leaves behind is still past its deadline and drains on the next tick. So a large batch of expiries settles over several ticks rather than in one.
- A pre-split install can hold far more than these caps admit (the pre-split
build had no pending-queue caps at all). Expect a burst of capacity
message_failedevents on the first launch after upgrading. Make sure your UI can render that without looking like a mass send failure.
Two independent fields where the RN layers substituted their own value for an omitted config field, and that value had drifted from the Rust default. Neither affects an app that passes the field explicitly.
Both RN bridges substituted 5000 when updateAckConfig was called without
defaultTimeoutMs, silently halving the timeout against the SDK default. The
fallback is now 10000, matching DEFAULT_ACK_TIMEOUT_MS.
Apps that omit it: ACK waits — and therefore retry timing — return to the documented default. If you were relying on the 5 s behaviour, set it explicitly.
DEFAULT_PENDING_TTL_MS moved to 30 minutes when delivery ACKs became deferred
(see §7 for the
outbound queue — this is the inbound pending-decryption queue, a
different one). Rust and UniFFI were updated; all three RN layers kept the old
120000, and because the JS wrapper materializes the field before it crosses
the bridge, an RN app that omitted pendingQueue.pendingTtlMs got 2 minutes no
matter what the SDK default said.
All three now use 1800000. Apps that omit the field hold an
arrived-before-the-session-was-ready message for 30 minutes instead of 2,
which is the window the deferred-ACK model needs — that queue is the primary
recovery path before the session confirms, and a message evicted from it is not
delivered and was never ACKed. Memory is unchanged: the count caps (64 per peer,
4096 global) and byte caps (4 MiB / 32 MiB) still bound it, so a longer TTL lets
entries linger within those caps rather than raising the ceiling.
If you were relying on the 2-minute behaviour, set pendingTtlMs explicitly.
Drift tests now pin all of these bridge literals to the Rust constants, so they cannot separate again.
Only relevant if you wrote native code that calls internetMessageReceived, or
you synthesize relay answers yourself. The bundled bridges are already fixed.
Relay answers — __GROUP_CREATED__, __GROUP_ERROR__, and the
__GROUP_INFO__ / __USER_GROUPS__ snapshots — are synthesized locally from a
relay notification. No peer transmits them. The bridges were passing the literal
string "relay" as the FFI sender_id and stamping the frame
requires_ack: true.
Both are claims about a peer that does not exist, and the core acted on both:
"relay" was inserted into known_peers, emitted as NeighborDiscovered,
enrolled in service-discovery fan-out, sent an unsolicited key-package DM under
default auto_key_exchange — and, far more often, every injected frame produced
a delivery ACK addressed back to relay. All undeliverable, each drawing a relay
DeliveryError that re-armed the presence watch, which is why
Presence check for relay: false (last seen: None) never aged out.
The rule: senderId on internetMessageReceived asserts the peer is
reachable. It drives outbox flush, Welcome re-arm, auto key exchange, and
neighbor_discovered.
| Frame | senderId |
requires_ack |
|---|---|---|
| Locally synthesized (no peer sent it) | "" |
false |
Names a real relay-reported actor (a group message's sender, added_by, removed_by) |
that id | unchanged |
Unattributed ingestion is a supported, tested mode of the reachability seam. Never pass a placeholder id.
Both built-in stores derive an opaque namespace — account-<sha256 hex> over a
domain-separated (app_id, user_id) — and use it as a path component and
credential-store suffix. Multiple accounts on one install can no longer share
keys or delivery state.
Changing either appId or userId selects a fresh storage namespace. If
your app treats userId as mutable (renames, re-registration), understand that
this now means "start from a fresh identity and an empty outbox".
Namespaces are validated on all three platforms before becoming a path — a
custom namespace must match account-[0-9a-f]{64} exactly.
The pre-namespace store was shared by every account on the install, so at most
one account may inherit it. The first to launch writes a claim, reads it back
to verify, and then adopts by read-through: a miss in the namespaced store falls
through to the legacy store and promotes what it finds. delete removes the
legacy copy too, so a deleted key cannot be resurrected; if that removal fails,
the key is tombstoned in the namespaced store instead and read-through treats it
as absent from then on, so the guarantee holds either way. The whole
probe → claim → read-back sequence holds a process-wide lock, so two accounts
starting at once cannot both adopt.
An account that does not win the claim gets neither identity nor delivery state: a fresh MLS identity, an empty outbox, an empty pending queue, and an empty block list. That is reported, never silent:
| Platform | How it surfaces |
|---|---|
| React Native | diagnostic event, level error — Legacy secure store belongs to another account… or Could not record this account's claim… |
| Python | SecureStorage(...).legacy_adoption, plus a logged warning |
What to do: log and alert on those diagnostics. They mean a user lost their sessions, groups, peer capability records, queued messages, and block list. If your app supports multiple accounts on one device, decide which one should inherit — the SDK's answer is "whichever launches first", which may not be yours.
Python callers building SecureStorage directly: pass a namespace, or you land
on the new service name, find nothing, and mint a fresh identity. A no-namespace
construction now warns. adopt_legacy_store=False opts out quietly, since that
is a decision rather than an accident.
Both adoption mechanisms — the secure-store read-through and the protocol-state sweep — are one-shot upgrade scaffolding. The sweep is resumable (a crash leaves the remainder for the next launch), non-destructive (a key already present in protocol-state storage wins), and marked complete only when it finished without a storage error.
Namespacing keeps accounts apart; it does not erase one when a user signs out. Destroying the protocol instance releases memory and nothing else — the outbox, the pending queue, the block list, and the whole MLS identity stay on disk under that account's namespace. Two consequences are worth planning for:
- On the next sign-in as the same user, the restored outbox is re-driven.
Undelivered messages are retried on every launch and reconnect until they
expire (
outbox_max_lifetime_ms, seven days by default) or exhaust their retries. - On iOS the Keychain outlives the app container, so an uninstall does not take the secure store with it. A reinstall followed by a sign-in as the same user adopts that material again — identity, sessions, and, through the pre-split store, delivery state.
React Native applications can erase all of it (added in v0.18.2; on
v0.17.0 this API does not exist):
await protocol.destroy();
await protocol.wipePersistedState(appId, userId);Order matters, and the identity is passed explicitly. The protocol persists
as it works — outbox entries on the send path, pending snapshots, sealed state
records — so a wipe underneath a live instance races those writes; the native
side rejects the call if the account named is the one the current instance is
running. The identity is an argument because destroy() clears the config the
namespace would otherwise be derived from. Pass the same appId/userId the
protocol was created with; any other pair names a different account and wipes
nothing.
What it erases, for that account only:
| Store | iOS | Android |
|---|---|---|
| Namespaced secure store | Keychain service <bundle>.mls.v2.<namespace> |
mls_secure_storage_v2_<namespace> |
| Namespaced protocol state | Application Support/<bundle>/protocol-state-v1/<namespace>/ |
noBackupFilesDir/offline-protocol/protocol-state-v1/<namespace>/ |
| Pre-namespace secure store | Keychain service <bundle>.mls |
mls_secure_storage |
The pre-namespace store is only erased when this account owns the claim or the store is unclaimed. Another account's claim makes it off-limits, and a claim that cannot be read also stops the wipe — unreadable and foreign are indistinguishable, and only one of those two mistakes is recoverable. A claim that is present but not decodable counts as another account's, for the same reason: bytes this SDK cannot interpret are still evidence that something claimed the store. The androidx master key is never touched: it is shared with every other account's store.
Three things to know before wiring it in:
- It rotates the account's MLS and Nostr identities. Peers holding a
session will see a desync on next contact and re-establish from a fresh key
package. Because the identity key is what the address derives from, wiping it
gives this device a new address: peers reach the old one and find nobody,
and must be given the new one out of band. Read it back with
localAddress()after the nextinitializeMls. - It is irreversible and it is not a "clear my messages" button. There is no partial mode; the outbox, block list, and every group membership go together.
- Retry on failure. The wipe is idempotent, attempts every store even if one fails, and reports the first error. Secure storage goes first, so an interrupted wipe leaves protocol-state records as ciphertext whose key is already gone rather than as readable state.
Applications that supply their own storage providers must erase their own containers — the SDK only knows how to wipe the built-in ones.
There is no equivalent API in the Python bindings. The state directory could be
removed trivially, but the secure store cannot be enumerated: keyring has no
listing operation, the SDK's per-key_type index has no index of key types,
and the MLS key-type set is open (OpenMLS contributes its own labels). A partial
wipe that left signing-identity material behind would be worse than none, so
Python callers should scope SecureStorage to a namespace they can drop
wholesale at the backend instead.
No event types were added — MessageFailed and ConvergenceDiag already
existed — but they now fire in new situations, and one of them can name a
message you never saw fail before.
Restore settlements are parked until start(), not emitted from
initialize_mls. Apps routinely call initialize_mls before installing a
callback, so anything emitted there would be lost. resume() drains them too
(a pause()d app that shortens pendingMessageMaxLifetimeMs settles messages
while parked).
The parked queue is capped at 8192 settlements, keeping the oldest and reporting the suppressed count when it drains.
| Reason | Meaning |
|---|---|
Pending session queue capacity exceeded |
evicted by any of the four pending caps |
Pending session lifetime exceeded |
pending_message_max_lifetime_ms elapsed |
Outbox capacity exceeded / Outbox lifetime exceeded |
pre-existing |
Recipient is not a valid user ID; queued message cannot be delivered |
restored queue failed the new recipient validation |
Outbox entry from a previous version was too large to migrate |
pre-split record over the 4 MiB record cap |
| an unrecoverable outbox record | its record key is the message id, so the loss is named |
Emitted when a whole pending queue is unrecoverable. The message ids live
inside the record that would not open, so the peer_id is the most that can be
reported. detail carries the reason.
- A dropped record is reported, not swallowed. Anything the app was told was queued gets settled when it cannot be recovered.
- But only a record that is actually gone is settled. A record that merely could not be read this session — seal key unavailable, one refused read — stays on disk and produces no event, because settling it would be a terminal answer the next launch overturns by restoring the entry and delivering it.
- Therefore: do not treat a quiet startup as proof everything restored. Treat
message_failedas proof that something did not. - A retry of a failed
initialize_mlscan settle the same id twice. A duplicate terminal event is deliberate — it is a far smaller lie than silence. Make your handler idempotent.
No new event type — but this one changes meaning, and an app that settles a message on it now settles too early.
A receiver that cannot decrypt or parse an inbound frame used to drop it and send a delivery ACK anyway, so the sender marked the message delivered and stopped retrying: silent loss behind an ACK claiming the opposite. It now withholds the ACK, which is what lets the sender's resend deliver — and for a DM that resend is re-sealed against the peer's current session, so it carries a live ratchet generation rather than replaying bytes that already failed.
The consequence for your event handler:
| Event | Before | Now |
|---|---|---|
messageDecryptionFailed |
effectively terminal — one per lost message | advisory, once per failed attempt, bounded by the sender's ACK retry budget |
messageFailed |
terminal | terminal — settle here |
fileReceiveFailed |
terminal | terminal — settle here for media |
What to change. If your UI marks a message lost, removes it from a list, or
resolves a promise on messageDecryptionFailed, move that to messageFailed
(or fileReceiveFailed for media). Treat messageDecryptionFailed as "this
attempt did not decrypt" — useful for diagnostics, and expect repeats for the
same message. Make the handler idempotent, as with the settlements above.
Media has no sender-side re-seal (chunks are re-encoded rather than replayed),
so an undecryptable chunk recovers the way an interrupted transfer already
does: the withheld ACK drives the media outbox to surface MediaResendRequired
and the app re-supplies the bytes.
What did not change is the re-key. Only a proven epoch mismatch tears down and rebuilds a session. These failures withhold the ACK but never re-key — turning every malformed frame into a session teardown would be an unbounded churn vector. And failures after a successful decrypt stay terminal and still ACK (an empty or non-UTF-8 plaintext, a decrypted media body that does not parse): the generation is spent, so no resend could ever deliver.
All of the above sits under the existing encryption.cryptoRecoveryEnabled
kill switch (default on); setting it false restores the previous
drop-and-ACK. One related change is deliberately not under that switch: a
media chunk failing its identity binding is now answered with silence, matching
what the text path has always done, because it governs what the receiver
reveals to whoever injected the frame rather than whether anything can be
recovered. A repeated injection of the identical frame therefore re-emits
MEDIA_SENDER_GROUP_MISMATCH on each attempt instead of being suppressed by
dedup after the first — the rate is the signal, as with SESSION_REKEY_TRIGGERED.
The event has always meant "an establishment attempt failed", but until now
every path that fired it also left no live session, so tearing down session
state on it happened to work. v0.21.0 adds the first counter-example: a
session Welcome refused for carrying an unprovable identity
(§14's leaf binding) is
refused non-destructively — the Welcome is declined, a
GROUP_LEAF_IDENTITY_UNPROVEN security warning fires alongside it, and the
session you already had with that peer keeps working.
What to change. If your app clears conversation encryption state, resets a
"secure" indicator, or re-triggers establishment on secure_session_failed
alone, stop — the peer may still be reachable over the surviving session.
Settle session liveness on secure_session_established and actual send
results; treat secure_session_failed as a diagnostic about one attempt.
The SDK has carried a complete battery-aware relay policy for several releases:
DORS scores energy, the relay role promotes and demotes, and message forwarding
refuses to spend the last few percent of somebody's battery carrying other
people's traffic. All of it read the charge out of a per-transport metrics map
that nothing ever wrote to. The policy was complete and unreachable, so on every
real device the relay role was never evaluated, relay_promoted and
relay_demoted could not fire, and forwarding always took its "unknown battery
means willing" branch. The floor that exists to stop a dying phone relaying was
decorative.
v0.22.0 lands the feed on the TransportManager and merges it into the two
snapshot loops that build the metrics map, so everything downstream of it comes
alive at once. Three consequences, none of which changes a signature:
- Nothing happens until your app supplies the feed. Call
setBatteryState(level, isCharging). Prefer it oversetBatteryLevel(level): a plugged-in device is deliberately excused the soft floor, and reporting the level alone strips relay duty from exactly the devices that should keep it. An app that calls neither gets the same nothing it got before, now documented rather than accidental. - Once the feed arrives, forwarding can stop. A device under the floor now declines to carry other people's frames, which it never did before. That is the floor working rather than a regression, and it is the whole point of reporting the level.
- React Native only:
allowRelayandminBatteryForRelaywere ornamental and now take effect. Both crossed the bridge and were then parsed by nothing, because onlyrelayPrioritywas read. An app that setallowRelay: falseand watched the device relay anyway will now see it stop.
What to change. Subscribe a platform battery observer (BatteryManager on
Android, UIDevice on iOS) and push into setBatteryState; this release ships
the API, not the subscription. Then re-read any relay config you set while it
was inert, because those values now decide behaviour instead of being recorded
and ignored.
This is the one change in this document that can stop an iOS build outright,
and it is the only step required. Open ios/Podfile and delete:
- Any
pod 'MeshSdk', ...line, with or without:modular_headers => true. - Any
post_installhook that configuredMeshSdk— in particular one settingDEFINES_MODULE,SWIFT_INCLUDE_PATHS,LIBRARY_SEARCH_PATHS, orOTHER_LDFLAGSfor it. The podspec now sets what is needed; a hook naming$(PODS_TARGET_SRCROOT)/Generatedpoints at a path that no longer exists. - Any flag naming
offline_protocol_uniffi_simoroffline_protocol_uniffi_device. Those two archives are gone, and linking them fails with library not found.
Then pod install. Its output should list MeshSdk under "Auto-linking React
Native modules".
If a pod 'MeshSdk', :path => '.../mesh-sdk/ios' line survives the upgrade,
pod install fails immediately with a "no podspec found" error naming that
path — loud, not silent. Deleting the line is the fix.
What changed and why. Two packaging defects were fixed together:
- iOS autolinking is enabled. It was off because the podspec lived in the
package's
ios/directory, and React Native resolves a dependency's podspec by globbing*.podspecin the package root only, without recursing — so autolinking could never see it and every consumer had to declare the pod by hand.MeshSdk.podspecnow sits at the package root. - The native binary is an XCFramework. It previously shipped as two loose
archives (
..._device.a,..._sim.a) declared throughvendored_libraries, which made CocoaPods emit an unconditional-lfor both into the app's link line. Simulator builds then tried to link the device archive and failed on the architecture mismatch. The podspec's own sdk-conditionalOTHER_LDFLAGScould not prevent this: they sat inpod_target_xcconfig, and the flags that matter are on the app target, which a podspec cannot reach. Slice selection is now CocoaPods' job, and it gets it right without help.
Simulator builds work. If you had concluded this SDK was device-only, that was this bug. Both slices have always shipped in the npm package — the publish gate refuses to publish without the simulator slice — but the linker flags made the simulator one unusable. No workaround is needed now, and any workaround you carried must be removed per the list above.
Nothing else changes: same package name, same import, no JS/TS API change, no wire-format change, Android untouched.
Regenerate UniFFI bindings. The UDL changed: a new
ProtocolStateStorageProvider callback interface, the two-argument
initialize_mls, [Throws=ProtocolError] on the three config updaters, and
pending_message_max_lifetime_ms on RetryConfig.
./scripts/generate-bindings.sh # after UDL changes — Swift, Kotlin, Python
cd bindings/react-native
npm run build:uniffi:all # or :ios / :androidRequires uniffi CLI 0.30.0 matching the workspace pin.
React Native native rebuild required — new source files are compiled in:
| Platform | New files |
|---|---|
iOS (MeshSdk.podspec) |
ProtocolStateStorage.swift, StorageNamespace.swift, LegacyStoreAdoption.swift |
| Android | ProtocolStateStorage.kt, StorageNamespace.kt, LegacyStoreAdoption.kt |
Run pod install for iOS. A JS-only update will not pick these up.
Android test dependency: org.robolectric:robolectric:4.16 was added to
testImplementation. The build.gradle react-native detection also now checks
for react-native/android/ rather than just the package directory, so newer RN
packages no longer resolve to an unversioned react-android dependency.
Events cross the FFI as JSON strings, so event field changes need no bindings
regeneration — only bindings/react-native/src/types.ts, which can drift.
Run these against a build of your previous version, then upgrade in place.
Migration correctness
- An install with queued (undelivered) messages still has them after upgrade, and they deliver.
- An install with blocked peers still has them blocked after upgrade. (This is the sharpest failure mode — verify it explicitly.)
- MLS identity survives within a release line: existing sessions still decrypt and existing groups still work. This does not hold across §14 — the identity becomes the MLS credential there, so every session and group from a pre-§14 build is invalidated by design. Upgrading across §14, verify the opposite: that peers re-establish cleanly from fresh key packages rather than appearing stuck.
- Multi-account installs: exactly one account inherits; the others log the
errordiagnostic and start clean without crashing. - Uninstall removes protocol state (Python: verify your installer removes
state_root). - Kill the app mid-first-launch, relaunch: the sweep resumes and converges.
- A failed
initialize_mlsis surfaced and retried, not treated as success (§1.9).
New rejections
- No code path sends to a
:-containing recipient (unresolved:,did:,npub:) — including presence, typing, read receipts, and service requests. - Oversized text sends are caught at your composer, not surfaced as an opaque error.
- No reliability config passes
0formaxTrackedMessagesorretentionTimeSecs. - Swift: all three
update*Configcalls compile withtryand handle the throw.
Events
- Event callback is installed before
start(). -
message_failedhandler is idempotent and can render a burst without looking like a mass failure. -
convergence_diag/pending_state_lostis at least logged. - RN:
Failed to apply … configurationwarnings are surfaced, not buried.
Custom providers only
-
storeis durable before it returns (test with a forced power loss or an fsync-counting fake). -
loadrefuses over 8 MiB without allocating it. - Destroyed records report
CorruptedData; transient failures reportLoadFailed. - Entries are addressed by digest, not by an encoding of the key.
- A custom
MlsStorageProviderreads through to your previous location, or you migrated the pre-split key types yourself (§1.6).
ProtocolConfig.userId is gone. It is replaced by profile, and the two
are not the same thing wearing a new name.
userId used to be your identity on every surface at once: the app picked a
string and that string was the device — its sender, its recipient, its
peer_id, and the thing peers trusted. Nothing authenticated it, so
impersonating someone cost typing their name.
Now the device holds an Ed25519 identity key and its address is the hash of
that key, rendered off1…. Peers verify an address by re-deriving it from the
key its owner presents, so claiming one you do not hold a key for is not
possible rather than merely discouraged.
profile is what is left of the old field's other job: choosing which stored
identity this instance runs as. It never leaves the device.
const protocol = new OfflineProtocol({
appId: 'my-app',
- userId: currentUserId,
+ profile: currentUserId,
});Keeping the same string in profile that you passed as userId keeps you in
the same storage namespace — the namespace is still
SHA-256(domain ‖ 0x00 ‖ appId ‖ 0x00 ‖ <that string>), unchanged. An app with
one account per install can pass a constant like 'default'.
const address = await protocol.localAddress(); // "off1q..." or nullIt is null until startup completes, because the key that defines it lives in
storage that is not open before then. The identity_ready event carries the
same value at the moment it becomes known:
protocol.on('identity_ready', ({ address }) => setMyAddress(address));Equivalents: local_address() in Rust and Python, localAddress() over
UniFFI.
Read this before you re-key anything. The single most common way to get this migration wrong is to conclude "our user id changed, so everything keyed by it must change". That is half right, and the wrong half destroys data.
The migration splits in two, and only one side moves:
| Keyed by | Changes? | |
|---|---|---|
| Your own storage — your local DB filename, MMKV/UserDefaults namespace, cache directories, the SDK storage namespace | the string you pass as profile |
No. Keep passing the same string you passed as userId. |
Peer identity — recipient, conversation keys, contact rows, group rosters |
the peer's off1… address |
Yes. |
profile never goes on the wire and no peer ever sees it. Passing your old
userId through unchanged is not a migration shim — it is the intended use,
and it keeps every namespace you derived from that same string intact.
With that split in mind:
- Sending.
recipientmust be a peer's address. A username reaches nobody. - Comparing. "Is this message mine?" compares against
localAddress(), not against the profile. - Storing peer-keyed rows. Conversation rows, contact records, and group membership keyed by a peer's username must be re-keyed by that peer's address. There is no mapping from the old peer ids to the new ones — those identities genuinely changed.
- Storing self-keyed state. Leave it alone. See the two traps below.
- Displaying. An
off1…string is not a name. Keep your own display names (thesenderName/accepterNamefields already carry them) and treat the address the way you would a phone number: the thing you route on, not the thing you show.
Both of these were hit by a real app during this migration. Neither throws, neither logs, and both look like a successful launch.
1. A per-user database or namespace opened under the new id. Code shaped like this is extremely common:
const db = open(`db-${userId}.sqlite`); // or `user-${userId}` for MMKVAn off1… address passes the usual identifier validation (it is plain
lowercase alphanumerics), so this opens a different, empty database. No
error, no fallback — the entire message history simply stops existing, and a
fresh empty store looks exactly like a first launch.
Fix: feed these the value you pass as profile, not the address.
2. A teardown/switch check that reads the id change as an account switch.
const shouldWipe = nextUserId !== tornDownUserId; // now true for everyoneIf an id-changed comparison gates wiping SDK state, upgrading looks like every user switched accounts, and the app wipes its own MLS sessions on first launch.
Fix: compare profiles, which do not change.
The general rule: if a string was doing double duty as "who I am" and "which
storage is mine", the second job stays with profile. Only the first job
moves to the address.
Reaching someone by typing their username was only ever possible because usernames were addresses. Two paths give it back, and they are not equivalent.
Invite/QR is the primary path, and permanent. createInvite() produces a
compact blob carrying {address, pubkey, petname?, sig?}, and parseInvite()
verifies it offline: derive_address(pubkey) == address is checked at scan,
before create(). Nothing about this is transitional — the out-of-band
confirmation a scanned code represents is what makes the directory below safe.
A signed username directory is additive, and off by default. Setting
transports.nostr.usernameDiscoveryEnabled publishes a record binding this
install's profile to its address and enables resolveUsername(). It is
deliberately opt-in: publishing binds a human-readable name to an address in a
public place, where the mapping is the payload.
The directory is not authoritative — anyone may claim any name — so a
resolution returns the whole set of claimants in one username_resolved event
and a human must choose. Do not auto-select, and store the address rather than
the name. See docs/spec/username-discovery.md.
If you already run an account system, do not wait for that layer. A serverless discovery record is the right design for peers with no infrastructure, and the wrong one for an app that already has authenticated accounts and unique usernames — it is squattable by construction, where your own directory is authoritative. Binding an address to an account needs no new SDK surface; the four primitives already ship:
- Client calls
getIdentityPublicKey()andlocalAddress(). - Client signs a server-issued nonce with
signData(nonce). - Server checks the signature and that
deriveAddress(publicKey) === address. - Server stores the address on the account row and serves it from its existing user lookup.
That gives reach-by-username with your own uniqueness guarantees, and it stays the better answer now that the discovery layer has shipped: yours is authoritative and unsquattable, and the serverless directory by design is neither.
Changing the identity changes the MLS credential, which invalidates every existing session regardless of anything else — and 1:1 session slots are named after the two ids, which OpenMLS cannot rename. So this release starts a fresh identity world: old sessions do not carry over, and peers on an older build fail cleanly at credential verification rather than half-working.
Old containers are left untouched rather than deleted, so nothing is destroyed
on upgrade. Clean them up when you are confident, by passing the old user id
where wipePersistedState now expects a profile:
await protocol.wipePersistedState(appId, oldUserId);Relay accounts, JWTs, and group rosters move into address space with the server-side change; a build that sends address-shaped ids to a relay that still expects usernames will fail its identity check. Sequence the relay first.
Deriving the identity from the key makes the pin store redundant, so it is
deleted. There is no resetTofuForPeer and no tofu_reset event, and the
TOFU_KEY_MISMATCH / TOFU_STORE_FULL / SIGNATURE_DOWNGRADE warning codes no
longer exist.
What replaces them. A control message is accepted only if it carries an
Ed25519 signature and the key that produced it re-derives to the address in
sender. That check has no first-contact window — the pin store's weakest
point, where whoever claimed a name first became its owner — so impersonation
goes from winning a race to finding a 160-bit second preimage (~2^160). That is
the cost of aiming at an address that already exists. The birthday bound on the
same truncation is ~2^80, which produces two keys sharing one address rather
than a chosen peer's address — a deliberate trade against BLE frame budget,
documented on Address::HASH_LEN.
Three behaviour changes to plan for:
-
Unsigned control frames are refused, always. Previously they were accepted from any peer without a pin, and refused only from peers with one (or under
requireTransportIdentity). Now every security-gated control prefix must be signed. In practice this meansinitializeMlsis mandatory — an instance that never initializes has no identity key, so its control traffic is dropped by every peer. The relay server's own answers (__GROUP_CREATED__,__GROUP_MEMBER_ADDED__,__GROUP_MEMBER_REMOVED__,__GROUP_INFO__,__USER_GROUPS__,__GROUP_ERROR__) are exempt, because no peer signs them; that exemption is narrow — relay ingest only, and only for frames the relay did not attribute to a peer. -
SENDER_ADDRESS_MISMATCHreplacesTOFU_KEY_MISMATCH, and means something stronger. The old code was ambiguous: a peer who reinstalled looked exactly like an impersonator, which is why a reset action had to exist. The new one is not ambiguous. A peer who reinstalls gets a new key and therefore a new address, arriving as a new contact. If you see this code, the frame was signed by someone who is not who they say they are. Surface it as such; do not offer a "trust anyway" affordance. -
Relay-native member-removal reconciliation is now inert. A
__GROUP_MEMBER_REMOVED__frame is authorized off its wiresender, which must be a group admin. The relay's own answer is injected unattributed (that is what the exemption above requires), so its placeholder sender can never be an admin: the frame is dropped and nogroup_member_removedevent fires. Before this release the bridges passed the relay-reportedremoved_byas the sender, so the reconciliation could take effect for an admin no pin had been established for yet.The path the SDK itself uses is unchanged and still works: the removing admin sends a signed
__GROUP_MEMBER_REMOVED__directly to the removed member (removeMember). If your app removes members by calling the relay directly, the other members will no longer see the roster update — drive removals through the SDK instead, or wait for the follow-up that moves relay answers onto dedicated FFI entry points (the same work that closes the exemption's residual).__GROUP_MEMBER_ADDED__is unaffected: its handler reads the payload and runs no sender check.
requireTransportIdentity keeps its false default and no longer gates the
signature requirement. Its one remaining effect is to reject control frames that
arrive with no transport peer identity at all — which on a deployment running
Nostr or sender-less relay delivery rejects their entire control plane. Leave it
off unless you run neither.
Eight methods and four types were removed from every binding:
| Removed | Surface |
|---|---|
learnRoute, getBestRoute, getAllRoutes, hasRoute, removeNeighborRoutes, cleanupExpiredRoutes, getRoutingStats, updateRoutingConfig |
UniFFI, React Native (TS/Kotlin/Swift), Python |
RouteEntry, RoutingStats, GradientRoutingConfig, PathConfig |
dictionaries and their TypeScript interfaces |
ProtocolConfig.path (forwardToTopK, maxCongestionLevel) |
config, every language |
Delete your calls; there is nothing to replace them with. The table they
read and wrote was never consulted by the delivery path: forwarding chooses
among the neighbors a device can address at that moment, and always did. A
route recorded through learnRoute changed nothing, and getBestRoute answered
from a table no frame ever followed. Keeping the surface implied a routing layer
that did not exist.
If you passed a path section in your config, drop it. It was parsed and
carried to the engine, which never read it.
Applications that never called these methods (the expected case, since nothing in the delivery path depended on them) need no changes beyond removing the config section if present.
A new DataStore object ships on every binding: offline-first documents any
member of a space can edit while disconnected, merging deterministically when
replicas meet again. Messaging is synced events; this is synced state. Two
peers with a secure session converge on the documents they share, and so does
every member of a group.
Existing applications need no changes. data.enabled defaults to true,
but nothing is persisted until a document is written, nothing is sent until a
document is shared, and no existing API changed shape. An application that
never opens a store pays nothing at rest and nothing on the wire; set
data.enabled to false if you would rather the layer refuse outright. Skip
the rest of this section if you are not using it.
A space replicates with the scope its name refers to:
| Space name | Replicates with |
|---|---|
| A peer's address | That peer |
A group id (groupId from createGroup) |
Every member of that group |
| Anything else | Nobody; it stays on the device |
Nothing else needs configuring, and there is no sharing call: the space name is the sharing decision.
A group space uses the group's own roster, so adding or removing a member changes who replicates without any second membership list to maintain. A change is encrypted once for the whole group rather than once per member.
A group replicates only when every member is running a build that supports it. Group replication is negotiated separately from 1:1, because an install shipping only the 1:1 layer would display a group replication frame to its user as raw text. A single member on such a build stops the group's documents replicating for everyone until they upgrade, and the SDK will keep probing them. 1:1 spaces are unaffected. There is no event for this today; if a group's documents are not converging, check that every member is on a current build.
A document too large to catch up inside a single frame is reported rather than sent.
Replicas that stay in contact converge. Two that are separated by a partition outlasting a compaction may not, because compaction deletes the history a change made on the other side depends on. Nothing is lost on either device and nothing crashes; the documents simply stay apart, and the refusal is logged.
Deleting a document does not delete it from the peer, and does not keep it
deleted here. There are no deletion tombstones in this release. deleteDoc
removes the records on this device, and the peer's next version offer names the
document again, so it is recreated and refilled from their copy. In a space
named after a peer, treat deletion as local cleanup that replication may undo,
not as a way to remove content: to retire content from both sides, empty the
document (deletions inside a document replicate like any other change); to stop
a space replicating at all, use a space name that is neither a peer address
nor a group id. The
same is true of wipeAll() on a running engine, for the same missing
tombstone: see the storage section below.
const protocol = new OfflineProtocol({
appId: 'my-app',
profile: 'default',
data: { enabled: true },
});
await protocol.start();
const store = new DataStore();
await store.mapSet('space-1', 'profile', 'fields', 'name', {
kind: 'text',
value: 'Ada',
});
await store.flush('space-1', 'profile');enabled defaults to true, so the block above is showing you the flag
rather than requiring it. It was false while the layer could store documents
but not replicate them, because advertising a capability with no sync behind it
invites peers to expect a sync that never comes; both halves ship together, so
the switch is on.
Documents live wherever protocol state already does, so there is no setup.
If you point them somewhere else — DataStore.withStorage(protocol, provider),
or DataConfig::storage in Rust — then wipePersistedState() no longer
covers your documents. It clears the account directory of the default
provider, which your backend is not inside. Call DataStore.wipeAll() on
logout as well, or documents outlive the account that created them. wipeAll()
throws if the backend refused any delete, and that error is the only signal
that records survived the wipe: treat it as a failed logout rather than
logging it, because nothing inside the application will show the difference.
A wipe is only durable once replication has stopped. It is the same
missing tombstone described above: nothing distinguishes a space this device
wiped from one it has never seen. Called while the engine is still running
with live sessions, every document comes back, from both directions. The
peer's next version offer names the documents and they are recreated and
refilled from the peer's copy, and an offer of our own naming nothing reads to
the peer as a replica that has never seen the space, which it answers with all
of it. There is no error and no event; the documents simply return. On the
logout path this does not bite, because the engine is being torn down anyway.
Anywhere else, stop it before wiping, and read wipeAll() as clearing this
device rather than as deleting content.
Verify any custom backend with runStorageConformance(provider): an empty
failures array is the definition of supported. Reference adapters live in
examples/storage-adapters/.
Two things to know about the seam. Sealing covers record values, not
record keys: a backend sees {space}/{doc}, so treat space and document
names as metadata and do not put secrets in them. And switching backends
while documents are open migrates each open document into the new backend
before it returns, so if the migration cannot be written the switch fails and
nothing moves.
DataDisabled, DataStorageUnavailable, DocTooLarge and DataCorrupted
are appended to the error enum. Appended, so existing codes keep their
positions and no existing mapping changes — but a switch with an exhaustive
default may now reach it.
DocTooLarge is the one worth handling deliberately: a document is capped at
1 MiB compacted, with a data_doc_size_warning event at 768 KiB. When the cap
is passed the breaching change is still durable (refusing it would lose
work the user believed they had made), and deletions keep working while growth
is refused. So the recovery is: hear the warning, prune before the cap; and if
you hit it, delete content and the document resumes accepting edits.
data_changed and data_doc_size_warning. Both are ordinary events with no
one-shot semantics, so a listener that ignores unknown types needs no change.
data_changed fires after the change is durable, so a UI that re-renders
on it is rendering state that survives a restart.
The CRDT engine adds roughly 1.5 MB. Native crates.io consumers can drop it:
offline-protocol = { version = "0.24", default-features = false }The mobile artifact carries it either way: two binding flavors would mean a runtime FFI checksum mismatch rather than a build error, which is a worse failure than the bytes.
Nothing in v0.24.0 breaks a build. No binding method changed shape, no type
was removed, and no configuration key you already set means something else.
What changed is what a peer will be refused for, and what your application is
now responsible for scheduling.
RFC 9420 leaves the maximum total lifetime of a key package to the application, and this SDK defined none, so a package claiming a century was admitted and stayed usable for establishing new sessions until the century ran out. All three routes by which a key package this install did not mint is admitted now refuse a window wider than 90 days: the 1:1 import, every read of the contact cache, and a group invite.
For a fleet running this SDK, nothing changes. 90 days admits every key
package any released version has ever put on the wire, and a leaf node's 28
days clears it three times over. Closing the gap also fixed the other half:
this SDK was minting 84-day windows while documenting 30, because the builder
was never told a lifetime and OpenMLS applied its own default of three months
plus an hour. A package minted by v0.24.0 says 30 days because it is 30 days.
Interoperating with a non-OpenMLS stack is where this bites. mls-rs
defaults to a one-year lifetime, so a peer built on it is refused at import
with an InvalidKeyPackage naming both widths. If you talk to such a peer,
configure its key package lifetime at or under 90 days.
The cap is 90 rather than the bound OpenMLS declares because three months plus an hour is exactly what an unconfigured build emits: a cap set there would admit every package this SDK has ever minted with no margin, and refuse any peer whose skew allowance is a second wider.
The canonical signing payload bound the sender, the message id, the recipient
and the content, and nothing about time, so a frame recorded off the air
verified as well on its tenth delivery as on its first. offline-ctrl-v2 puts
the frame's timestamp inside the signature, under its own domain. Nothing on
the wire grows, and peers negotiate the payload through ctrl_versions in the
key package, so no application code changes and first contact still converges
in one round trip.
Two operational consequences are worth knowing before you deploy.
A new STALE_CONTROL_FRAME security warning reports the refusal. When it
appears across many peers at once, suspect the device's own clock first. The
timestamp is judged against local time, so a device whose clock is wrong reads
every correct peer as stale. Surface it the way you surface other security
warnings; do not treat a single occurrence as an attack.
security.control_freshness_enforced (controlFreshnessEnforced in the
bindings, default true) turns enforcement off without shipping a new binary.
It exists for a fleet whose clocks are wrong, and it gives back exactly the
replay this closed, so treat it as a recovery lever rather than a setting to
deploy on.
One configuration interaction: raising outbox_max_lifetime_ms above about
7.5 days means this device's own late retransmissions of signed control frames
can be refused as stale by the peer they finally reach. Ordinary messages are
unaffected, and the 7-day default sits well inside the window.
Post-compromise security arrives when a commit rotates a member's leaf in the ratchet tree, and this SDK originates one on a re-key. Nothing scheduled a re-key: one fired on an epoch desync, which is a fault rather than a cadence. A pair that never forked therefore never rotated, and the window a stolen key stays useful for was bounded by nothing.
let rotated = try protocol.rekeySession(peerId: peer)rekeySession / rekey_session exists on every binding and returns a boolean.
false is not a failure: it means the per-peer rate-limit window has not
lapsed, and a caller on a fixed schedule that briefly outruns the floor is
behaving correctly, so a later call succeeds. An error means the rotation did
not happen, and a rotation that fails changes nothing — the reset is
advertised before the local session is torn down, so the session is still
intact and still usable. Rotate while the peer is reachable and treat a failure
as "try again later".
The right cadence is yours, because what a rotation costs a mains-powered lock and a phone on a metered link are different answers and nothing on the wire distinguishes them. It matters most against a leaf node, which never commits at all, so every rotation in such a pair is the phone's to originate. Queued messages survive a rotation, sealed at flush time against whatever session is current then.
offline-protocol-leaf is new, and a constrained device that speaks this
protocol is a peer rather than a class of peer: the same frames, the same
envelope, the same trust gates. Four obligations sit with the integrator and
none of them is revealed by a passing build. Start at
docs/spec/leaf-provisioning.md, and read
docs/spec/ble-framing.md for the link itself.
EncryptedMessage::from_bytes, EncryptedMessage::from_base64 and
GroupId::new now return SealedError rather than MlsError, the envelope
codec having moved into the new offline-protocol-sealed crate. From<SealedError>
exists for both MlsError and the engine's Error and passes the inner
message through, so every rendered error string, every FFI error code and every
wire byte is what it was. Only code that matches on those functions' error type
directly needs a change.
Nothing in v0.24.1 changes shape. No method signature moved, no type was
added or removed, and the upgrade is a version bump. What changed is that a set
of React Native methods reached the iOS engine for the first time, so
configuration your application has been passing all along stops being
discarded. Read this before you roll it to a fleet, because a device can
start or stop doing something on this release that it has done, or not done,
since v0.22.0, and nothing in your code will have changed.
React Native on iOS only. Android was never affected: its dispatch is by method name and position, and the Kotlin side of every method named here was correct throughout. Applications consuming the Rust crates directly are not affected either.
updateRelayConfig and getRelayConfig were never declared in the bridge, so
from v0.22.0 onward every relay setting handed to create() was dropped on
iOS behind a console.warn and the engine kept RelayConfig's own defaults:
allowRelay: true, minBatteryForRelay: 30, relayPriority: 'auto'. The
direction of the surprise is the opposite of the usual one, because the
defaults are permissive. The settings that were ignored are the ones that
turn something off.
allowRelay: falsenever reached the engine, so the forwarding gate on an iOS device you meant to keep out of the path has been open sincev0.22.0, carrying other people's traffic whenever the mesh handed it any. It closes on this release. If some of your mesh's delivery has been leaning on those devices, that capacity goes away with it, so this is a topology change and not only a settings fix.allowRelay: trueis what the engine already had. Nothing changes.minBatteryForRelayabove 30 was ignored and the device relayed down to 30%; your stricter floor applies now. Below 30 it stopped earlier than you asked, and now goes as low as you set.relayPriorityinside the config block was dropped, butsetRelayPriority()is a separate bridge method that always resolved. A device configured through that call has been honouring it all along.
The battery feed the floor reads is live for the first time here too, and it
was not merely absent: setBatteryLevel received the bit pattern of an object
pointer, a large positive number that the surrounding min(100, max(0, …))
clamped to 100, so every call told the engine the device was fully charged.
setBatteryState was not in the bridge at all. Both carry the real charge now,
which means a device that stays on the relay path will drop off it when its
charge falls under the floor, where before the floor could never bite. If your
application never calls either method, wire one to the platform's charge
notifications before you rely on minBatteryForRelay at all.
wipePersistedState kept a pre-rename userId: parameter label and has been
uncallable on iOS since v0.21.0. It did not fail loudly; React Native could
not find the selector at module load and the JavaScript method was simply
absent, so a logout path that called it rejected with a TypeError naming a
function that is not one, which an unawaited call swallows entirely.
The consequence is on disk right now on every affected device: each account
signed out of since v0.21.0 left its MLS identity, sealed protocol state and
message store behind. The first successful wipePersistedState on this release
clears the account it is called for. It does not sweep the residue of
earlier accounts, which is keyed by (app_id, user_id) as
§10 describes, so if you need
those gone, enumerate them. A custom data-layer backend still owes
DataStore.wipeAll() alongside it, unchanged from
§16.
sendMessage, sendMessageRich and sendPresenceUpdate resolved and ran, but
the priority and status arguments arrived as the bit pattern of an object
pointer rather than the number, which fell through to the default: arm every
time. Both defaults are the innocuous-looking value, which is why nobody caught
it: every message an iOS build has sent went out medium, and every presence
update went out online, including the ones your application sent to say
away or offline. Peers were told the user was present whenever presence
was updated at all.
Both are honoured now, and no call site changes. Priority is what decides who
survives pressure rather than who goes first: a full pending-ACK table evicts a
lower-priority message to admit a higher-priority one, and the retry queue pops
the higher priority first among entries due at the same moment. Until this
release every iOS message competed as medium, so nothing marked critical
was protected and nothing marked low was given up first. The priority field
on ackEvicted starts carrying real values too.
forwardMessage is the one place a caller sees a difference in the
TypeScript's own behaviour: an omitted priority used to cross as null,
which React Native refused before the Swift method ran, hanging the promise
forever on debug builds. It now resolves to MessagePriority.Medium in
TypeScript, matching what sendMessage has always done and what the core
already inferred from an absent value. Callers who passed a priority are
unaffected, and release builds never hit the hang.
Twelve conversions from a JavaScript number array into bytes used a narrowing
initializer that traps rather than one that rejects, so any element outside
0...255 aborted the process rather than failing the call. These were live in
every release that shipped the method, and the transport ones are driven by
what arrives from a peer rather than by your own code: a malformed BLE
fragment, a Wi-Fi Direct or internet frame, an MLS ciphertext or Welcome, a key
package, or a file chunk. The remaining three aborted create() on an
initialTtl above 255, and create() or updateDorsConfig on a negative
DORS historyWindowSize.
All of them now reject or clamp, and the rejection surfaces as the promise failure your call site already handles. This is the reason to schedule the upgrade rather than fold it into your next feature release.
- Read
getRelayConfigback on an iOS device, which now returns what the engine holds rather than failing, and confirm it is the policy you meant. - Exercise your logout path on iOS once and verify the account directory is gone.
- If your application publishes
awayorofflinepresence, check what peers now see, because until this release they only ever sawonline. - If you persist or assert on message priority, re-check the iOS path, since it now carries the value you set.
Rust crates only, for the removals below. No binding and no configuration
key changes: none of what follows was ever reachable through the FFI, and the
React Native transports.reticulum section (daemonAddress, autoReconnect,
maxReconnectAttempts) is untouched and still read by the native managers.
One behaviour change, for anyone running a custom daemon. The bundled
managers now attach with contract v1: they
expect Identify to be answered with Challenge, sign and send
DeclareAddress, and announce the carrier only on the StatusUpdate(connected)
that follows a bound session. A daemon that speaks the earlier shape, Identify
answered by nothing in particular and StatusUpdate treated as advisory, gets a
transport that connects and never becomes available: the attach times out after
ten seconds and the manager reconnects on its ladder. The frames a daemon has to
send, and the security warnings that explain a refused or mismatched attach, are
in docs/reticulum.md.
| Removed | Surface |
|---|---|
ReticulumConfig and its four fields |
offline-protocol-transport |
ReticulumTransport::with_config, config() |
offline-protocol-transport |
ReticulumTransport::should_reconnect, increment_reconnect_attempts |
offline-protocol-transport |
ReticulumTransportBuilder |
offline-protocol-transport |
RETICULUM_CONNECTION_TIMEOUT_SECS, RETICULUM_MAX_PAYLOAD_SIZE |
offline_protocol_transport::constants |
Replace any construction with ReticulumTransport::new(device_id). There
is nothing to carry across, because nothing was reading the values. Reconnection
belongs to the platform bridge, which holds its own backoff and its own timeout
and takes them from the app's transport config; reconnect_delay had no reader
in any crate; and RETICULUM_MAX_PAYLOAD_SIZE was a declared intent that no
code path enforced, so treating it as a limit was already wrong.
If you set these fields expecting them to do something, the behaviour you
wanted was never there, and the values you want live in your app's
transports.reticulum configuration instead.
| Limit | Value | Where enforced |
|---|---|---|
| Message content | 256 KiB | send_message*, forward_message → InvalidArgument |
| Rich extras (serialized) | 32 KiB | send_message_with boundary |
| Recipient / app id length | 256 bytes | UserId / AppId |
| Pending queue, per peer | 64 messages / 2 MiB | eviction + message_failed |
| Pending queue, global | 4096 messages / 16 MiB | eviction + message_failed |
| Pending lifetime | 7 days (configurable) | pending_message_max_lifetime_ms |
| Outbox lifetime | 7 days (configurable) | outbox_max_lifetime_ms |
| One replicated document | 1 MiB compacted | DocTooLarge at commit, warning event at 768 KiB |
| One value in a document | 1 MiB | InvalidArgument at the operation |
| One protocol-state record | 4 MiB | refused on write, dropped on read |
| Provider transfer ceiling | 8 MiB | MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES, enforced inside each provider |
| Transport frame | 1 MiB | transport layer (unchanged) |
The 4 MiB / 8 MiB relationship is deliberate — the provider ceiling is a
superset of core's cap plus the seal envelope, and both halves are pinned by
tests (bounded_load_ceiling_is_a_superset_of_the_record_cap and
built_in_providers_mirror_the_transfer_ceiling, which reads the three provider
source files and asserts their literals).
| Situation | Rust | UniFFI / RN |
|---|---|---|
Recipient not a valid UserId |
Error::InvalidArgument |
ProtocolError.InvalidArgument |
| Content over 256 KiB | Error::InvalidArgument |
ProtocolError.InvalidArgument |
| Rejected reliability config | Error::InvalidConfiguration |
ProtocolError.InvalidConfiguration |
| Provider destroyed a record | ProtocolStateError::Corrupted |
MlsStorageError.CorruptedData |
| Provider read failed transiently | ProtocolStateError::LoadFailed |
MlsStorageError.LoadFailed |
| Provider cannot express absence | ProtocolStateError::NotFound |
MlsStorageError.KeyNotFound |
| Data layer off in config | Error::DataDisabled |
ProtocolError.DataDisabled |
initialize_mls has not run |
Error::DataStorageUnavailable |
ProtocolError.DataStorageUnavailable |
| Document past the 1 MiB cap | Error::DocTooLarge |
ProtocolError.DocTooLarge |
| Document bytes will not decode | Error::DataCorrupted |
ProtocolError.DataCorrupted |
| Bad space / document / collection name | Error::InvalidArgument |
ProtocolError.InvalidArgument |
| Single value over the 1 MiB value limit | Error::InvalidArgument |
ProtocolError.InvalidArgument |
Four ProtocolError variants were appended this release for the data layer
(DataDisabled, DataStorageUnavailable, DocTooLarge, DataCorrupted). The
taxonomy is append-only, so every code that existed before keeps the
discriminant it shipped with and no existing mapping changes.
- CHANGELOG — the full entry for this release, with the reasoning behind each fix
- MLS Integration — provider contracts, custom storage, protocol-state confidentiality
- Message Delivery — queue bounds, lifetimes, flush triggers
- Configuration — every parameter, including the fixed message-plane limits
- API Reference —
send_messageboundary rules,initialize_mls