Skip to main content

ping_core/
client.rs

1//! `MessagingClient` — top-level handle. Owns the OpenMLS provider, identity, local device,
2//! and the set of open conversations.
3//!
4//! All operations are `async`. The intent is that the FFI generators emit Swift `async`,
5//! Kotlin `suspend`, and the WASM glue exposes Promises.
6
7use openmls::framing::MlsMessageOut;
8use openmls::prelude::{
9    tls_codec::Serialize as TlsSerialize, BasicCredential, Ciphersuite, CredentialWithKey,
10    KeyPackageBuilder,
11};
12use openmls_basic_credential::SignatureKeyPair;
13use openmls_traits::OpenMlsProvider;
14use parking_lot::RwLock;
15use ping_mls_store::{PersistentMlsProvider, StorageBackend};
16use std::collections::HashMap;
17use std::sync::Arc;
18use zeroize::Zeroizing;
19
20use crate::{
21    codec,
22    conversation::{Conversation, ConversationId, ConversationMeta, MemberInfo},
23    device::{
24        CatchupAppEventEntry, CatchupConversationEntry, CatchupSnapshot, DeviceId, DeviceInfo,
25        LinkingTicket, LocalDevice, CATCHUP_SNAPSHOT_SOFT_CAP, CATCHUP_SNAPSHOT_VERSION,
26    },
27    error::{Error, Result},
28    identity::{Identity, UserId},
29    message::{IncomingMessage, MessageEnvelope, MessageKind},
30    storage::Storage,
31    sync::SyncCursor,
32    transport::Transport,
33};
34
35const DEFAULT_CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
36
37/// Whether a transport send failure is a DEFINITE server rejection (the server
38/// returned an HTTP error response) rather than an ambiguous network failure
39/// where the server may actually have applied the message.
40///
41/// This decides whether a staged Commit can be safely rolled back: only a
42/// definite rejection guarantees the server did NOT apply it. The host transport
43/// embeds the HTTP status in the error string (e.g. "network: http 409"); 4xx +
44/// known server error codes are definite, while timeouts / "fetch failed" / 5xx
45/// are ambiguous (could be a masked success). When unsure we treat it as
46/// ambiguous (return false) — merging on a masked success is recoverable, but
47/// rolling back a Commit the server DID apply strands us a step behind forever.
48fn is_definite_rejection(err: &Error) -> bool {
49    let Error::Transport(s) = err else {
50        return false;
51    };
52    let s = s.to_ascii_lowercase();
53    s.contains("http 4")
54        || s.contains("epoch_advanced")
55        || s.contains("invalid_request")
56        || s.contains("not_found")
57        || s.contains("conflict")
58        || s.contains("forbidden")
59        || s.contains("unauthorized")
60}
61
62/// Per-chat result reported by [`MessagingClient::admit_device_to_chats`].
63#[derive(Debug, Clone)]
64pub struct AdmitChatOutcome {
65    pub conversation_id: ConversationId,
66    pub status: AdmitChatStatus,
67}
68
69#[derive(Debug, Clone)]
70pub enum AdmitChatStatus {
71    /// The new device is now an MLS leaf in this chat. Both the Commit
72    /// and the addressed Welcome have been sent.
73    Admitted,
74    /// We chose not to admit (e.g. the conversation is a DeviceGroup,
75    /// which was already handled at linking-ticket build time).
76    Skipped { reason: String },
77    /// MLS or transport rejected the admission. `error` is the underlying
78    /// message — typically a `transport error: ...` or an OpenMLS error.
79    Failed { error: String },
80}
81
82#[derive(Debug)]
83pub struct ClientConfig {
84    pub identity: Identity,
85    pub device_label: String,
86    pub storage: Arc<dyn Storage>,
87    pub transport: Arc<dyn Transport>,
88    /// Wall clock in ms. Pulled from the host so we can use a synthetic clock in tests.
89    pub now_ms: u64,
90    /// [CR-4] OpenMLS-provider backend. Defaults to in-memory; iOS NSE and web SW
91    /// cold-start paths MUST pass `StorageBackend::Sqlite { path, encryption_key }`
92    /// (native) or `StorageBackend::IndexedDb { db_name }` (WASM, when that lands).
93    /// See `docs/design/CR4_CR7_PERSISTENCE.md`.
94    pub storage_backend: StorageBackend,
95    /// Optional 32-byte Ed25519 secret key the SDK should use as the
96    /// device signing key. When set AND no `LocalDevice` is yet
97    /// persisted in `storage`, the SDK constructs its first
98    /// `LocalDevice` from this key instead of generating a fresh
99    /// random one — so `device_id = SHA-256(public_key_of(secret))`
100    /// is fully determined by what the host provided.
101    ///
102    /// Use case: align the SDK's `device_id` (which it stamps into
103    /// every envelope's `sender_device` field) with an externally-
104    /// computed device id — typically `SHA-256(device_signing_pubkey)`
105    /// in the host's auth layer, where the JWT carries that same
106    /// value as its `device_id` claim. Without this alignment, a
107    /// server that validates `envelope.sender_device ==
108    /// jwt.device_id` would reject every send.
109    ///
110    /// Ignored on re-init (when storage already has a persisted
111    /// `LocalDevice`) so the device identity remains stable across
112    /// restarts.
113    pub device_signing_secret_key: Option<[u8; 32]>,
114}
115
116impl ClientConfig {
117    /// Construct a config with `StorageBackend::Memory` — convenient for tests and
118    /// the existing v0.1 in-memory flow.
119    pub fn new_in_memory(
120        identity: Identity,
121        device_label: String,
122        storage: Arc<dyn Storage>,
123        transport: Arc<dyn Transport>,
124        now_ms: u64,
125    ) -> Self {
126        Self {
127            identity,
128            device_label,
129            storage,
130            transport,
131            now_ms,
132            storage_backend: StorageBackend::Memory,
133            device_signing_secret_key: None,
134        }
135    }
136}
137
138pub struct MessagingClient {
139    pub(crate) identity: Identity,
140    pub(crate) local_device: LocalDevice,
141    pub(crate) crypto: Arc<PersistentMlsProvider>,
142    pub(crate) signing: Arc<SignatureKeyPair>,
143    pub(crate) storage: Arc<dyn Storage>,
144    pub(crate) transport: Arc<dyn Transport>,
145    conversations: RwLock<HashMap<ConversationId, Conversation>>,
146    /// Conversations detected as STRANDED during catch-up: a full page of
147    /// events was fetched but nothing could be applied (every envelope failed
148    /// to decrypt / was wrong-epoch), meaning a Commit was missed and the group
149    /// can no longer advance from local state. The host polls
150    /// [`MessagingClient::stranded_conversations`] after a sync and recovers
151    /// each (re-Welcome / same-user state snapshot). Cleared automatically once
152    /// the conversation makes progress again.
153    stranded: RwLock<std::collections::HashSet<ConversationId>>,
154}
155
156impl std::fmt::Debug for MessagingClient {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.debug_struct("MessagingClient")
159            .field("user_id", &self.identity.user_id().as_hex())
160            .field("device_id", &self.local_device.device_id.as_hex())
161            .field("conversation_count", &self.conversations.read().len())
162            .finish()
163    }
164}
165
166impl MessagingClient {
167    /// Initialise. Creates a new local device if none is recorded in storage; otherwise rehydrates.
168    pub async fn init(cfg: ClientConfig) -> Result<Arc<Self>> {
169        // [CR-4] OpenMLS provider is now pluggable. For `StorageBackend::Memory` this
170        // behaves like the old `OpenMlsRustCrypto::default()`. For `Sqlite`, the
171        // working set is hydrated from the on-disk blob; subsequent `checkpoint` calls
172        // flush it back. iOS NSE / web SW cold-start lives here.
173        //
174        // Use `open_async` so the WASM `StorageBackend::IndexedDb` variant can read
175        // its snapshot blob through the host-supplied `AsyncBlobStore` before
176        // returning — without this, the provider's `MemoryStorage` would be empty
177        // and `MlsGroup::load` would silently return `None` for every group on
178        // cold restart, breaking chat persistence across reloads. Native targets
179        // (Memory + Sqlite) delegate to the sync path under the hood, so the
180        // `.await` is free there.
181        let crypto = PersistentMlsProvider::open_async(cfg.storage_backend.clone())
182            .await
183            .map_err(|e| Error::Storage(format!("provider open: {e}")))?;
184        let local_device = match cfg.storage.get("device", "local").await? {
185            Some(bytes) => decode_local_device(&bytes, cfg.identity.user_id().clone())?,
186            None => {
187                // First-init path. If the host supplied a signing secret
188                // (typically to align the device_id with their auth
189                // layer), use it; otherwise mint a fresh random key.
190                // Either way, the constructed `LocalDevice` is
191                // immediately persisted so future inits load from
192                // storage without consulting the override again.
193                let dev = match cfg.device_signing_secret_key.as_ref() {
194                    Some(secret) => LocalDevice::from_signing_secret(
195                        cfg.identity.user_id().clone(),
196                        cfg.device_label,
197                        cfg.now_ms,
198                        secret,
199                    ),
200                    None => LocalDevice::generate(
201                        cfg.identity.user_id().clone(),
202                        cfg.device_label,
203                        cfg.now_ms,
204                    ),
205                };
206                let bytes = encode_local_device(&dev)?;
207                cfg.storage.put("device", "local", bytes).await?;
208                dev
209            }
210        };
211
212        // [CR-4] MLS signing keypair MUST be stable across cold restarts — otherwise the
213        // leaf-key stored on disk no longer matches the per-client key on re-init, and any
214        // send-after-restart silently misroutes. We derive deterministically from the
215        // already-persistent `LocalDevice::signing` (Ed25519, 32 raw bytes), and the
216        // ciphersuite's signature scheme is Ed25519 too — so the device signing key and the
217        // MLS leaf signing key are the same bytes. The MLS storage provider also receives
218        // a copy via `store()` so OpenMLS-internal lookups (process_message, etc.) succeed.
219        let signing = {
220            let sk_bytes = local_device.signing.to_bytes().to_vec();
221            let pk_bytes = local_device.signing.verifying_key().to_bytes().to_vec();
222            let kp = SignatureKeyPair::from_raw(
223                DEFAULT_CIPHERSUITE.signature_algorithm(),
224                sk_bytes,
225                pk_bytes,
226            );
227            kp.store(crypto.storage()).map_err(Error::mls)?;
228            Arc::new(kp)
229        };
230
231        let client = Arc::new(Self {
232            identity: cfg.identity,
233            local_device,
234            crypto,
235            signing,
236            storage: cfg.storage,
237            transport: cfg.transport,
238            conversations: RwLock::new(HashMap::new()),
239            stranded: RwLock::new(std::collections::HashSet::new()),
240        });
241
242        client.rehydrate_conversations(cfg.now_ms).await?;
243
244        // [CR-10] Ensure the DeviceGroup exists at init, not lazily inside
245        // build_linking_ticket. Single-device users need somewhere to write
246        // personal events (drafts, read pointers, notes, vault wrapper)
247        // even before they pair a second device. Lazy creation in
248        // build_linking_ticket left them with no DG → no place for
249        // personal state to land.
250        //
251        // Idempotent — re-init after a cold restart finds the DG via
252        // rehydrate_conversations and this becomes a no-op.
253        client.ensure_device_group(cfg.now_ms).await?;
254
255        Ok(client)
256    }
257
258    /// Open a client that can DECRYPT but never persists — the notification
259    /// preview path.
260    ///
261    /// The motivating case is the iOS Notification Service Extension: a second
262    /// process, woken per push, that has to turn one inbound envelope into a
263    /// sender and a message preview for the lock screen, while the main app
264    /// keeps running and keeps writing.
265    ///
266    /// [`init`](Self::init) cannot be used for that, and not merely as a matter
267    /// of taste. Persistence here checkpoints the ENTIRE MLS working set as one
268    /// blob (`ping_mls_store`), so two writers do not merge — whichever flushes
269    /// second replaces everything the other did, discarding epochs and ratchet
270    /// state wholesale. `init` also *creates* state (minting a `LocalDevice`,
271    /// ensuring the DeviceGroup exists), which is exactly the behaviour a
272    /// read-only opener must not have.
273    ///
274    /// So this constructor is the same hydration with every writing path
275    /// removed:
276    ///   * the storage backend MUST be read-only, checked here rather than
277    ///     trusted, so a caller cannot reach this code with a writable store;
278    ///   * a missing `LocalDevice` is an ERROR, never a fresh identity — this
279    ///     process joins an existing installation or it does nothing;
280    ///   * the DeviceGroup is not created. A reader that finds none simply has
281    ///     no DeviceGroup, which costs it nothing (personal-state sync is the
282    ///     app's job, not a notification's).
283    ///
284    /// The MLS signing keypair is still `store`d, but only into the in-memory
285    /// `MemoryStorage` the provider hands OpenMLS; with a read-only backend
286    /// that never reaches disk.
287    ///
288    /// Pair with [`preview_envelope`](Self::preview_envelope), and give it a
289    /// read-only host `Storage` too — this guards the MLS blob, not the host's
290    /// own key-value writes.
291    pub async fn open_read_only(cfg: ClientConfig) -> Result<Arc<Self>> {
292        let crypto = PersistentMlsProvider::open_async(cfg.storage_backend.clone())
293            .await
294            .map_err(|e| Error::Storage(format!("provider open: {e}")))?;
295        if !crypto.is_read_only() {
296            return Err(Error::Invalid(
297                "open_read_only requires StorageBackend::SqliteReadOnly".into(),
298            ));
299        }
300
301        let local_device = match cfg.storage.get("device", "local").await? {
302            Some(bytes) => decode_local_device(&bytes, cfg.identity.user_id().clone())?,
303            None => {
304                return Err(Error::Storage(
305                    "open_read_only: no LocalDevice in storage; \
306                     a read-only client never mints one"
307                        .into(),
308                ))
309            }
310        };
311
312        let signing = {
313            let sk_bytes = local_device.signing.to_bytes().to_vec();
314            let pk_bytes = local_device.signing.verifying_key().to_bytes().to_vec();
315            let kp = SignatureKeyPair::from_raw(
316                DEFAULT_CIPHERSUITE.signature_algorithm(),
317                sk_bytes,
318                pk_bytes,
319            );
320            kp.store(crypto.storage()).map_err(Error::mls)?;
321            Arc::new(kp)
322        };
323
324        let client = Arc::new(Self {
325            identity: cfg.identity,
326            local_device,
327            crypto,
328            signing,
329            storage: cfg.storage,
330            transport: cfg.transport,
331            conversations: RwLock::new(HashMap::new()),
332            stranded: RwLock::new(std::collections::HashSet::new()),
333        });
334
335        client.rehydrate_conversations(cfg.now_ms).await?;
336        Ok(client)
337    }
338
339    /// Decrypt ONE application envelope for display, changing nothing durable.
340    ///
341    /// The counterpart to [`open_read_only`](Self::open_read_only): applies the
342    /// envelope to this process's private copy of the MLS state and returns the
343    /// plaintext, with no flush of either the MLS blob or the host's key-value
344    /// store. The ratchet advance is deliberately thrown away — the owning
345    /// process still holds the un-advanced state and decrypts the same message
346    /// again, independently, when it next runs. Two readers of one snapshot;
347    /// neither can desynchronise the other.
348    ///
349    /// Handshake traffic returns `Ok(None)` without being processed. A Commit
350    /// moves the group to a new epoch and a Welcome joins one, and both are
351    /// state changes whose whole point is to be durable — applying either in a
352    /// process that discards its writes would burn the message for a preview
353    /// nobody could act on, and a self-removing Commit would tear down group
354    /// state the owner still needs. There is nothing to show for them anyway:
355    /// they carry no user-visible text.
356    ///
357    /// `Ok(None)` also covers "already applied" (the hydrated cursor rejects a
358    /// replay) and "not a conversation this device knows".
359    pub fn preview_envelope(
360        &self,
361        env: &MessageEnvelope,
362        now_ms: u64,
363    ) -> Result<Option<IncomingMessage>> {
364        debug_assert!(
365            self.crypto.is_read_only(),
366            "preview_envelope is only sound on a read-only provider"
367        );
368        if env.kind != MessageKind::Application {
369            return Ok(None);
370        }
371        if !self.conversations.read().contains_key(&env.conversation_id) {
372            return Ok(None);
373        }
374        let (out, _removed) = self.apply_envelope_in_memory(env, now_ms)?;
375        Ok(out)
376    }
377
378    /// [CR-10] Idempotently ensures this user's DeviceGroup exists in
379    /// `self.conversations`. Called from `init` (so single-device users
380    /// have a DG immediately) and from `build_linking_ticket` (the legacy
381    /// lazy path; still safe to call when the DG already exists, since
382    /// rehydrate_conversations would have re-attached it before init
383    /// returned).
384    ///
385    /// The DeviceGroup is a one-leaf MLS group at creation time —
386    /// `add_members` (called by `build_linking_ticket` when a second
387    /// device pairs in) is what grows it. We persist the snapshot so a
388    /// cold restart picks it up before this function runs again.
389    pub(crate) async fn ensure_device_group(self: &Arc<Self>, now_ms: u64) -> Result<()> {
390        let dg_id = device_group_id_for(self.identity.user_id());
391        if self.conversations.read().contains_key(&dg_id) {
392            return Ok(());
393        }
394        let mut new_dg = Conversation::create(
395            dg_id,
396            Some("device-group".into()),
397            self.local_device.device_id.clone(),
398            self.identity.user_id(),
399            self.crypto.clone(),
400            self.signing.clone(),
401            self.storage.clone(),
402            now_ms,
403        )?;
404        new_dg.meta.is_device_group = true;
405        new_dg.snapshot_to_storage().await?;
406        self.conversations.write().insert(dg_id, new_dg);
407        Ok(())
408    }
409
410    pub fn user_id(&self) -> UserId {
411        self.identity.user_id().clone()
412    }
413    /// Export this client's account identity (the Ed25519 seed, CBOR-wrapped —
414    /// same format `Identity::import` / `MessagingClient::init(identity_export)`
415    /// accept). SECRET. Hosts use this to TRANSFER the account identity to a
416    /// newly-linked device over the sealed linking channel, so every linked
417    /// device shares ONE `user_id` and `IncomingMessage.sender_user_id` equals
418    /// the local `user_id()` for any of the account's own devices — the basis
419    /// for cross-device self-attribution. Never log or persist in cleartext.
420    pub fn export_identity(&self) -> Zeroizing<Vec<u8>> {
421        self.identity.export()
422    }
423    pub fn device_id(&self) -> DeviceId {
424        self.local_device.device_id.clone()
425    }
426    pub fn device_info(&self, now_ms: u64) -> DeviceInfo {
427        self.local_device.info(now_ms)
428    }
429
430    /// Generate a fresh KeyPackage to publish to the directory. Hosts call this when registering
431    /// a device or topping up the directory.
432    ///
433    /// `build()` writes the private init + encryption keys into the storage
434    /// provider's working set, but ON ITS OWN that write is NOT durable: on the
435    /// WASM/AsyncBlob backend the working set only reaches IndexedDB at the next
436    /// `checkpoint_async`, so a page reload before the next state-changing op
437    /// loses the private keys while the PUBLIC KeyPackage has already been
438    /// published. Any Welcome later bound to that KeyPackage then fails with
439    /// "No matching key package was found in the key store" (breaking calls and
440    /// every invite to this device). So we checkpoint HERE, before returning the
441    /// bytes the host will publish — the published KeyPackage is durable the
442    /// instant it leaves this function. Hence `async`.
443    pub async fn fresh_key_package(&self) -> Result<Vec<u8>> {
444        self.build_key_package(false).await
445    }
446
447    /// Generate a fresh LAST-RESORT KeyPackage.
448    ///
449    /// A normal KeyPackage is single-use: once a Welcome consumes it, the
450    /// private init key is deleted and the directory entry is burned. When a
451    /// device's published pool runs dry, every invite to that device hard-fails
452    /// ("user unavailable") until it comes online and tops up — the classic
453    /// "I added them but they never got it" complaint.
454    ///
455    /// A last-resort KeyPackage (RFC 9420 §10) carries the `LastResort`
456    /// extension, signalling the server it may serve this KeyPackage MORE THAN
457    /// ONCE when no single-use KeyPackages remain. The host publishes exactly
458    /// one per device; the server keeps it as the always-available fallback so
459    /// an invite never fails purely because the pool emptied. Forward secrecy
460    /// for the joining epoch is slightly weaker (the init key is reused until
461    /// replenishment), which is the accepted RFC trade-off for availability.
462    pub async fn fresh_last_resort_key_package(&self) -> Result<Vec<u8>> {
463        self.build_key_package(true).await
464    }
465
466    async fn build_key_package(&self, last_resort: bool) -> Result<Vec<u8>> {
467        let credential_with_key = CredentialWithKey {
468            credential: BasicCredential::new(self.identity.user_id().0.clone()).into(),
469            signature_key: self.signing.public().to_vec().into(),
470        };
471        let mut builder = KeyPackageBuilder::new()
472            // Advertise the group-name extension capability so a later
473            // `set_name` (rename / avatar change via GroupContextExtensions)
474            // passes openmls' per-member capability check on every group this
475            // device joins. For a last-resort KeyPackage the leaf must also
476            // advertise the LastResort extension it carries, or openmls
477            // validation rejects the KeyPackage at add-member time. See
478            // `conversation::ping_leaf_capabilities_for`.
479            .leaf_node_capabilities(crate::conversation::ping_leaf_capabilities_for(last_resort));
480        if last_resort {
481            builder = builder.mark_as_last_resort();
482        }
483        let bundle = builder
484            .build(
485                DEFAULT_CIPHERSUITE,
486                self.crypto.as_ref(),
487                self.signing.as_ref(),
488                credential_with_key,
489            )
490            .map_err(Error::mls)?;
491        // Durably persist the freshly-generated private keys BEFORE the public
492        // KeyPackage is handed to the host to publish (see doc comment).
493        self.crypto
494            .checkpoint_async()
495            .await
496            .map_err(|e| Error::Storage(format!("key package checkpoint: {e}")))?;
497        // KeyPackages are serialized as MlsMessage(KeyPackage) per the MLS framing spec.
498        let msg: MlsMessageOut = bundle.key_package().clone().into();
499        msg.tls_serialize_detached().map_err(Error::mls)
500    }
501
502    /// Create a new conversation owned by this client (and seeded with a single member: this device).
503    pub async fn create_conversation(
504        self: &Arc<Self>,
505        name: Option<String>,
506        now_ms: u64,
507    ) -> Result<ConversationId> {
508        self.create_conversation_with_id(ConversationId::new(), name, now_ms)
509            .await
510    }
511
512    /// Create an ephemeral per-call MLS group. Identical to
513    /// [`create_conversation`] except the id carries the `0xFF 0xCC` call-group
514    /// sentinel (see [`ConversationId::new_call_group`]), so every device — the
515    /// callee joining via a name-stripped Welcome, a freshly-linked sibling —
516    /// recognises it as a call group by its id alone and keeps it out of the chat
517    /// list, with no dependence on the (creator-local) `call:` name or a
518    /// per-device registry. Hosts MUST mint call groups via this method rather
519    /// than `create_conversation(name: "call:…")` to get the structural guarantee.
520    pub async fn create_call_conversation(
521        self: &Arc<Self>,
522        name: Option<String>,
523        now_ms: u64,
524    ) -> Result<ConversationId> {
525        self.create_conversation_with_id(ConversationId::new_call_group(), name, now_ms)
526            .await
527    }
528
529    async fn create_conversation_with_id(
530        self: &Arc<Self>,
531        id: ConversationId,
532        name: Option<String>,
533        now_ms: u64,
534    ) -> Result<ConversationId> {
535        let convo = Conversation::create(
536            id,
537            name,
538            self.local_device.device_id.clone(),
539            self.identity.user_id(),
540            self.crypto.clone(),
541            self.signing.clone(),
542            self.storage.clone(),
543            now_ms,
544        )?;
545        convo.snapshot_to_storage().await?;
546        self.conversations.write().insert(id, convo);
547        Ok(id)
548    }
549
550    /// Join via a Welcome bundled in a [`MessageEnvelope`] of kind `Welcome`.
551    pub async fn join_conversation(
552        self: &Arc<Self>,
553        welcome_envelope: &MessageEnvelope,
554        now_ms: u64,
555    ) -> Result<ConversationId> {
556        if welcome_envelope.kind != MessageKind::Welcome {
557            return Err(Error::Invalid("expected Welcome envelope".into()));
558        }
559        let convo = Conversation::join(
560            &welcome_envelope.payload,
561            self.local_device.device_id.clone(),
562            self.crypto.clone(),
563            self.signing.clone(),
564            self.storage.clone(),
565            now_ms,
566        )?;
567        let id = convo.id();
568        convo.snapshot_to_storage().await?;
569        self.conversations.write().insert(id, convo);
570        // Joining (re-Welcome) recovers a previously-stranded conversation.
571        self.stranded.write().remove(&id);
572        Ok(id)
573    }
574
575    /// Conversations detected as STRANDED during catch-up — a Commit was missed
576    /// and the group can no longer advance from local state. The host should
577    /// recover each (re-Welcome from a peer, or a same-user state-snapshot
578    /// import) so messages start delivering again. The set self-clears as a
579    /// conversation makes progress or is re-joined.
580    pub fn stranded_conversations(&self) -> Vec<ConversationId> {
581        let mut ids: Vec<ConversationId> = self.stranded.read().iter().copied().collect();
582        ids.sort_by_key(|a| a.0);
583        ids
584    }
585
586    pub fn list_conversations(&self) -> Vec<ConversationMeta> {
587        self.conversations
588            .read()
589            .values()
590            .map(|c| c.meta.clone())
591            .collect()
592    }
593
594    /// Member roster for a conversation, recovered locally from the MLS
595    /// group's leaf credentials. Empty if the conversation is unknown to
596    /// this client. Lets any device (including one that just joined via a
597    /// linking Welcome) resolve a 1:1 peer's `UserId` without the
598    /// out-of-band `ping.profile` re-send.
599    pub fn members(&self, conv_id: ConversationId) -> Vec<MemberInfo> {
600        self.conversations
601            .read()
602            .get(&conv_id)
603            .map(|c| c.members())
604            .unwrap_or_default()
605    }
606
607    /// Send an application message. Returns once the envelope has been handed to the transport.
608    pub async fn send(
609        &self,
610        conv_id: ConversationId,
611        plaintext: Vec<u8>,
612        now_ms: u64,
613    ) -> Result<MessageEnvelope> {
614        // FAST-FAIL on a stranded conversation. A missed Commit forked our epoch,
615        // so anything we encrypt now is at a stale epoch: the server either 409s
616        // it (epoch occupied) or accepts a frame the peers can never decrypt — a
617        // durable-but-undelivered message. Surface `EpochStranded` immediately so
618        // the host renders "needs repair" (and recovers via
619        // `stranded_conversations()` → re-Welcome / snapshot import, which clears
620        // the mark) instead of silently 409-looping every send. Without this the
621        // SDK knew the conversation was forked but sent anyway.
622        if self.stranded.read().contains(&conv_id) {
623            return Err(Error::EpochStranded(conv_id.as_hex()));
624        }
625        let envelope = {
626            let mut guard = self.conversations.write();
627            let convo = guard
628                .get_mut(&conv_id)
629                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
630            convo.send_application(&plaintext, now_ms)?
631        };
632        self.transport.send(envelope.clone()).await?;
633        // The OpenMLS sender ratchet advances on every Application message — `seq` + `hlc`
634        // are bumped on the conversation, and the underlying group keystore stores new
635        // generation keys. Without a checkpoint here, a reload rolls back to the pre-send
636        // state and the next send re-uses an already-consumed generation that receivers
637        // silently drop. Mirrors the snapshot calls after every Commit/Welcome op.
638        //
639        // Capture the snapshot inputs UNDER the read guard, then DROP the
640        // guard (end of the `let` statement) before the async flush — never
641        // hold a `parking_lot` guard across `.await` (see
642        // `Conversation::snapshot_inputs`).
643        let snap = self
644            .conversations
645            .read()
646            .get(&conv_id)
647            .map(|c| c.snapshot_inputs())
648            .transpose()?;
649        if let Some(snap) = snap {
650            snap.flush().await?;
651        }
652        Ok(envelope)
653    }
654
655    /// Add members. The Commit goes on the wire; the Welcome should be delivered to the new
656    /// devices' inboxes (the host transport implements that — typically as a separate addressed
657    /// envelope).
658    ///
659    /// [CR-2] Each entry is `(DeviceId, KeyPackage_bytes)`. The host typically gets the
660    /// device_id from the directory at the same time it gets the KeyPackage; we use it to
661    /// record a per-conversation `device_id → leaf_index` map so [`Self::revoke_device`]
662    /// can later locate the leaf without a fresh directory lookup. The SDK does not
663    /// cryptographically verify the host's device-id claim — that's a directory policy
664    /// concern.
665    //
666    // The `conversations` lock is taken only for the SYNCHRONOUS MLS work
667    // (the add commit) and the synchronous snapshot capture, then dropped
668    // BEFORE every `.await`. We must never hold a `parking_lot` guard
669    // across an await — see `Conversation::snapshot_inputs` for why (the
670    // single-threaded wasm worker would panic in `parking_lot`'s parker
671    // stub). `parking_lot/send_guard` is still set so any guard that DOES
672    // briefly cross a yield-free boundary stays `Send`.
673    pub async fn add_members(
674        &self,
675        conv_id: ConversationId,
676        entries: Vec<(DeviceId, Vec<u8>)>,
677        now_ms: u64,
678    ) -> Result<()> {
679        // Phase 1 — stage the Commit WITHOUT merging (local epoch unchanged).
680        let staged = {
681            let mut guard = self.conversations.write();
682            let convo = guard
683                .get_mut(&conv_id)
684                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
685            convo.stage_add_members(entries, now_ms)?
686        };
687
688        // Phase 2 — send the Commit FIRST, then merge only if the server accepts
689        // it (send-then-merge). A Commit the server REJECTS is rolled back, so the
690        // local epoch can never run ahead of the server — the desync that
691        // permanently bricks a group (every later Commit 409s; peers can't decrypt
692        // our epoch). A network failure with NO response is ambiguous (the server
693        // may have applied it), so there we merge to match a possible masked
694        // success rather than strand ourselves a step behind.
695        if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
696            let merged = {
697                let mut guard = self.conversations.write();
698                match guard.get_mut(&conv_id) {
699                    Some(convo) if is_definite_rejection(&send_err) => {
700                        let _ = convo.abort_staged();
701                        false
702                    }
703                    Some(convo) => {
704                        convo.confirm_staged(&staged, now_ms)?;
705                        true
706                    }
707                    None => false,
708                }
709            };
710            if merged {
711                self.flush_conversation(&conv_id).await?;
712            }
713            return Err(send_err);
714        }
715
716        // Phase 3 — Commit accepted: merge locally + persist (so the advanced
717        // epoch survives a crash even if the Welcome below fails).
718        {
719            let mut guard = self.conversations.write();
720            let convo = guard
721                .get_mut(&conv_id)
722                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
723            convo.confirm_staged(&staged, now_ms)?;
724        }
725        self.flush_conversation(&conv_id).await?;
726
727        // Phase 4 — deliver the Welcome to the new members. Best-effort: they are
728        // in the group server-side now; a failed Welcome is recoverable
729        // (re-invite) and must NOT roll back the merged Commit.
730        if let Some(welcome) = staged.welcome {
731            self.transport.send(welcome).await?;
732        }
733        Ok(())
734    }
735
736    /// Change a conversation's `name` (carried in the GroupContext) and broadcast
737    /// the change to every member as an MLS GroupContextExtensions Commit. Unlike
738    /// a hydration broadcast, the new name rides MLS group STATE, so every member
739    /// — and every future joiner via the GroupInfo — converges on it. Hosts use
740    /// this to make a rename or an embedded avatar-media-id change bulletproof
741    /// (the `name` carries the `ping:meta:v1:` blob).
742    ///
743    /// No Welcome (membership is unchanged). Uses the same send-then-merge
744    /// rollback discipline as [`Self::add_members`] so a server-rejected Commit
745    /// never desyncs the local epoch. All members must have re-linked since the
746    /// group-name capability shipped (see `conversation::ping_leaf_capabilities`),
747    /// else openmls rejects the Commit.
748    pub async fn set_conversation_name(
749        &self,
750        conv_id: ConversationId,
751        name: Option<String>,
752        now_ms: u64,
753    ) -> Result<()> {
754        // Phase 1 — stage the Commit WITHOUT merging (local epoch unchanged).
755        let staged = {
756            let mut guard = self.conversations.write();
757            let convo = guard
758                .get_mut(&conv_id)
759                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
760            convo.stage_set_name(name, now_ms)?
761        };
762
763        // Phase 2 — send-then-merge (see `add_members` for the rollback rationale).
764        if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
765            let merged = {
766                let mut guard = self.conversations.write();
767                match guard.get_mut(&conv_id) {
768                    Some(convo) if is_definite_rejection(&send_err) => {
769                        let _ = convo.abort_staged();
770                        false
771                    }
772                    Some(convo) => {
773                        convo.confirm_staged(&staged, now_ms)?;
774                        true
775                    }
776                    None => false,
777                }
778            };
779            if merged {
780                self.flush_conversation(&conv_id).await?;
781            }
782            return Err(send_err);
783        }
784
785        // Phase 3 — Commit accepted: merge locally + persist.
786        {
787            let mut guard = self.conversations.write();
788            let convo = guard
789                .get_mut(&conv_id)
790                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
791            convo.confirm_staged(&staged, now_ms)?;
792        }
793        self.flush_conversation(&conv_id).await?;
794        Ok(())
795    }
796
797    /// Snapshot + flush a conversation's persistable state. Captures the snapshot
798    /// synchronously under the read guard, drops the guard, then awaits the flush
799    /// (never hold a `parking_lot` guard across an await — wasm parker panics).
800    async fn flush_conversation(&self, conv_id: &ConversationId) -> Result<()> {
801        let snap = self
802            .conversations
803            .read()
804            .get(conv_id)
805            .map(|c| c.snapshot_inputs())
806            .transpose()?;
807        if let Some(snap) = snap {
808            snap.flush().await?;
809        }
810        Ok(())
811    }
812
813    /// Admits `new_device_id` to every conversation in `kps_per_chat` via
814    /// the standard MLS `add_members` flow — one Commit + one Welcome per
815    /// chat. This is the SDK-side replacement for the host's previous
816    /// per-chat reconciler loop after device linking; centralising it
817    /// here means iOS/Android/web hosts all share the orchestration and
818    /// the transport's Welcome-recipient priming is automatic.
819    ///
820    /// Inputs:
821    /// - `new_device_id`: the device being admitted (matches the
822    ///   `device_binding_sig` recipient in the linking ticket).
823    /// - `kps_per_chat`: one freshly-claimed KeyPackage per chat. The
824    ///   host claims these via the auth-layer's per-account KP pool
825    ///   (`GET /v1/devices/{accountId}`) AFTER the new device's
826    ///   bootstrap has uploaded its KP batch.
827    /// - `now_ms`: wall-clock used to stamp HLCs on the emitted
828    ///   envelopes.
829    ///
830    /// Per-chat failures (unknown conversation, MLS error, transport
831    /// error, etc.) are CAPTURED in the returned vec rather than
832    /// short-circuiting the whole call — losing one chat shouldn't
833    /// strand the new device on every other chat. The caller decides
834    /// whether to retry the failed entries (e.g. with a fresh KP).
835    pub async fn admit_device_to_chats(
836        &self,
837        new_device_id: DeviceId,
838        kps_per_chat: Vec<(ConversationId, Vec<u8>)>,
839        now_ms: u64,
840    ) -> Result<Vec<AdmitChatOutcome>> {
841        let mut outcomes = Vec::with_capacity(kps_per_chat.len());
842        for (conv_id, kp_bytes) in kps_per_chat {
843            // Belt-and-braces: skip the DeviceGroup. The DG was already
844            // welcomed via the linking ticket — re-adding the new
845            // device there would produce a duplicate-add Commit that
846            // BE de-dups, but the noise is avoidable.
847            let is_dg = self
848                .conversations
849                .read()
850                .get(&conv_id)
851                .map(|c| c.meta().is_device_group)
852                .unwrap_or(false);
853            if is_dg {
854                outcomes.push(AdmitChatOutcome {
855                    conversation_id: conv_id,
856                    status: AdmitChatStatus::Skipped {
857                        reason: "device_group".to_string(),
858                    },
859                });
860                continue;
861            }
862
863            // Prime the host transport with the welcome recipient BEFORE
864            // we mutate MLS state. If priming fails (non-web hosts use
865            // the default no-op), continue — the host's transport will
866            // either route some other way or surface a 4xx on the
867            // welcome send and we'll catch it below.
868            let _ = self
869                .transport
870                .set_next_welcome_recipients(conv_id, vec![new_device_id.clone()])
871                .await;
872
873            let entry = (new_device_id.clone(), kp_bytes);
874            let outcome_result = {
875                let mut guard = self.conversations.write();
876                match guard.get_mut(&conv_id) {
877                    Some(convo) => convo.add_members(vec![entry], now_ms),
878                    None => Err(Error::UnknownConversation(conv_id.as_hex())),
879                }
880            };
881
882            let outcome = match outcome_result {
883                Ok(o) => o,
884                Err(e) => {
885                    outcomes.push(AdmitChatOutcome {
886                        conversation_id: conv_id,
887                        status: AdmitChatStatus::Failed {
888                            error: e.to_string(),
889                        },
890                    });
891                    continue;
892                }
893            };
894
895            if let Err(e) = self.transport.send(outcome.commit).await {
896                outcomes.push(AdmitChatOutcome {
897                    conversation_id: conv_id,
898                    status: AdmitChatStatus::Failed {
899                        error: format!("commit send: {e}"),
900                    },
901                });
902                continue;
903            }
904            if let Err(e) = self.transport.send(outcome.welcome).await {
905                outcomes.push(AdmitChatOutcome {
906                    conversation_id: conv_id,
907                    status: AdmitChatStatus::Failed {
908                        error: format!("welcome send: {e}"),
909                    },
910                });
911                continue;
912            }
913
914            // Capture the snapshot under the read guard, drop it, then
915            // flush async (never hold the lock across `.await`).
916            let snap_result = self
917                .conversations
918                .read()
919                .get(&conv_id)
920                .map(|c| c.snapshot_inputs())
921                .transpose();
922            let flush_result = match snap_result {
923                Ok(Some(snap)) => snap.flush().await,
924                Ok(None) => Ok(()),
925                Err(e) => Err(e),
926            };
927            if let Err(e) = flush_result {
928                // Snapshot failure is non-fatal for the join — the MLS adds
929                // already shipped — but record it so the host can decide
930                // whether to retry. The next successful send/process will
931                // re-snapshot anyway.
932                outcomes.push(AdmitChatOutcome {
933                    conversation_id: conv_id,
934                    status: AdmitChatStatus::Failed {
935                        error: format!("snapshot: {e}"),
936                    },
937                });
938                continue;
939            }
940
941            outcomes.push(AdmitChatOutcome {
942                conversation_id: conv_id,
943                status: AdmitChatStatus::Admitted,
944            });
945        }
946        Ok(outcomes)
947    }
948
949    pub async fn remove_members(
950        &self,
951        conv_id: ConversationId,
952        leaf_indexes: Vec<u32>,
953        now_ms: u64,
954    ) -> Result<()> {
955        // Send-then-merge — see `add_members` for the full rationale.
956        let staged = {
957            let mut guard = self.conversations.write();
958            let convo = guard
959                .get_mut(&conv_id)
960                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
961            convo.stage_remove_members(leaf_indexes, now_ms)?
962        };
963
964        if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
965            let merged = {
966                let mut guard = self.conversations.write();
967                match guard.get_mut(&conv_id) {
968                    Some(convo) if is_definite_rejection(&send_err) => {
969                        let _ = convo.abort_staged();
970                        false
971                    }
972                    Some(convo) => {
973                        convo.confirm_staged(&staged, now_ms)?;
974                        true
975                    }
976                    None => false,
977                }
978            };
979            if merged {
980                self.flush_conversation(&conv_id).await?;
981            }
982            return Err(send_err);
983        }
984
985        {
986            let mut guard = self.conversations.write();
987            let convo = guard
988                .get_mut(&conv_id)
989                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
990            convo.confirm_staged(&staged, now_ms)?;
991        }
992        self.flush_conversation(&conv_id).await?;
993        Ok(())
994    }
995
996    /// Re-admit a device, first evicting any existing leaf that duplicates the
997    /// new KeyPackage's signature key (the phrase-restore case — see
998    /// [`Conversation::duplicate_signature_key_leaves`] and
999    /// `docs/specs/re-admit-device.md`). Equivalent to [`Self::add_members`] when
1000    /// there is no duplicate, so it is a strict superset — safe to prefer on the
1001    /// recovery re-admission path.
1002    ///
1003    /// It composes the two conformance-tested membership primitives —
1004    /// `remove_members` (evict the dead duplicate leaf, freeing its signing key)
1005    /// then `add_members` (admit the fresh device + ship its Welcome) — each with
1006    /// its own send-then-merge rollback, so a server-rejected Commit never leaves
1007    /// the local epoch ahead of the server. Two Commits on the rare recovery path;
1008    /// folding them into a single combined Remove+Add commit is a possible future
1009    /// optimization (kept out of scope to reuse already-vetted primitives). If the
1010    /// remove succeeds but the add fails, the device is simply un-admitted (no
1011    /// worse than before) and the caller retries.
1012    pub async fn re_admit_device(
1013        &self,
1014        conv_id: ConversationId,
1015        entry: (DeviceId, Vec<u8>),
1016        now_ms: u64,
1017    ) -> Result<()> {
1018        let dup_leaves = {
1019            let guard = self.conversations.read();
1020            let convo = guard
1021                .get(&conv_id)
1022                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1023            convo.duplicate_signature_key_leaves(&entry.1)?
1024        };
1025        if !dup_leaves.is_empty() {
1026            self.remove_members(conv_id, dup_leaves, now_ms).await?;
1027        }
1028        self.add_members(conv_id, vec![entry], now_ms).await?;
1029        Ok(())
1030    }
1031
1032    /// Leave a conversation. Broadcasts a self-Remove PROPOSAL (MLS doesn't
1033    /// allow committing your own removal — a remaining member commits it via
1034    /// [`Self::commit_pending_proposals`]). After this returns, the host should
1035    /// delete the conversation locally; the leaver remains a cryptographic
1036    /// member only until a peer commits the proposal, at which point the server
1037    /// stops delivering to this device.
1038    pub async fn leave_conversation(&self, conv_id: ConversationId, now_ms: u64) -> Result<()> {
1039        let proposal = {
1040            let mut guard = self.conversations.write();
1041            let convo = guard
1042                .get_mut(&conv_id)
1043                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1044            convo.leave_group(now_ms)?
1045        };
1046        // A proposal doesn't change the epoch, so there's nothing to roll back
1047        // on a send failure — surface the error and let the host retry.
1048        self.transport.send(proposal).await?;
1049        self.flush_conversation(&conv_id).await?;
1050        Ok(())
1051    }
1052
1053    /// Drop a conversation's ENTIRE local state with **no** network side effect.
1054    ///
1055    /// Unlike [`Self::leave_conversation`] (which broadcasts a self-Remove
1056    /// proposal so a remaining member evicts you), this is a purely LOCAL
1057    /// teardown for a group the server does not back — e.g. an invite that minted
1058    /// the MLS group locally but never completed server-side, so the backend
1059    /// 404s `fetchSince` / 403s the member roster for it. The host detects that
1060    /// authoritative "not a member" verdict and calls this so the dead group
1061    /// stops rehydrating on every restart (and re-materialising as a ghost
1062    /// conversation). No envelope is sent — there is no live group to send to.
1063    ///
1064    /// Deletes the OpenMLS group state AND the host-side snapshot rows
1065    /// (`groups/{id}/…`, `cursors/{id}`, `device_leaves/{id}`) that
1066    /// [`Self::rehydrate_conversations`] would otherwise reload, then drops the
1067    /// in-memory handle + `stranded` marker. Idempotent: an unknown id still
1068    /// best-effort purges any orphan storage rows, and calling it twice is safe.
1069    pub async fn drop_conversation_local(&self, conv_id: ConversationId) -> Result<()> {
1070        // Take the handle OUT of the map first, then delete its OpenMLS state via
1071        // the owned value — mirrors the self-removal teardown in
1072        // `process_envelope` and keeps no lock across the awaits below.
1073        let existing = self.conversations.write().remove(&conv_id);
1074        if let Some(mut convo) = existing {
1075            if let Err(e) = convo.delete_group_state() {
1076                // Best-effort: dropping the in-memory handle + purging the
1077                // snapshot rows below already makes the group non-rehydratable.
1078                tracing::warn!(error = %e, "drop_conversation_local: delete group state failed");
1079            }
1080        }
1081        self.stranded.write().remove(&conv_id);
1082
1083        // Purge the host-side snapshot rows so a restart's
1084        // `rehydrate_conversations` (which walks `groups/{id}/meta`) can't bring
1085        // the group back. Best-effort — deleting an absent key is a no-op on
1086        // every backend. Sweep the whole `groups/{id}/` prefix so any future
1087        // sub-key is covered, then the known `cursors` + `device_leaves` rows.
1088        let hex = conv_id.as_hex();
1089        if let Ok(keys) = self.storage.list_keys("groups", &format!("{hex}/")).await {
1090            for k in keys {
1091                let _ = self.storage.delete("groups", &k).await;
1092            }
1093        }
1094        let _ = self.storage.delete("cursors", &hex).await;
1095        let _ = self.storage.delete("device_leaves", &hex).await;
1096        Ok(())
1097    }
1098
1099    /// Conversations with buffered pending proposals (e.g. a peer's leave
1100    /// proposal awaiting a Commit). The host polls this after a sync and, if it
1101    /// is the designated committer, calls [`Self::commit_pending_proposals`] to
1102    /// evict the leaver. Sorted for determinism.
1103    pub fn conversations_with_pending_proposals(&self) -> Vec<ConversationId> {
1104        let guard = self.conversations.read();
1105        let mut ids: Vec<ConversationId> = guard
1106            .iter()
1107            .filter(|(_, c)| c.has_pending_proposals())
1108            .map(|(id, _)| *id)
1109            .collect();
1110        ids.sort_by_key(|a| a.0);
1111        ids
1112    }
1113
1114    /// Commit all buffered pending proposals for a conversation (evicts a peer
1115    /// who left). No-op (Ok) when nothing is pending. Send-then-merge with
1116    /// rollback like add/remove so a server-rejected Commit doesn't desync the
1117    /// epoch — on an `epoch_advanced` rejection the host should re-sync (another
1118    /// member already committed) and the pending proposal will have cleared.
1119    pub async fn commit_pending_proposals(
1120        &self,
1121        conv_id: ConversationId,
1122        now_ms: u64,
1123    ) -> Result<()> {
1124        let staged = {
1125            let mut guard = self.conversations.write();
1126            let convo = guard
1127                .get_mut(&conv_id)
1128                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1129            match convo.stage_commit_pending_proposals(now_ms)? {
1130                Some(s) => s,
1131                None => return Ok(()),
1132            }
1133        };
1134
1135        if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
1136            let merged = {
1137                let mut guard = self.conversations.write();
1138                match guard.get_mut(&conv_id) {
1139                    Some(convo) if is_definite_rejection(&send_err) => {
1140                        let _ = convo.abort_staged();
1141                        false
1142                    }
1143                    Some(convo) => {
1144                        convo.confirm_staged(&staged, now_ms)?;
1145                        true
1146                    }
1147                    None => false,
1148                }
1149            };
1150            if merged {
1151                self.flush_conversation(&conv_id).await?;
1152            }
1153            return Err(send_err);
1154        }
1155
1156        {
1157            let mut guard = self.conversations.write();
1158            let convo = guard
1159                .get_mut(&conv_id)
1160                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1161            convo.confirm_staged(&staged, now_ms)?;
1162        }
1163        self.flush_conversation(&conv_id).await?;
1164        Ok(())
1165    }
1166
1167    /// Process an inbound envelope coming from the transport's subscribe callback or a sync pull.
1168    /// Returns `Some` for application traffic, `None` for handshake messages (already merged).
1169    ///
1170    /// LIVE path: applies the envelope to the in-memory MLS working set and then
1171    /// durably flushes THIS envelope before returning (per-envelope crash-safety
1172    /// for streaming). The catch-up drain (`sync_conversations`) instead applies a
1173    /// whole page in-memory and flushes ONCE per page — see `apply_envelope_in_memory`.
1174    pub async fn process_envelope(
1175        &self,
1176        env: &MessageEnvelope,
1177        now_ms: u64,
1178    ) -> Result<Option<IncomingMessage>> {
1179        let (out, removed) = self.apply_envelope_in_memory(env, now_ms)?;
1180        // A self-removal already deleted the group's storage; nothing to flush.
1181        if !removed {
1182            self.flush_conversation(&env.conversation_id).await?;
1183        }
1184        Ok(out)
1185    }
1186
1187    /// Apply an inbound envelope to the IN-MEMORY MLS working set and advance the
1188    /// in-memory cursor, WITHOUT the durable checkpoint flush. Returns the
1189    /// decrypted application message (if any) and whether the conversation was
1190    /// REMOVED (a Commit that removed our own leaf tears the group down and
1191    /// deletes its storage synchronously — a removed conversation needs no flush).
1192    ///
1193    /// The write guard is taken and dropped entirely within this synchronous
1194    /// helper — NO `.await` happens while it is held. That is the wasm
1195    /// parking-panic constraint: previously the write guard was held across
1196    /// `snapshot_to_storage().await`, and on the single-threaded wasm worker a
1197    /// concurrent reader that landed while a writer was waiting made `parking_lot`
1198    /// park → panic "Parking not supported". Callers `.await` the flush AFTER this
1199    /// returns (`process_envelope` per call; `sync_conversations` once per page).
1200    ///
1201    /// Batching the flush is the drain-wedge fix: `flush` writes a FULL-state
1202    /// checkpoint (the whole MLS store as one blob), so flushing per envelope made
1203    /// a backlog drain O(N × total_state) of synchronous serialization on the
1204    /// host's single serial queue — the stall the client saw as "messages
1205    /// delivered late". Crash-safety is unchanged: `flush` persists MLS state
1206    /// before the cursor (see `ConversationSnapshot::flush`), so a crash mid-page
1207    /// re-fetches the page and `SyncCursor::is_new` dedups the already-applied
1208    /// events.
1209    fn apply_envelope_in_memory(
1210        &self,
1211        env: &MessageEnvelope,
1212        now_ms: u64,
1213    ) -> Result<(Option<IncomingMessage>, bool)> {
1214        // Welcome envelopes for unknown conversations are routed to
1215        // `join_conversation` by the caller. Here we only handle traffic for
1216        // already-open groups.
1217        let mut guard = self.conversations.write();
1218        let convo = match guard.get_mut(&env.conversation_id) {
1219            Some(c) => c,
1220            None => return Err(Error::UnknownConversation(env.conversation_id.as_hex())),
1221        };
1222        let out = convo.process(env, now_ms)?;
1223        // SELF-REMOVAL: if this Commit removed our own leaf the group is now
1224        // Inactive. Tear it down — delete the OpenMLS state + drop the handle
1225        // — so the conversation becomes UNKNOWN to this client. A later
1226        // re-invite Welcome for the same group_id is then routed to
1227        // `join_conversation` (the host only joins UNKNOWN conversations) and
1228        // re-joins from clean storage, instead of being suppressed as an
1229        // "already-joined duplicate" (the cause of the stuck-un-joined re-invite).
1230        if !convo.is_active() {
1231            let conv_id = env.conversation_id;
1232            // Best-effort storage cleanup; even if it fails, dropping the
1233            // in-memory handle already makes the conversation re-joinable.
1234            if let Err(e) = convo.delete_group_state() {
1235                tracing::warn!(error = %e, "process_envelope: delete removed group state failed");
1236            }
1237            guard.remove(&conv_id);
1238            self.stranded.write().remove(&conv_id);
1239            return Ok((out, true));
1240        }
1241        Ok((out, false))
1242    }
1243
1244    /// Catch-up sync: pull missing events for every open conversation since its cursor.
1245    /// Returns the list of newly-decrypted application messages, in apply order.
1246    pub async fn sync_conversations(&self, now_ms: u64) -> Result<Vec<IncomingMessage>> {
1247        // Snapshot the conversation IDs ONLY — not their cursors. The cursor is
1248        // re-read fresh per fetch below. Two bugs this avoids:
1249        //   1. Stale-cursor pagination: `process_envelope` advances the
1250        //      conversation's cursor as it applies each page, but the OLD code
1251        //      kept fetching from the cursor captured up-front — so a chat with
1252        //      more than one page (256+) of backlog re-fetched the SAME first
1253        //      page forever and never paged past it (catch-up silently truncated
1254        //      a freshly-linked device's history, incl. group-avatar/name
1255        //      hydration that lands after the first page).
1256        //   2. Join-during-sync: reading IDs fresh here (and re-reading the map
1257        //      each iteration) means a conversation the host joins via a live
1258        //      Welcome right before/around this call is still covered.
1259        // Iterate a sorted, de-duplicated ID set for deterministic order.
1260        let conversation_ids: Vec<ConversationId> = {
1261            let guard = self.conversations.read();
1262            let mut ids: Vec<ConversationId> = guard.keys().copied().collect();
1263            ids.sort_by_key(|a| a.0);
1264            ids
1265        };
1266
1267        let mut delivered = Vec::new();
1268        for conv_id in conversation_ids {
1269            loop {
1270                // Re-read the LIVE cursor each iteration so pagination advances
1271                // as `process_envelope` consumes pages. If the conversation was
1272                // removed mid-sync (e.g. a wipe), stop cleanly.
1273                let cursor = match self.conversations.read().get(&conv_id) {
1274                    Some(c) => c.cursor.clone(),
1275                    None => break,
1276                };
1277                // PER-CONVERSATION ISOLATION: a transport error on ONE
1278                // conversation (a transient 5xx, a non-404 fetch failure) must
1279                // not abort catch-up for ALL the others. Previously a single
1280                // erroring conversation propagated `?` and failed the entire
1281                // `sync_conversations` — so on a freshly-linked device, one bad
1282                // conversation (e.g. the device group, or a group mid-churn)
1283                // blocked every chat from syncing, and group name/avatar
1284                // hydration broadcasts never arrived. Log + skip this
1285                // conversation instead.
1286                let batch = match self.transport.fetch_since(conv_id, cursor, 256).await {
1287                    Ok(b) => b,
1288                    Err(e) => {
1289                        tracing::warn!(error = %e, "sync_conversations: fetch_since failed; skipping conversation");
1290                        break;
1291                    }
1292                };
1293                if batch.is_empty() {
1294                    break;
1295                }
1296                let mut advanced = false;
1297                let mut removed = false;
1298                for env in &batch {
1299                    // PER-ENVELOPE ISOLATION: a single undecryptable / malformed
1300                    // / wrong-epoch envelope must not drop the rest of the page
1301                    // (or fail the sync). Skip it; the live stream / a later
1302                    // epoch advance can still deliver retriable ones.
1303                    //
1304                    // BATCHED CHECKPOINT (drain-wedge fix): apply each envelope to
1305                    // the in-memory working set WITHOUT flushing, then persist the
1306                    // whole page with ONE full-state checkpoint below. This turns a
1307                    // backlog drain from O(N × total_state) synchronous
1308                    // serializations into O(1) per page (~100-1000× less work on
1309                    // the host's single serial queue) — the stall the client saw
1310                    // as "messages delivered late".
1311                    match self.apply_envelope_in_memory(env, now_ms) {
1312                        Ok((msg, rm)) => {
1313                            // Commits / handshakes advance the in-memory cursor too
1314                            // (no app message surfaced) — treat them as progress so
1315                            // we keep paging instead of re-fetching the same page.
1316                            advanced = true;
1317                            if let Some(msg) = msg {
1318                                delivered.push(msg);
1319                            }
1320                            if rm {
1321                                // A self-removal Commit deleted the conversation
1322                                // mid-page; stop applying further envelopes to it.
1323                                removed = true;
1324                                break;
1325                            }
1326                        }
1327                        Err(e) => {
1328                            tracing::warn!(error = %e, "sync_conversations: process_envelope failed; skipping envelope");
1329                        }
1330                    }
1331                }
1332                if advanced {
1333                    // Progress was made — this conversation is not stranded
1334                    // (clear any prior stranded mark).
1335                    self.stranded.write().remove(&conv_id);
1336                }
1337                // ONE durable checkpoint for the whole page. Skipped when the
1338                // conversation was self-removed (its storage was already deleted).
1339                // Crash-safety is preserved by `flush`'s state-before-cursor
1340                // ordering: a crash before/inside this flush re-fetches the page on
1341                // restart and `SyncCursor::is_new` dedups the already-applied events.
1342                if advanced && !removed {
1343                    if let Err(e) = self.flush_conversation(&conv_id).await {
1344                        tracing::warn!(error = %e, "sync_conversations: batch checkpoint failed; will re-fetch on next sync");
1345                        break;
1346                    }
1347                }
1348                if removed {
1349                    break; // conversation gone; nothing more to page
1350                }
1351                if batch.len() < 256 {
1352                    break; // partial page → caught up
1353                }
1354                if !advanced {
1355                    // Full page but nothing advanced the cursor (every envelope
1356                    // errored / was wrong-epoch). The group can no longer advance
1357                    // from local state — a Commit was missed. Mark it STRANDED so
1358                    // the host can recover it (re-Welcome / snapshot import) via
1359                    // `stranded_conversations()`, and bail to avoid an infinite
1360                    // re-fetch loop.
1361                    tracing::warn!(
1362                        "sync_conversations: full page made no progress; marking conversation stranded"
1363                    );
1364                    self.stranded.write().insert(conv_id);
1365                    break;
1366                }
1367            }
1368        }
1369        Ok(delivered)
1370    }
1371
1372    /// Rehydrate conversations from storage on startup ([CR-4]).
1373    ///
1374    /// Walks the host-side `groups` namespace for meta records, pairs each with its
1375    /// cursor + device→leaf map, and asks `Conversation::load` to re-attach to the
1376    /// underlying OpenMLS group state. The MLS state itself was persisted by the
1377    /// SQLite-backed `PersistentMlsProvider` on the previous run; this method
1378    /// reconciles the SDK-side caches with what's on disk.
1379    async fn rehydrate_conversations(self: &Arc<Self>, now_ms: u64) -> Result<()> {
1380        let metas = self.storage.list_keys("groups", "").await?;
1381        for path in metas {
1382            // path looks like "{convId}/meta"
1383            let Some((id_hex, suffix)) = path.split_once('/') else {
1384                continue;
1385            };
1386            if suffix != "meta" {
1387                continue;
1388            }
1389            let Some(meta_bytes) = self.storage.get("groups", &path).await? else {
1390                continue;
1391            };
1392            let meta: ConversationMeta = match codec::decode(&meta_bytes) {
1393                Ok(m) => m,
1394                Err(_) => continue,
1395            };
1396            let cursor_bytes = self
1397                .storage
1398                .get("cursors", id_hex)
1399                .await?
1400                .unwrap_or_default();
1401            let cursor = if cursor_bytes.is_empty() {
1402                SyncCursor::default()
1403            } else {
1404                SyncCursor::decode(&cursor_bytes).unwrap_or_default()
1405            };
1406
1407            // [CR-2] device→leaf map was persisted alongside meta + cursor.
1408            let device_leaves_bytes = self
1409                .storage
1410                .get("device_leaves", id_hex)
1411                .await?
1412                .unwrap_or_default();
1413            let device_leaves: std::collections::BTreeMap<DeviceId, u32> =
1414                if device_leaves_bytes.is_empty() {
1415                    std::collections::BTreeMap::new()
1416                } else {
1417                    let pairs: Vec<(DeviceId, u32)> =
1418                        codec::decode(&device_leaves_bytes).unwrap_or_default();
1419                    pairs.into_iter().collect()
1420                };
1421
1422            match Conversation::load(
1423                meta.id,
1424                meta.clone(),
1425                cursor,
1426                device_leaves,
1427                self.local_device.device_id.clone(),
1428                self.crypto.clone(),
1429                self.signing.clone(),
1430                self.storage.clone(),
1431                now_ms,
1432            ) {
1433                Ok(Some(convo)) => {
1434                    tracing::debug!(
1435                        target: "ping_core::client",
1436                        convo = %id_hex,
1437                        epoch = meta.epoch,
1438                        "rehydrated conversation from disk"
1439                    );
1440                    self.conversations.write().insert(meta.id, convo);
1441                }
1442                Ok(None) => {
1443                    tracing::warn!(
1444                        target: "ping_core::client",
1445                        convo = %id_hex,
1446                        "host-side meta present but OpenMLS state missing — skipping"
1447                    );
1448                }
1449                Err(e) => {
1450                    tracing::warn!(
1451                        target: "ping_core::client",
1452                        convo = %id_hex,
1453                        error = %e,
1454                        "Conversation::load failed — skipping"
1455                    );
1456                }
1457            }
1458        }
1459        Ok(())
1460    }
1461
1462    // ------------------- Multi-device API -------------------
1463
1464    /// Build a [`LinkingTicket`] for a new device. The caller obtains `new_device_kp` from the
1465    /// new device (e.g., via QR-encoded handshake) and is responsible for sealing the returned
1466    /// ticket against the new device's ephemeral X25519 pubkey before transmission via
1467    /// [`ping_link::seal_ticket`].
1468    ///
1469    /// [CR-13] `last_app_events` is a host-supplied list of `(conversation_id, app_event_bytes)`
1470    /// for the new device's "what you missed" UI. The SDK adds its own metas + (currently-
1471    /// empty) per-conversation MLS state and bundles everything into
1472    /// [`device::CatchupSnapshot`], CBOR-encoded into the ticket's `catchup_snapshot` field.
1473    /// Pass an empty `Vec` to suppress catchup data (the new device sees an empty
1474    /// conversation list until normal sync runs).
1475    pub async fn build_linking_ticket(
1476        self: &Arc<Self>,
1477        new_device_id: DeviceId,
1478        new_device_kp: Vec<u8>,
1479        last_app_events: Vec<(ConversationId, Vec<u8>)>,
1480        now_ms: u64,
1481    ) -> Result<LinkingTicket> {
1482        let device_binding_sig = self.identity.sign_device_binding(&new_device_id.0);
1483        let dg_id = device_group_id_for(self.identity.user_id());
1484
1485        // [CR-10] DG is eagerly created at init now, but call ensure here too so
1486        // hosts that bypass `MessagingClient::init` (mocked tests, legacy upgrade
1487        // paths) keep working.
1488        self.ensure_device_group(now_ms).await?;
1489
1490        // Admit the new device to the DeviceGroup.
1491        let outcome = {
1492            let mut conversations = self.conversations.write();
1493            // `ensure_device_group` above creates it, but return a typed error
1494            // instead of panicking (which would unwind across FFI/wasm) on the
1495            // pathological case where it's still missing.
1496            let dg = conversations.get_mut(&dg_id).ok_or_else(|| {
1497                Error::Invalid("device group missing after ensure_device_group".into())
1498            })?;
1499            // [CR-2] Record the new device's leaf in the DG so future `revoke_device`
1500            // can find it. The new_device_id we got as a parameter is the inviter's
1501            // own assertion — same trust model as the rest of `add_members`.
1502            dg.add_members(vec![(new_device_id.clone(), new_device_kp)], now_ms)?
1503        };
1504
1505        // [CR-13] Assemble the catchup snapshot: SDK-known conversation metadata + host-
1506        // supplied last-known plaintext per conversation. [CR-7] now populates
1507        // `group_state_bytes` with each group's MLS state so the new device can decrypt
1508        // historical traffic without re-Welcoming. An empty `group_state_bytes` would
1509        // mean either a group with no exportable state (shouldn't happen) or an
1510        // encoder failure (we let those propagate as errors below).
1511        let catchup_snapshot = if last_app_events.is_empty() && self.conversations.read().is_empty()
1512        {
1513            // Cheap path: nothing to snapshot, skip the encode round-trip.
1514            Vec::new()
1515        } else {
1516            // CR-7 per-group state export is O(conversations) expensive MLS
1517            // serialization AND unconsumed by current hosts (both re-admit the new
1518            // device via the post-link re-Welcome reconciler rather than importing
1519            // this state). Exporting every group's state for an account with hundreds
1520            // or thousands of conversations is wasted CPU that also blows the 256 KB
1521            // ticket cap. So budget the EXPORT itself: fill `group_state_bytes` only
1522            // while under a small byte budget, in iteration order; once exhausted, ship
1523            // the (cheap) meta with empty `group_state_bytes` and skip the expensive
1524            // export entirely — the receiver falls back to the normal re-Welcome path
1525            // for those. `encode_within_cap` below is the final guarantee the whole
1526            // snapshot fits regardless. This keeps the per-link cost bounded to a
1527            // CONSTANT (~budget) instead of growing with conversation count.
1528            const GROUP_STATE_EXPORT_BUDGET: usize = CATCHUP_SNAPSHOT_SOFT_CAP; // 64 KiB
1529            let mut group_state_budget = GROUP_STATE_EXPORT_BUDGET;
1530            let conversation_metas: Vec<CatchupConversationEntry> = {
1531                let guard = self.conversations.read();
1532                let mut metas = Vec::with_capacity(guard.len());
1533                for c in guard.values() {
1534                    let group_state_bytes = if group_state_budget > 0 {
1535                        let bytes = c.export_state_snapshot(now_ms)?.to_vec();
1536                        if bytes.len() <= group_state_budget {
1537                            group_state_budget -= bytes.len();
1538                            bytes
1539                        } else {
1540                            // This one would overflow the budget — drop it and stop
1541                            // exporting further (later conversations skip the export).
1542                            group_state_budget = 0;
1543                            Vec::new()
1544                        }
1545                    } else {
1546                        Vec::new()
1547                    };
1548                    metas.push(CatchupConversationEntry {
1549                        conversation_id: c.id(),
1550                        meta: c.meta().clone(),
1551                        group_state_bytes,
1552                    });
1553                }
1554                metas
1555            };
1556            let last_app_events_per_conv: Vec<CatchupAppEventEntry> = last_app_events
1557                .into_iter()
1558                .map(|(conversation_id, app_event_bytes)| CatchupAppEventEntry {
1559                    conversation_id,
1560                    app_event_bytes,
1561                })
1562                .collect();
1563            // `encode_within_cap` (not `encode`) so a user with many/large groups can
1564            // still link: the snapshot snapshots EVERY conversation, so a big account
1565            // would otherwise blow the 256 KB ticket cap and hard-fail linking outright.
1566            // It sheds per-group MLS state (largest first) to fit — those conversations
1567            // catch up via the post-link re-Welcome reconciler the hosts already run.
1568            CatchupSnapshot {
1569                v: CATCHUP_SNAPSHOT_VERSION,
1570                conversation_metas,
1571                last_app_events_per_conv,
1572            }
1573            .encode_within_cap()?
1574        };
1575
1576        Ok(LinkingTicket {
1577            v: 1,
1578            user_id: self.identity.user_id().clone(),
1579            user_pubkey: self.identity.public_key().to_bytes().to_vec(),
1580            new_device_id,
1581            device_binding_sig,
1582            device_group_welcome: outcome.welcome.payload,
1583            catchup_snapshot,
1584        })
1585    }
1586
1587    /// Apply a received linking ticket. Joins the user's DeviceGroup; the catch-up snapshot
1588    /// (if any) is decrypted by the host using the standard per-conversation channel afterwards.
1589    pub async fn consume_linking_ticket(
1590        self: &Arc<Self>,
1591        ticket: &LinkingTicket,
1592        now_ms: u64,
1593    ) -> Result<()> {
1594        // Verify the binding the existing device made for us. (Ed25519 public keys are 32 bytes.)
1595        let pk_bytes: [u8; 32] = ticket
1596            .user_pubkey
1597            .as_slice()
1598            .try_into()
1599            .map_err(|_| Error::Identity("user_pubkey must be 32 bytes".into()))?;
1600        let user_pk = ed25519_dalek::VerifyingKey::from_bytes(&pk_bytes)
1601            .map_err(|e| Error::Identity(format!("bad user pubkey: {e}")))?;
1602        Identity::verify_device_binding(
1603            &user_pk,
1604            &ticket.user_id,
1605            &ticket.new_device_id.0,
1606            &ticket.device_binding_sig,
1607        )?;
1608        if ticket.new_device_id != self.local_device.device_id {
1609            return Err(Error::Invalid(
1610                "ticket addressed to a different device".into(),
1611            ));
1612        }
1613
1614        let dummy_env = MessageEnvelope::new(
1615            ConversationId(device_group_id_for(&ticket.user_id).0),
1616            0,
1617            MessageKind::Welcome,
1618            self.local_device.device_id.clone(),
1619            0,
1620            crate::clock::Hlc::ZERO,
1621            ticket.device_group_welcome.clone(),
1622        );
1623        self.join_conversation(&dummy_env, now_ms).await?;
1624        Ok(())
1625    }
1626
1627    /// [CR-7] Export the MLS state snapshot for one open conversation.
1628    ///
1629    /// Thin pass-through to [`Conversation::export_state_snapshot`]. Returned bytes
1630    /// are wrapped in `Zeroizing` because they contain past epoch secrets.
1631    pub fn export_conversation_state_snapshot(
1632        &self,
1633        conv_id: ConversationId,
1634        now_ms: u64,
1635    ) -> Result<zeroize::Zeroizing<Vec<u8>>> {
1636        let guard = self.conversations.read();
1637        let convo = guard
1638            .get(&conv_id)
1639            .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1640        convo.export_state_snapshot(now_ms)
1641    }
1642
1643    /// [CR-7] Import a `GroupStateSnapshot` produced by another device's
1644    /// [`Conversation::export_state_snapshot`].
1645    ///
1646    /// Replays the snapshot's entries into this client's OpenMLS provider, then
1647    /// reconstructs the `Conversation` handle via `MlsGroup::load`. After return,
1648    /// the conversation is in `list_conversations()` and `send`/`process_envelope`
1649    /// work against it normally.
1650    ///
1651    /// **Scope.** This is for the *same-user* hand-off (linking, recovery). The
1652    /// snapshot exposes the exporter's view of past epoch secrets for the target
1653    /// group; only call this when the receiving device has been authenticated to
1654    /// the same user identity (mnemonic, QR-handshake). Cross-user history transfer
1655    /// uses HPKE-sealed AppEvent re-shares (umbrella §15.6), not this method.
1656    ///
1657    /// **Sanity.** Refuses snapshots whose `group_id` doesn't match the bytes the
1658    /// receiver intends to claim — guards against host bugs that shuffle snapshots
1659    /// between groups. Refuses mismatched OpenMLS storage versions outright; no
1660    /// silent forward/back compatibility.
1661    pub async fn import_state_snapshot(
1662        self: &Arc<Self>,
1663        snapshot_bytes: &[u8],
1664        now_ms: u64,
1665    ) -> Result<ConversationId> {
1666        use crate::device::GroupStateSnapshot;
1667        let snap = GroupStateSnapshot::decode(snapshot_bytes)
1668            .map_err(|e| Error::Invalid(format!("snapshot decode: {e}")))?;
1669
1670        if snap.openmls_storage_version != openmls_traits::storage::CURRENT_VERSION {
1671            return Err(Error::Invalid(format!(
1672                "snapshot openmls_storage_version={} not supported (this SDK supports v={})",
1673                snap.openmls_storage_version,
1674                openmls_traits::storage::CURRENT_VERSION
1675            )));
1676        }
1677
1678        let conv_id = snap.group_id;
1679
1680        // Refuse if we already have an active handle for this conv — the host should
1681        // close it first, otherwise import silently overwrites in-memory state and
1682        // the existing handle becomes stale.
1683        if self.conversations.read().contains_key(&conv_id) {
1684            return Err(Error::Invalid(format!(
1685                "conversation {} already open; close before importing snapshot",
1686                conv_id.as_hex()
1687            )));
1688        }
1689
1690        // Replay raw KV pairs into the provider's working set.
1691        let entries: Vec<(Vec<u8>, Vec<u8>)> =
1692            snap.entries.into_iter().map(|e| (e.key, e.value)).collect();
1693        self.crypto
1694            .import_entries(entries)
1695            .map_err(|e| Error::Storage(format!("import entries: {e}")))?;
1696
1697        // Reconstruct the Conversation handle. `Conversation::load` will return
1698        // `Ok(None)` if OpenMLS still can't find the group — i.e. our snapshot was
1699        // incomplete or for a different storage version.
1700        let meta = ConversationMeta {
1701            id: conv_id,
1702            name: None,
1703            epoch: 0, // will be overwritten from the loaded group state in process()
1704            member_count: 0,
1705            is_device_group: false, // host can flip this via meta update if needed
1706            created_at_ms: now_ms,
1707        };
1708        let convo = Conversation::load(
1709            conv_id,
1710            meta,
1711            SyncCursor::default(),
1712            std::collections::BTreeMap::new(),
1713            self.local_device.device_id.clone(),
1714            self.crypto.clone(),
1715            self.signing.clone(),
1716            self.storage.clone(),
1717            now_ms,
1718        )?
1719        .ok_or_else(|| {
1720            Error::Invalid(
1721                "snapshot imported but OpenMLS could not load the group — snapshot may be incomplete or storage version mismatched"
1722                    .into(),
1723            )
1724        })?;
1725
1726        // Pull the live epoch + member count from the loaded group so the meta we
1727        // just stubbed is consistent with what we'll observe on subsequent process_envelope.
1728        let live_epoch = convo.epoch();
1729        let live_members = convo.group.members().count() as u32;
1730        let live_name = convo.name_from_group_state();
1731        let mut convo = convo;
1732        convo.meta.epoch = live_epoch;
1733        convo.meta.member_count = live_members;
1734        // Recover the name from the loaded GroupContext state (a snapshot import
1735        // is join-equivalent; the stubbed `name: None` would otherwise stick).
1736        convo.meta.name = live_name;
1737        convo.snapshot_to_storage().await?;
1738
1739        self.conversations.write().insert(conv_id, convo);
1740        Ok(conv_id)
1741    }
1742
1743    /// Export a derived secret from one conversation's MLS exporter ([CR-8]).
1744    ///
1745    /// Thin pass-through to [`Conversation::export_secret`]. See that method's doc comment
1746    /// for the contract on `label`, `context`, length validation, and zeroization. The
1747    /// returned `Zeroizing<Vec<u8>>` is automatically wiped when dropped.
1748    pub fn export_conversation_secret(
1749        &self,
1750        conv_id: ConversationId,
1751        label: &str,
1752        context: &[u8],
1753        length: usize,
1754    ) -> Result<Zeroizing<Vec<u8>>> {
1755        let guard = self.conversations.read();
1756        let convo = guard
1757            .get(&conv_id)
1758            .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1759        convo.export_secret(label, context, length)
1760    }
1761
1762    /// Revoke a device by removing its leaf from every conversation where we know its
1763    /// position ([CR-2]).
1764    ///
1765    /// Returns one Commit envelope per conversation the device was a leaf in. The host
1766    /// broadcasts each envelope to the affected conversation; the SDK has also already
1767    /// handed them to the transport via `transport.send` (idempotent broadcast is the
1768    /// host's call).
1769    ///
1770    /// **Scope.** The SDK can only resolve leaves it recorded itself — either when it
1771    /// admitted the device via [`Self::add_members`] or when this device joined as the
1772    /// target via Welcome. For peer-admitted devices the leaf index isn't locally known;
1773    /// those conversations are silently skipped. The host can fall back to
1774    /// `remove_members(leaf_index)` directly using a transport-side directory lookup if
1775    /// it needs to revoke from those conversations too. See
1776    /// `docs/architecture/multi-device.md §Device removal` for the broader flow.
1777    ///
1778    /// Conversations with no entry for `device_id` produce no envelope; an empty `Vec`
1779    /// return is a valid outcome (e.g. the device was already revoked, or was never
1780    /// added by this client).
1781    #[allow(clippy::await_holding_lock)] // see add_members for rationale
1782    pub async fn revoke_device(
1783        &self,
1784        device_id: DeviceId,
1785        now_ms: u64,
1786    ) -> Result<Vec<MessageEnvelope>> {
1787        // 1. Walk every open conversation and gather (conv_id, leaf_index) pairs where
1788        //    we know `device_id` controls a leaf. Done under a read lock so we don't hold
1789        //    the write lock across the per-conversation remove path.
1790        let targets: Vec<(ConversationId, u32)> = self
1791            .conversations
1792            .read()
1793            .iter()
1794            .filter_map(|(id, c)| c.leaf_index_of(&device_id).map(|leaf| (*id, leaf)))
1795            .collect();
1796
1797        // 2. For each target, emit a remove_members commit. We do this sequentially: each
1798        //    one is a separate MLS epoch advance on its own group, and they don't share
1799        //    state, so parallel issuance is safe but adds complexity we don't need for v1.
1800        let mut envelopes = Vec::with_capacity(targets.len());
1801        for (conv_id, leaf_index) in targets {
1802            let envelope = {
1803                let mut guard = self.conversations.write();
1804                let convo = guard
1805                    .get_mut(&conv_id)
1806                    .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1807                convo.remove_members(vec![leaf_index], now_ms)?
1808            };
1809            self.transport.send(envelope.clone()).await?;
1810            if let Some(c) = self.conversations.read().get(&conv_id) {
1811                c.snapshot_to_storage().await?;
1812            }
1813            envelopes.push(envelope);
1814        }
1815
1816        // 3. Notify the auth-layer server so it can invalidate the
1817        //    revoked device's KeyPackage pool, mark `auth.devices.revoked_at`,
1818        //    and refuse any future envelope signed by the revoked device's
1819        //    JWT. Done AFTER the MLS Commits so peers learn via MLS first
1820        //    (the canonical path) and the auth layer is the eventual-
1821        //    consistency cleanup. Transport failures bubble up so callers
1822        //    can retry — but the MLS-side work has already shipped, so
1823        //    the device is functionally revoked in every group; only the
1824        //    auth-layer KeyPackage purge is pending.
1825        self.transport.revoke_device_remote(device_id).await?;
1826        Ok(envelopes)
1827    }
1828}
1829
1830fn device_group_id_for(user_id: &UserId) -> ConversationId {
1831    // Deterministic 16-byte ID derived from the user's id, prefixed so it cannot collide with
1832    // a randomly-generated ULID in normal use (ULIDs start with a millisecond timestamp).
1833    let mut bytes = [0u8; 16];
1834    bytes[0] = 0xFF;
1835    bytes[1] = 0xDC; // "DeviCe" group sentinel
1836    let h = codec::sha256(&user_id.0);
1837    bytes[2..].copy_from_slice(&h[..14]);
1838    ConversationId(bytes)
1839}
1840
1841fn encode_local_device(d: &LocalDevice) -> Result<Vec<u8>> {
1842    use serde::Serialize;
1843    #[derive(Serialize)]
1844    struct Persisted<'a> {
1845        device_id: &'a DeviceId,
1846        label: &'a str,
1847        created_at_ms: u64,
1848        #[serde(with = "serde_bytes")]
1849        signing_seed: &'a [u8],
1850    }
1851    codec::encode(&Persisted {
1852        device_id: &d.device_id,
1853        label: &d.label,
1854        created_at_ms: d.created_at_ms,
1855        signing_seed: d.signing.as_bytes(),
1856    })
1857}
1858
1859fn decode_local_device(bytes: &[u8], user_id: UserId) -> Result<LocalDevice> {
1860    use serde::Deserialize;
1861    #[derive(Deserialize)]
1862    struct Persisted {
1863        device_id: DeviceId,
1864        label: String,
1865        created_at_ms: u64,
1866        #[serde(with = "serde_bytes")]
1867        signing_seed: Vec<u8>,
1868    }
1869    let p: Persisted = codec::decode(bytes)?;
1870    let seed: [u8; 32] = p
1871        .signing_seed
1872        .as_slice()
1873        .try_into()
1874        .map_err(|_| Error::Invalid("device signing seed must be 32 bytes".into()))?;
1875    let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
1876    Ok(LocalDevice {
1877        device_id: p.device_id,
1878        user_id,
1879        label: p.label,
1880        signing,
1881        created_at_ms: p.created_at_ms,
1882    })
1883}