Skip to main content

offline_protocol/protocol/
session.rs

1//! Session confirmation, welcome lifecycle, and pending session reconciliation.
2
3use super::{
4    classify_transport_send_error, internal_prefixes, lock_shared_state, send_failure_token,
5    OfflineProtocol, PresenceRescueThrottle, PruneAllowance, RestorableRecord, SessionState,
6    WelcomeDeliveryState, WelcomeLifecycleRecord, CONFIRMATION_PROBE_INTERVAL_SECS,
7    CONFIRMATION_RETRY_INTERVAL_SECS, MAX_REKEY_TRACKED_PEERS, RECONCILIATION_THROTTLE_MS,
8    REKEY_INTERVAL_SECS, SEND_FAIL_REASON_CONFIRM_TIMEOUT, WELCOME_INTERNET_CONFIRM_TIMEOUT_SECS,
9    WELCOME_LIFECYCLE_TTL_SECS, WELCOME_MESH_CONFIRM_TIMEOUT_SECS, WELCOME_NO_CARRIER_RETRY_SECS,
10    WELCOME_PRESENCE_RESCUE_BASE_SECS, WELCOME_PRESENCE_RESCUE_MAX_SECS, WELCOME_RETRY_BATCH_SIZE,
11    WELCOME_RETRY_JITTER_RATIO, WELCOME_UNREACHABLE_RETRY_CAP_SECS, WELCOME_WATCHLIST_MAX_AGE_SECS,
12};
13use crate::events::SecurityWarningCode;
14use crate::mls_observability::MlsOperationContext;
15use crate::{Error, EstablishmentState, Event, Result, SessionStateError};
16use chrono::{Duration as ChronoDuration, Utc};
17use offline_protocol_core::{Message, MessagePriority};
18use offline_protocol_mls::{MlsManager, WelcomeMessage};
19use offline_protocol_transport::TransportType;
20use std::collections::HashSet;
21use std::hash::{Hash, Hasher};
22use std::sync::{Arc, RwLock};
23use std::time::{Duration as StdDuration, Instant};
24use tracing::{debug, info, warn};
25
26impl OfflineProtocol {
27    // ========================================================================
28    // SESSION STATE
29    // ========================================================================
30
31    /// Returns true when persisted state marks this peer session as confirmed.
32    ///
33    /// Uses the in-memory `confirmed_sessions` cache as a fast path to avoid
34    /// hitting persistent storage on every message send. The cache is populated
35    /// when sessions are confirmed and cleared when sessions are invalidated.
36    pub(super) fn is_session_confirmed(&mut self, peer_id: &str) -> Result<bool> {
37        // Fast path: check in-memory cache only — no I/O. The cache is kept in
38        // sync by confirm_session_state() and all invalidation paths (blocking,
39        // stale cleanup, etc.). We deliberately skip the has_mls_session() guard
40        // here because it calls load_group() which hits MlsStorage (10-100ms on
41        // mobile Keychain/Keystore). If the session was wiped externally, the
42        // encrypt call will fail and we handle it there.
43        if self.confirmed_sessions.contains(peer_id) {
44            return Ok(true);
45        }
46
47        let persisted = self
48            .load_session_state_entry(peer_id)?
49            .unwrap_or(SessionState::Pending);
50        if matches!(persisted, SessionState::Confirmed) {
51            if !self.has_mls_session(peer_id)? {
52                warn!(
53                    peer_id = %peer_id,
54                    "Persisted confirmed state has no matching MLS session; clearing stale state"
55                );
56                self.confirmed_sessions.remove(peer_id);
57                self.clear_confirmation_recovery_tracking(peer_id);
58                self.welcome_lifecycles.remove(peer_id);
59                // The MLS session is gone, so any both-create owner gate for this
60                // peer is stale; clear it (memory + storage) or it would block
61                // `welcome_received` confirmation on the next re-pairing.
62                self.clear_both_create_awaiting_decrypt(peer_id);
63                if let Err(err) = self.clear_session_state_entry(peer_id) {
64                    warn!(
65                        peer_id = %peer_id,
66                        error = %err,
67                        "Failed to clear stale persisted session state"
68                    );
69                }
70                if let Err(err) = self.clear_welcome_lifecycle_entry(peer_id) {
71                    warn!(
72                        peer_id = %peer_id,
73                        error = %err,
74                        "Failed to clear stale persisted welcome lifecycle"
75                    );
76                }
77                return Ok(false);
78            }
79            self.confirmed_sessions.insert(peer_id.to_string());
80            return Ok(true);
81        }
82        Ok(false)
83    }
84
85    pub(super) fn has_mls_session(&self, peer_id: &str) -> Result<bool> {
86        let Some(mls) = self.mls_manager.clone() else {
87            return Ok(false);
88        };
89
90        let manager = mls
91            .read()
92            .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
93        manager.has_session(peer_id).map_err(Error::Mls)
94    }
95
96    /// Returns the current establishment state for a peer (for API and error reporting).
97    pub(super) fn establishment_state(&self, peer_id: &str) -> Result<EstablishmentState> {
98        if let Some(mls) = &self.mls_manager {
99            let has_session = {
100                let manager = mls
101                    .read()
102                    .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
103                manager.has_session(peer_id).map_err(Error::Mls)?
104            };
105            if has_session {
106                let state = self
107                    .load_session_state_entry(peer_id)?
108                    .unwrap_or(SessionState::Pending);
109                return Ok(if matches!(state, SessionState::Confirmed) {
110                    EstablishmentState::SessionConfirmed
111                } else {
112                    EstablishmentState::SessionPending
113                });
114            }
115        }
116
117        let now_ms = Utc::now().timestamp_millis() as u64;
118        if let Some(pkg) = self.pending_key_packages.get(peer_id) {
119            if now_ms < pkg.local_expires_at_ms {
120                return Ok(EstablishmentState::HaveKeyPackage);
121            }
122        }
123        if self.load_peer_key_package_from_storage(peer_id).is_some() {
124            return Ok(EstablishmentState::HaveKeyPackage);
125        }
126        Ok(EstablishmentState::NoKeyPackage)
127    }
128
129    /// Monotonic state transition helper: Pending -> Confirmed only.
130    pub(super) fn confirm_session_state(
131        &mut self,
132        peer_id: &str,
133        source_event: &str,
134    ) -> Result<bool> {
135        if !self.can_confirm_from_source(peer_id, source_event) {
136            warn!(
137                event = "session_confirmation_blocked",
138                session_or_group_id = %peer_id,
139                source_event = %source_event,
140                "session_confirmation_blocked"
141            );
142            return Ok(false);
143        }
144
145        let previous = self.ensure_session_state_entry(peer_id, source_event)?;
146
147        if matches!(previous, SessionState::Confirmed) {
148            self.confirmed_sessions.insert(peer_id.to_string());
149            self.clear_confirmation_recovery_tracking(peer_id);
150            self.clear_both_create_awaiting_decrypt(peer_id);
151            info!(
152                event = "session_state_transition",
153                session_or_group_id = %peer_id,
154                previous_state = "Confirmed",
155                new_state = "Confirmed",
156                source_event = %source_event,
157                "session_state_transition"
158            );
159            return Ok(false);
160        }
161
162        // Persist first, then publish in-memory view.
163        if let Err(err) = self.persist_session_state(peer_id, SessionState::Confirmed, source_event)
164        {
165            self.schedule_confirmation_retry(peer_id, source_event);
166            return Err(err);
167        }
168
169        self.confirmed_sessions.insert(peer_id.to_string());
170        self.clear_confirmation_recovery_tracking(peer_id);
171        // Group-aware proof has arrived (decrypt), so we no longer need to gate
172        // confirmation on decrypt for this both-create peer.
173        self.clear_both_create_awaiting_decrypt(peer_id);
174        // The peer has proved the session, so the outbound Welcome — kept
175        // non-terminal so a lost fragment keeps being retried — is delivered.
176        // Mark it Sent so process_welcome_retry_queue stops re-sending it. Mesh
177        // has no transport-level delivery ack, so this session proof is the mesh
178        // equivalent of on_transport_send_confirmed.
179        self.mark_welcome_confirmed(peer_id, source_event);
180        if source_event != "welcome_received" && source_event != "decrypt_success" {
181            self.maybe_emit_local_session_established(
182                peer_id,
183                Self::session_ready_context_for_source(source_event),
184            );
185        }
186        info!(
187            event = "session_state_transition",
188            session_or_group_id = %peer_id,
189            previous_state = previous.as_str(),
190            new_state = "Confirmed",
191            source_event = %source_event,
192            "session_state_transition"
193        );
194        Ok(true)
195    }
196
197    /// Marks a non-terminal outbound welcome lifecycle as `Sent` once the peer
198    /// has proved the session (probe / ack / welcome / decrypt). The mesh
199    /// equivalent of [`Self::on_transport_send_confirmed`], which only fires for
200    /// Internet. No-op when there is no welcome lifecycle for `peer_id` (e.g. a
201    /// confirmation keyed by a group id rather than a 1:1 peer) or it is already
202    /// terminal, so it is safe to call from every confirmation path.
203    fn mark_welcome_confirmed(&mut self, peer_id: &str, source_event: &str) {
204        match self.welcome_lifecycles.get(peer_id).map(|r| r.state) {
205            // In flight / retried — fall through and mark Sent below.
206            Some(WelcomeDeliveryState::SendAttempted) | Some(WelcomeDeliveryState::Failed) => {}
207            // Parked, never actually sent (no carrier at send time) for a peer
208            // that converged over some other path. `Created -> Sent` is not a
209            // legal transition and there is nothing in flight to confirm, so drop
210            // the now-moot lifecycle (memory + storage) rather than leaving it to
211            // linger until the session is deleted.
212            Some(WelcomeDeliveryState::Created) => {
213                self.welcome_lifecycles.remove(peer_id);
214                if let Err(err) = self.clear_welcome_lifecycle_entry(peer_id) {
215                    warn!(
216                        peer_id = %peer_id,
217                        error = %err,
218                        "Failed to clear parked welcome lifecycle on confirmation"
219                    );
220                }
221                return;
222            }
223            // Already terminal (Sent / Expired) or no lifecycle — nothing to do.
224            _ => return,
225        }
226        if let Some(record) = self.welcome_lifecycles.get_mut(peer_id) {
227            record.next_retry_at = None;
228            record.last_reason_code = None;
229            record.last_transport_error = None;
230        }
231        // transition_welcome_state persists the record and logs the transition.
232        if self
233            .transition_welcome_state(peer_id, WelcomeDeliveryState::Sent, source_event)
234            .is_err()
235        {
236            return;
237        }
238        if let Some(snapshot) = self.welcome_lifecycles.get(peer_id).cloned() {
239            if let Ok(state) = lock_shared_state(&self.shared_state) {
240                state.emit_event(Event::welcome_send_succeeded(
241                    peer_id.to_string(),
242                    snapshot.welcome_message.id.as_str().to_string(),
243                    snapshot.group_id,
244                    snapshot.attempt,
245                ));
246            }
247        }
248    }
249
250    /// Ensures a session has an explicit persisted state entry.
251    pub(super) fn ensure_session_state_entry(
252        &self,
253        peer_id: &str,
254        source_event: &str,
255    ) -> Result<SessionState> {
256        let existing = self.load_session_state_entry(peer_id)?;
257        if let Some(state) = existing {
258            return Ok(state);
259        }
260
261        self.persist_session_state(peer_id, SessionState::Pending, source_event)?;
262        info!(
263            event = "session_state_transition",
264            session_or_group_id = %peer_id,
265            previous_state = "Absent",
266            new_state = "Pending",
267            source_event = %source_event,
268            "session_state_transition"
269        );
270        Ok(SessionState::Pending)
271    }
272
273    /// Reconstructs runtime confirmation cache from persisted session states.
274    /// `allowance` is the launch's shared advisory pool — this walk's deletes
275    /// are advisory (a dropped record re-bootstraps to `Pending`, which is the
276    /// safe answer), and they add up against the same allowance as the four
277    /// category walks. See `storage::MAX_RESTORE_PRUNE_DELETES`.
278    pub(super) fn restore_session_states_from_manager(
279        &mut self,
280        mls: Arc<RwLock<MlsManager>>,
281        allowance: &mut PruneAllowance,
282    ) -> Result<()> {
283        self.confirmed_sessions.clear();
284
285        let sessions = {
286            let manager = mls
287                .read()
288                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
289            manager.list_sessions()?
290        };
291
292        // The session list itself is deliberately not truncated the way a
293        // container-listed walk is: dropping its tail would silently leave
294        // confirmed peers out of `confirmed_sessions`. What needed bounding was
295        // the *deletes* a bad store makes this walk issue — one device barrier
296        // per peer, with nothing counting them until now.
297        let mut budget = allowance.refusing();
298        for peer_id in sessions {
299            // Before anything that can `continue`: an MLS session with this
300            // peer exists, which is the capability fact, and it holds whether
301            // or not the peer's `session_states` record turns out to be
302            // readable. Deriving it from the record instead would reintroduce
303            // exactly the deletable dependency `encryption_capable_peers`
304            // exists to escape.
305            self.mark_encryption_capable(&peer_id);
306
307            // Restore-path read: a record whose bytes will not decode is
308            // dropped and re-bootstrapped as `Pending` rather than failing
309            // initialization forever. The send path deliberately keeps the
310            // strict loader — see `load_restorable_state_record`.
311            let state = match self.load_session_state_for_restore(&peer_id, Some(&mut budget)) {
312                RestorableRecord::Present(state) => state,
313                RestorableRecord::Absent => self.bootstrap_missing_session_state(&peer_id),
314                // A record is still on disk and could not be read *this*
315                // session. Re-bootstrapping would persist `Pending` straight
316                // over one that may say `Confirmed`, and propagating would fail
317                // `initialize_mls` for as long as that single record stays
318                // unreadable — on an install that then cannot send at all.
319                // Skipping is what leaves the record recoverable: the peer is
320                // simply absent from `confirmed_sessions`, and the send path's
321                // own strict loader (`is_session_confirmed`) still fails closed
322                // for it — and re-reads the record, so a transient failure
323                // heals the moment the store answers again.
324                //
325                // Three consequences worth naming rather than rediscovering,
326                // all of them following from the peer being absent from
327                // `confirmed_sessions` for the run:
328                //
329                // 1. The Welcome machinery gates on
330                //    `!confirmed_sessions.contains(peer)` *plus* a live
331                //    `welcome_lifecycles` entry (`welcome_pending_peers`,
332                //    `resend_unconfirmed_sent_welcome`, the retry ladder). So a
333                //    peer whose lifecycle restored as `Sent` while its session
334                //    state did not can draw a redundant Welcome re-send.
335                // 2. `flush_restored_confirmed_pending_messages` will not flush
336                //    this peer's queued pre-session messages this session. They
337                //    stay queued — in memory and on disk — and flush once a
338                //    launch can read the record, or once a live confirmation
339                //    re-adds the peer.
340                // 3. Tier-2 crypto recovery re-seals a resend only for a peer
341                //    in `confirmed_sessions` (`reseal_resend_content`), so a
342                //    resend to this peer replays its original ciphertext for
343                //    the run rather than re-sealing to the current epoch.
344                //
345                // All three are deferrals on an already-degraded store, and all
346                // three are strictly better than the alternative this replaced
347                // — propagating, which failed `initialize_mls` on every launch
348                // for as long as the one record stayed unreadable, on an
349                // install that could then send nothing at all.
350                RestorableRecord::Unavailable => {
351                    warn!(
352                        session_or_group_id = %peer_id,
353                        "Session state could not be read this session; leaving the record in \
354                         place and treating the session as unconfirmed"
355                    );
356                    continue;
357                }
358            };
359            if matches!(state, SessionState::Confirmed) {
360                self.confirmed_sessions.insert(peer_id.clone());
361            }
362            info!(
363                event = "session_state_restored",
364                session_or_group_id = %peer_id,
365                previous_state = "Pending",
366                new_state = %state.as_str(),
367                source_event = "initialize_mls",
368                "session_state_restored"
369            );
370        }
371
372        if budget.exhausted {
373            warn!(
374                deleted = budget.spent,
375                budget = super::storage::MAX_RESTORE_PRUNE_DELETES,
376                "Session state restore hit its share of the launch delete budget; the \
377                 unreadable records left behind are re-bootstrapped as Pending anyway and \
378                 dropped on a later launch"
379            );
380        }
381
382        Ok(())
383    }
384
385    /// The state a session with no readable record starts from.
386    ///
387    /// Infallible: the migration write is best effort, like every other
388    /// persistence call on the restore path. It used to propagate, so one
389    /// `StoreFailed` — a full disk, a container the OS has locked — failed
390    /// `initialize_mls` outright and, with `require_encryption` on by default,
391    /// left the install unable to send. `Pending` is the safe answer in memory
392    /// either way, and a launch that can write re-derives the record.
393    pub(super) fn bootstrap_missing_session_state(&self, peer_id: &str) -> SessionState {
394        // Legacy session records without explicit state are treated as Pending.
395        // Recovery is driven by probe/ack reconciliation, never by implicit inference.
396        let restored_state = SessionState::Pending;
397        if let Err(e) = self.persist_session_state(
398            peer_id,
399            restored_state,
400            "initialize_mls_missing_state_migration",
401        ) {
402            warn!(
403                session_or_group_id = %peer_id,
404                error = %e,
405                "Failed to persist the bootstrapped session state; the session is Pending for \
406                 this run and the record is re-derived on the next launch"
407            );
408        }
409        info!(
410            event = "session_state_transition",
411            session_or_group_id = %peer_id,
412            previous_state = "Absent",
413            new_state = %restored_state.as_str(),
414            source_event = "initialize_mls_missing_state_migration",
415            "session_state_transition"
416        );
417
418        restored_state
419    }
420
421    // ========================================================================
422    // CONFIRMATION RECOVERY
423    // ========================================================================
424
425    pub(super) fn schedule_confirmation_retry(&mut self, peer_id: &str, source_event: &str) {
426        self.confirmation_retry_due_at
427            .insert(peer_id.to_string(), Utc::now());
428        warn!(
429            event = "session_confirmation_retry_scheduled",
430            session_or_group_id = %peer_id,
431            source_event = %source_event,
432            "session_confirmation_retry_scheduled"
433        );
434    }
435
436    pub(super) fn clear_confirmation_recovery_tracking(&mut self, peer_id: &str) {
437        self.confirmation_retry_due_at.remove(peer_id);
438        self.confirmation_probe_due_at.remove(peer_id);
439    }
440
441    pub(super) fn collect_pending_session_peers(&mut self) -> Result<Vec<String>> {
442        let Some(mls) = self.mls_manager.clone() else {
443            return Ok(Vec::new());
444        };
445
446        let sessions = {
447            let manager = mls
448                .read()
449                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
450            manager.list_sessions()?
451        };
452
453        let mut pending = Vec::new();
454        for peer_id in sessions {
455            if !self.is_session_confirmed(&peer_id)? {
456                pending.push(peer_id);
457            }
458        }
459
460        Ok(pending)
461    }
462
463    pub(super) fn send_session_confirmation_probe(&mut self, peer_id: &str, source_event: &str) {
464        match self.send_internal_message(
465            peer_id,
466            internal_prefixes::SESSION_CONFIRM_PROBE.to_string(),
467            MessagePriority::High,
468        ) {
469            Ok(_) => {
470                info!(
471                    event = "session_confirmation_probe_sent",
472                    session_or_group_id = %peer_id,
473                    source_event = %source_event,
474                    "session_confirmation_probe_sent"
475                );
476            }
477            Err(err) => {
478                warn!(
479                    event = "session_confirmation_probe_failed",
480                    session_or_group_id = %peer_id,
481                    source_event = %source_event,
482                    error = %err,
483                    "session_confirmation_probe_failed"
484                );
485            }
486        }
487    }
488
489    /// Throttled wrapper around session reconciliation. Skips the expensive
490    /// `list_sessions()` storage I/O when there is no pending work or when the
491    /// last scan ran recently (within `RECONCILIATION_THROTTLE_MS`).
492    ///
493    /// This is the entry-point called from `process()` and `receive_message()`
494    /// on every tick/poll. Without throttling, the storage call holds the
495    /// protocol Mutex and blocks `sendMessage()` on mobile platforms.
496    pub(super) fn run_throttled_reconciliation(&mut self, source_event: &str) {
497        // Fast path: nothing pending → skip entirely (no storage I/O)
498        let has_pending_work = !self.pending_encrypted_messages.is_empty()
499            || !self.confirmation_probe_due_at.is_empty()
500            || !self.confirmation_retry_due_at.is_empty();
501
502        if !has_pending_work {
503            return;
504        }
505
506        // Throttle: only run the full scan every RECONCILIATION_THROTTLE_MS
507        let now = Instant::now();
508        let throttle = StdDuration::from_millis(RECONCILIATION_THROTTLE_MS);
509        if let Some(last) = self.last_reconciliation_at {
510            if now.duration_since(last) < throttle {
511                return;
512            }
513        }
514
515        self.last_reconciliation_at = Some(now);
516        self.retry_pending_session_confirmations();
517        self.kick_pending_session_reconciliation(source_event);
518    }
519
520    pub(super) fn kick_pending_session_reconciliation(&mut self, source_event: &str) {
521        let now = Utc::now();
522        let pending_peers = match self.collect_pending_session_peers() {
523            Ok(peers) => peers,
524            Err(err) => {
525                warn!(
526                    event = "session_confirmation_probe_scan_failed",
527                    source_event = %source_event,
528                    error = %err,
529                    "session_confirmation_probe_scan_failed"
530                );
531                return;
532            }
533        };
534
535        let pending_set: HashSet<String> = pending_peers.iter().cloned().collect();
536        self.confirmation_probe_due_at
537            .retain(|peer, _| pending_set.contains(peer));
538
539        for peer_id in pending_peers {
540            let due_at = self
541                .confirmation_probe_due_at
542                .get(&peer_id)
543                .copied()
544                .unwrap_or(now);
545            if due_at > now {
546                continue;
547            }
548
549            self.send_session_confirmation_probe(&peer_id, source_event);
550            self.confirmation_probe_due_at.insert(
551                peer_id,
552                now + ChronoDuration::seconds(CONFIRMATION_PROBE_INTERVAL_SECS),
553            );
554        }
555    }
556
557    /// Triggers a rate-limited re-key of the 1:1 session with `peer_id` after an
558    /// epoch-desync decrypt failure.
559    ///
560    /// The self-healing primitive is a two-part move, matching the unblock
561    /// `session_reset` flow: tear down our *own* stale session **and** advertise
562    /// a `session_reset` key package. The peer, on receiving it, drops its stale
563    /// session, auto-establishes a fresh one from our enclosed key package, and
564    /// Welcomes us back — which we, now session-less, simply *join*.
565    ///
566    /// Tearing down our session alone (without the reset key package) would
567    /// deadlock: the peer's original key package was consumed at first
568    /// establishment, so nothing could rebuild the channel, and the peer (still
569    /// believing its session is healthy) would never re-advertise one. Sending
570    /// the reset key package is what breaks that deadlock.
571    ///
572    /// Deleting the local session is also what makes convergence **symmetric and
573    /// single-round regardless of user-id ordering**. Because we keep no local
574    /// session, the peer's returning Welcome is joined via the fresh-session
575    /// path rather than the greater-id-adopts tiebreaker in
576    /// `handle_welcome_message` — so the lexicographically-smaller detector is no
577    /// longer stranded (the failure mode when the stale session was kept). Both
578    /// orderings are covered end-to-end by
579    /// `test_desync_dm_heals_end_to_end_when_detector_id_is_greater` and
580    /// `test_desync_dm_heals_end_to_end_when_detector_id_is_smaller`.
581    ///
582    /// Rate-limited via `rekey_due_at` to at most one re-key per
583    /// [`REKEY_INTERVAL_SECS`] per peer, so a peer replaying stale-epoch
584    /// ciphertext (or an injected wrong-epoch frame) cannot drive a re-key storm.
585    /// The interval is stamped before the send so a send error still counts
586    /// against the limit, and it is **never reset early** — not even by a
587    /// successful decrypt on the healed session. That is deliberate: a genuine
588    /// re-fork and a replayed old-epoch frame are indistinguishable at this layer
589    /// (both surface as `WrongEpoch`), so clearing the floor on heal would let an
590    /// attacker who lands one legit decrypt between replays defeat the limit and
591    /// force ~one teardown per inbound message. The floor lapses naturally after
592    /// [`REKEY_INTERVAL_SECS`]; during that window Tier 1 (un-ACK + sender
593    /// retries) keeps delivery honest, so the only cost of not resetting is that a
594    /// genuine second desync within the window heals up to one interval later —
595    /// an acceptable trade for a rare event against a bounded-churn guarantee.
596    ///
597    /// **SECURITY — this trigger is unauthenticated, and cannot be made
598    /// otherwise.** `peer_id` is the *wire-claimed* sender. Three facts compose:
599    /// `__MLS_ENC__` is a data-plane prefix, deliberately exempt from the
600    /// Ed25519 + derivation control-message gate (see `DATA_PLANE_PREFIXES`);
601    /// OpenMLS
602    /// validates the framing header — group id, then epoch — *before* any AEAD,
603    /// sender-data decryption or signature check, so `WrongEpoch` /
604    /// `NoPastEpochData` is produced with the sender still entirely unverified;
605    /// and a 1:1 slot id is `session:<a>:<b>` over two public user ids. So the
606    /// classification that lands here is reachable by **anyone who can inject a
607    /// frame** — no key material, no captured ciphertext, no session, no replay
608    /// needed. `test_forged_frame_reaches_session_desync_without_any_key_material`
609    /// builds such a frame from scratch to keep this statement honest. It is
610    /// inherent to MLS framing, not an OpenMLS defect, so no upgrade changes it.
611    ///
612    /// The `SenderIdentityMismatch` gate cannot help: it compares the MLS
613    /// credential to the claimed sender, and that credential only exists once
614    /// `process_message` **succeeds**. Every pre-authentication verdict is
615    /// structurally outside its reach.
616    ///
617    /// Because the trigger cannot be authenticated, the mitigation is that
618    /// acting on it is **harmless**, not that it is trusted:
619    /// - `SessionManager::decrypt_message` requires the envelope to name the
620    ///   slot shared with the claimed sender, so one derivable session id
621    ///   cannot be aimed at arbitrary peers, and `rekey_due_at` cannot be grown
622    ///   with attacker-chosen keys.
623    /// - [`REKEY_INTERVAL_SECS`] bounds this to one re-key per peer per window.
624    /// - The heal destroys nothing: queued plaintext survives a reset (it seals
625    ///   at flush time against the rebuilt session), and Tier 2 re-seals
626    ///   in-flight resends.
627    /// - Each re-key emits `SecurityWarningCode::SessionRekeyTriggered`, so a
628    ///   sustained rate — the signature of injected frames rather than a real
629    ///   fork — is visible to the app.
630    ///
631    /// **Residual:** an injector can still force bounded re-key churn on a pair
632    /// (delayed delivery, never lost). Closing that needs a signed
633    /// epoch-corroboration exchange before teardown; a liveness-only probe does
634    /// not work, since a healthy peer answers and we would tear down anyway.
635    /// Also see `docs/state-machines/session-lifecycle.md` ("Desync and heal")
636    /// and `docs/security/threat-model.md` (residual risk R2).
637    pub(super) fn schedule_session_rekey(&mut self, peer_id: &str) {
638        let now = Utc::now();
639        if let Some(due_at) = self.rekey_due_at.get(peer_id) {
640            if *due_at > now {
641                return;
642            }
643        }
644        // Bounded like every other map keyed by a wire-claimed id: the desync
645        // that gets us here is classified before MLS authenticates anything.
646        if !self.rekey_due_at.contains_key(peer_id)
647            && self.rekey_due_at.len() >= MAX_REKEY_TRACKED_PEERS
648        {
649            self.rekey_due_at.clear();
650        }
651        self.rekey_due_at.insert(
652            peer_id.to_string(),
653            now + ChronoDuration::seconds(REKEY_INTERVAL_SECS),
654        );
655
656        // A re-key is remotely triggerable and, until now, entirely silent to
657        // the app. A genuine fork produces these occasionally; a sustained rate
658        // for one peer is the signature of injected frames, which an operator
659        // cannot otherwise distinguish.
660        self.emit_security_warning(
661            peer_id,
662            SecurityWarningCode::SessionRekeyTriggered,
663            "1:1 session torn down and re-advertised after an epoch desync",
664        );
665        // Tear down our own stale session before advertising the reset key
666        // package, mirroring the unblock `session_reset` flow. This is what makes
667        // convergence symmetric regardless of user-id ordering: with no local
668        // session left, the peer's returning Welcome is *joined* (not gated by
669        // the greater-id-adopts tiebreaker in `handle_welcome_message`), so both
670        // sides rebuild from scratch and converge in a single round. Keeping the
671        // stale session instead strands the smaller-id detector, which the
672        // tiebreaker forbids from adopting. Best-effort: a missing session is a
673        // no-op, and a failed delete still sends the reset (the peer rebuild plus
674        // the auto key-package exchange re-arm establishment either way).
675        if self.has_mls_session(peer_id).unwrap_or(false) {
676            if let Err(err) = self.manual_mls_delete_session(peer_id) {
677                debug!(
678                    peer_id = %peer_id,
679                    error = %err,
680                    "rekey: failed to delete local stale session; sending reset anyway"
681                );
682            }
683        }
684        match self.send_key_package_to(peer_id, true) {
685            Ok(()) => {
686                info!(
687                    event = "session_rekey_triggered",
688                    peer_id = %peer_id,
689                    "Triggered 1:1 session re-key after epoch desync"
690                );
691            }
692            Err(err) => {
693                warn!(
694                    event = "session_rekey_send_failed",
695                    peer_id = %peer_id,
696                    error = %err,
697                    "session_rekey_send_failed"
698                );
699            }
700        }
701    }
702
703    pub(super) fn retry_pending_session_confirmations(&mut self) {
704        let now = Utc::now();
705        let due_peers: Vec<String> = self
706            .confirmation_retry_due_at
707            .iter()
708            .filter_map(|(peer_id, due_at)| {
709                if *due_at <= now {
710                    Some(peer_id.clone())
711                } else {
712                    None
713                }
714            })
715            .collect();
716
717        for peer_id in due_peers {
718            match self.has_mls_session(&peer_id) {
719                Ok(true) => {}
720                Ok(false) => {
721                    self.confirmation_retry_due_at.remove(&peer_id);
722                    continue;
723                }
724                Err(err) => {
725                    warn!(
726                        event = "session_confirmation_retry_scan_failed",
727                        session_or_group_id = %peer_id,
728                        error = %err,
729                        "session_confirmation_retry_scan_failed"
730                    );
731                    self.confirmation_retry_due_at.insert(
732                        peer_id,
733                        now + ChronoDuration::seconds(CONFIRMATION_RETRY_INTERVAL_SECS),
734                    );
735                    continue;
736                }
737            }
738
739            if !self.can_confirm_from_source(&peer_id, "confirmation_retry") {
740                debug!(
741                    peer_id = %peer_id,
742                    "Skipping confirmation retry until welcome send is at least attempted"
743                );
744                continue;
745            }
746
747            match self.confirm_session_state(&peer_id, "confirmation_retry") {
748                Ok(_) => {
749                    let _ = self.flush_pending_messages(&peer_id);
750                    self.process_pending_decryption(&peer_id);
751                }
752                Err(err) => {
753                    warn!(
754                        event = "session_confirmation_retry_failed",
755                        session_or_group_id = %peer_id,
756                        error = %err,
757                        "session_confirmation_retry_failed"
758                    );
759                    self.confirmation_retry_due_at.insert(
760                        peer_id,
761                        now + ChronoDuration::seconds(CONFIRMATION_RETRY_INTERVAL_SECS),
762                    );
763                }
764            }
765        }
766    }
767
768    /// Marks `peer_id` as a both-create owner gate — we kept our own group and
769    /// must observe a group-aware decrypt before confirming — and persists it so
770    /// an owner restart mid-convergence cannot let a stale plaintext probe/ack
771    /// confirm prematurely. Only writes storage on a genuine insert.
772    pub(super) fn mark_both_create_awaiting_decrypt(&mut self, peer_id: &str) {
773        if self
774            .both_create_awaiting_decrypt
775            .insert(peer_id.to_string())
776        {
777            self.persist_both_create_awaiting_decrypt(peer_id);
778        }
779    }
780
781    /// Clears the both-create owner gate for `peer_id` (it has converged) from
782    /// both memory and storage. Only writes storage on a genuine removal.
783    pub(super) fn clear_both_create_awaiting_decrypt(&mut self, peer_id: &str) {
784        if self.both_create_awaiting_decrypt.remove(peer_id) {
785            self.delete_both_create_awaiting_decrypt(peer_id);
786        }
787    }
788
789    pub(super) fn can_confirm_from_source(&self, peer_id: &str, source_event: &str) -> bool {
790        // Both-create owner: we kept our own group and are waiting for the peer to
791        // prove it adopted *our* group. Only a successful decrypt is group-aware
792        // proof of that; a plaintext probe/ack proves only that the peer holds
793        // some session (possibly its own, pre-adoption group), so it must not
794        // confirm us or we would stop retransmitting and strand the peer.
795        if self.both_create_awaiting_decrypt.contains(peer_id) && source_event != "decrypt_success"
796        {
797            return false;
798        }
799
800        // A successful group decrypt is definitive, group-aware proof that the
801        // peer adopted our 1:1 group. `decrypt_success` is raised only inside
802        // `DecryptResult::Success` (see message_dispatch.rs): ciphertext on the
803        // single stored `session:<a>:<b>` group actually decrypted, which is
804        // possible only if the peer is a member. It must therefore confirm the
805        // session regardless of our *local* Welcome's delivery state. Gating it on
806        // a still-active local Welcome (the lifecycle match below) strands a
807        // both-create owner whose own Welcome timed out to `Failed`/`Expired` on a
808        // lossy or asymmetric BLE link (the Android↔iOS case): the owner decrypts
809        // the peer's messages but never confirms, so every outbound send fails
810        // `SessionNotReady` — it can receive but never reply. The both-create gate
811        // above is preserved, so a both-create owner is still restricted to *only*
812        // decrypt_success (a plaintext probe/ack can never confirm it).
813        if source_event == "decrypt_success" {
814            return true;
815        }
816
817        if !matches!(
818            source_event,
819            "confirmation_ack_received" | "confirmation_probe_received" | "confirmation_retry"
820        ) {
821            return true;
822        }
823
824        match self.welcome_lifecycles.get(peer_id) {
825            Some(record) => matches!(
826                record.state,
827                WelcomeDeliveryState::Sent | WelcomeDeliveryState::SendAttempted
828            ),
829            None => matches!(
830                source_event,
831                // Compatibility path for sessions created before welcome lifecycle
832                // persistence existed. A plaintext probe/ack/retry can confirm here
833                // (it is the only evidence such a legacy session has); a group-aware
834                // decrypt already returned true above and never reaches this arm.
835                "confirmation_ack_received" | "confirmation_probe_received" | "confirmation_retry"
836            ),
837        }
838    }
839
840    pub(super) fn abort_pending_session_for_peer(
841        &mut self,
842        peer_id: &str,
843        reason: crate::events::WelcomeReasonCode,
844    ) {
845        self.drop_pending_queue_for_peer(
846            peer_id,
847            &format!("Welcome delivery failed: {}", reason.as_str()),
848        );
849        if let Ok(state) = lock_shared_state(&self.shared_state) {
850            state.emit_event(Event::secure_session_failed(
851                peer_id.to_string(),
852                format!("Welcome delivery failed: {}", reason.as_str()),
853            ));
854        }
855    }
856
857    pub(super) fn maybe_emit_local_session_established(
858        &self,
859        peer_id: &str,
860        context: MlsOperationContext,
861    ) {
862        let Some(record) = self.welcome_lifecycles.get(peer_id) else {
863            return;
864        };
865        if !matches!(record.state, WelcomeDeliveryState::Sent) {
866            return;
867        }
868        self.emit_mls_session_ready(peer_id, &record.group_id, context);
869        if let Ok(state) = lock_shared_state(&self.shared_state) {
870            state.emit_event(Event::secure_session_established(
871                peer_id.to_string(),
872                record.group_id.clone(),
873                record.group_id.starts_with("session:"),
874                true,
875            ));
876        }
877    }
878
879    // ========================================================================
880    // WELCOME LIFECYCLE
881    // ========================================================================
882
883    pub(super) fn send_welcome_message(
884        &mut self,
885        recipient: &str,
886        welcome: &WelcomeMessage,
887    ) -> Result<bool> {
888        let serialized =
889            serde_json::to_string(welcome).map_err(|e| Error::Serialization(e.to_string()))?;
890        let content = format!("{}{}", internal_prefixes::WELCOME, serialized);
891        let mut message =
892            self.create_message(recipient, content, Some(MessagePriority::High), None)?;
893        self.sign_control_message(&mut message)?;
894        let group_id = welcome.group_id.as_str().to_string();
895
896        self.upsert_welcome_lifecycle(recipient, &group_id, message, "welcome_created")?;
897        self.try_send_welcome(recipient, "welcome_initial_send")
898    }
899
900    /// Parks a Welcome that currently has no transport carrier: refresh the
901    /// carrier-relative TTL and schedule a slow re-check, WITHOUT consuming a
902    /// retry attempt, transitioning state, or emitting a `WelcomeSendAttempted`
903    /// event. A no-carrier send is a guaranteed failure, so re-attempting it at
904    /// the data-plane retry rate only churns storage I/O and app events while a
905    /// device is offline. The lifecycle stays in its current non-terminal state
906    /// (Created on the first send, Failed on a later retry) so the retry queue
907    /// re-polls it on [`WELCOME_NO_CARRIER_RETRY_SECS`]; `on_neighbor_discovered`
908    /// re-arms it immediately when a carrier surfaces the peer. Returns
909    /// `Ok(false)` (not sent).
910    ///
911    /// **Invariant: the refreshed TTL always outlives the retry being
912    /// scheduled.** [`Self::try_send_welcome`] checks `expires_at` *before*
913    /// running a due retry, so a record whose TTL lapsed while it waited gets
914    /// expired terminally (`welcome_send_expired` + `secure_session_failed`) by
915    /// the very timer meant to recover it. That is not hypothetical at the far
916    /// end of the unreachable ladder in
917    /// [`Self::apply_recipient_unreachable_failure`]: it reaches 480s at six
918    /// consecutive parks and [`WELCOME_UNREACHABLE_RETRY_CAP_SECS`] (600s)
919    /// beyond that, both above the 300s [`WELCOME_LIFECYCLE_TTL_SECS`]. A full
920    /// extra interval of slack keeps the probe's own outcome — a verdict
921    /// re-park or a confirm timeout — inside the window too. No-op for the 15s
922    /// no-carrier park, whose interval is far below the TTL.
923    fn park_welcome_no_carrier(
924        &mut self,
925        peer_id: &str,
926        reason: crate::events::WelcomeReasonCode,
927        transport_error: Option<&'static str>,
928        retry_in_secs: i64,
929    ) -> Result<bool> {
930        let snapshot = {
931            let Some(record) = self.welcome_lifecycles.get_mut(peer_id) else {
932                return Ok(false);
933            };
934            let now = Utc::now();
935            record.next_retry_at = Some(now + ChronoDuration::seconds(retry_in_secs));
936            record.expires_at = now
937                + ChronoDuration::seconds(
938                    WELCOME_LIFECYCLE_TTL_SECS.max(retry_in_secs.saturating_mul(2)),
939                );
940            record.last_reason_code = Some(reason);
941            record.last_transport_error = transport_error.map(str::to_string);
942            record.clone()
943        };
944        self.persist_welcome_lifecycle_entry(&snapshot)?;
945        debug!(
946            peer_id = %peer_id,
947            state = snapshot.state.as_str(),
948            retry_in_secs,
949            "Welcome parked: no transport carrier, re-checking on slow interval"
950        );
951        Ok(false)
952    }
953
954    pub(super) fn try_send_welcome(&mut self, peer_id: &str, source_event: &str) -> Result<bool> {
955        let now = Utc::now();
956        let mut record = self
957            .welcome_lifecycles
958            .get(peer_id)
959            .cloned()
960            .ok_or_else(|| Error::Other(format!("Missing welcome lifecycle for {}", peer_id)))?;
961
962        if matches!(record.state, WelcomeDeliveryState::Sent) {
963            return Ok(true);
964        }
965        if matches!(record.state, WelcomeDeliveryState::Expired) {
966            return Ok(false);
967        }
968
969        // Only honor the TTL when a carrier actually exists: a Welcome that has
970        // never had a deliverable transport must not age out.
971        let carrier_available = !self.transport_manager.get_available_transports().is_empty();
972        if carrier_available && record.expires_at <= now {
973            self.transition_welcome_state(peer_id, WelcomeDeliveryState::Expired, source_event)?;
974            if let Ok(state) = lock_shared_state(&self.shared_state) {
975                state.emit_event(Event::welcome_send_expired(
976                    peer_id.to_string(),
977                    record.welcome_message.id.as_str().to_string(),
978                    record.attempt,
979                    crate::events::WelcomeReasonCode::RetryExhausted,
980                ));
981            }
982            self.abort_pending_session_for_peer(
983                peer_id,
984                crate::events::WelcomeReasonCode::RetryExhausted,
985            );
986            return Ok(false);
987        }
988
989        // No carrier at all: a send is guaranteed to fail, so do NOT burn the
990        // speculative attempt increment, the SendAttempted/Failed transitions
991        // (two persisted state changes + two info logs), and a
992        // `WelcomeSendAttempted` event on it every retry tick. Park the
993        // lifecycle on a slow carrier-relative interval instead and re-check
994        // later; `on_neighbor_discovered` re-arms it immediately when a carrier
995        // surfaces the peer. This keeps an offline device quiet rather than
996        // spinning storage I/O + app events at the data-plane retry rate.
997        if !carrier_available {
998            return self.park_welcome_no_carrier(
999                peer_id,
1000                crate::events::WelcomeReasonCode::TransportUnavailable,
1001                None,
1002                WELCOME_NO_CARRIER_RETRY_SECS,
1003            );
1004        }
1005
1006        record.attempt = record.attempt.saturating_add(1);
1007        self.welcome_lifecycles
1008            .insert(peer_id.to_string(), record.clone());
1009        self.persist_welcome_lifecycle_entry(&record)?;
1010        self.transition_welcome_state(peer_id, WelcomeDeliveryState::SendAttempted, source_event)?;
1011
1012        if let Ok(state) = lock_shared_state(&self.shared_state) {
1013            state.emit_event(Event::welcome_send_attempted(
1014                peer_id.to_string(),
1015                record.welcome_message.id.as_str().to_string(),
1016                record.group_id.clone(),
1017                record.attempt,
1018            ));
1019        }
1020
1021        match self.transport_manager.send(&record.welcome_message) {
1022            Ok(()) => {
1023                let transport_used = self.transport_manager.current_transport();
1024                let mut updated =
1025                    self.welcome_lifecycles
1026                        .get(peer_id)
1027                        .cloned()
1028                        .ok_or_else(|| {
1029                            Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
1030                        })?;
1031
1032                // No transport's send() proves the peer reassembled and joined
1033                // the group, so keep the lifecycle NON-TERMINAL until an explicit
1034                // confirmation arrives and let the retry queue re-send on timeout.
1035                //
1036                //   - Internet send() only enqueues for platform polling; the
1037                //     confirmation is on_transport_send_confirmed.
1038                //   - A BLE / WiFi-Direct GATT WRITE_TYPE_NO_RESPONSE returning Ok
1039                //     only means the local stack accepted the bytes. A single lost
1040                //     fragment of the multi-fragment Welcome would otherwise leave
1041                //     the sender believing the session exists while the peer holds
1042                //     undecryptable ciphertext, with NO retry — permanently
1043                //     bricking the pair. The mesh confirmation is the peer proving
1044                //     the session (its probe / ack / welcome / decrypt), which marks
1045                //     the welcome Sent via confirm_session_state.
1046                let confirm_timeout_secs =
1047                    if matches!(transport_used, Some(TransportType::Internet)) {
1048                        WELCOME_INTERNET_CONFIRM_TIMEOUT_SECS
1049                    } else {
1050                        WELCOME_MESH_CONFIRM_TIMEOUT_SECS
1051                    };
1052                updated.next_retry_at =
1053                    Some(Utc::now() + ChronoDuration::seconds(confirm_timeout_secs));
1054                updated.last_reason_code = None;
1055                updated.last_transport_error = None;
1056                self.welcome_lifecycles
1057                    .insert(peer_id.to_string(), updated.clone());
1058                self.persist_welcome_lifecycle_entry(&updated)?;
1059
1060                // Seed the confirmation-probe scheduler for this pending peer so
1061                // the SENDER actively reconciles instead of waiting passively.
1062                //
1063                // The Welcome is now in flight but unconfirmed. The sender's only
1064                // built-in convergence path would otherwise be the receiver's
1065                // single proactive encrypted confirm on first Welcome receipt — a
1066                // single point of failure with no retry (a lost fragment, a
1067                // not-yet-ready encryptor, or the retransmit/owner_keep path on
1068                // the receiver all strand it). By marking a probe due now,
1069                // `run_throttled_reconciliation` (whose `has_pending_work` gate is
1070                // otherwise false on the sender, since it has no pending encrypted
1071                // messages) starts emitting `SESSION_CONFIRM_PROBE`s. The peer —
1072                // which holds the session — replies with `SESSION_CONFIRM_ACK`,
1073                // confirming us and marking this Welcome `Sent`. The entry is
1074                // dropped once confirmed (`clear_confirmation_recovery_tracking`)
1075                // or once the peer is no longer pending (`kick`'s retain), so this
1076                // adds no steady-state work after convergence.
1077                self.confirmation_probe_due_at
1078                    .insert(peer_id.to_string(), Utc::now());
1079
1080                Ok(false)
1081            }
1082            Err(err) => {
1083                let no_carrier = Self::is_no_carrier_error(&err);
1084                let reason = Self::map_welcome_reason_code(&err);
1085                // Classified, not rendered: the transport layer interpolates
1086                // the peer into `PeerNotReachable`, and this value is both
1087                // persisted with the lifecycle record and emitted on
1088                // `WelcomeSendFailed.transport_error`, where the scrubber
1089                // hashes `peer_id` beside it. The full error is logged above.
1090                self.apply_welcome_send_failure(
1091                    peer_id,
1092                    reason,
1093                    Some(send_failure_token(&err)),
1094                    no_carrier,
1095                    source_event,
1096                )
1097            }
1098        }
1099    }
1100
1101    /// Re-arms a stalled or expired outbound Welcome when a peer becomes
1102    /// reachable again, so a session that never converged while the peer was
1103    /// offline gets a fresh delivery attempt over the now-available carrier.
1104    ///
1105    /// No-op when the peer's session is already confirmed, when there is no
1106    /// Welcome lifecycle, or when the Welcome is already `Sent` or still
1107    /// in-flight (`SendAttempted`). An `Expired` lifecycle is rebuilt from its
1108    /// retained Welcome message — the MLS group is not torn down on expiry, so
1109    /// the stored Welcome is still valid to re-send — while a `Created`/`Failed`
1110    /// lifecycle has its budget and TTL reset and is retried immediately.
1111    pub(super) fn rearm_welcome_for_peer(&mut self, peer_id: &str, source_event: &str) {
1112        if self.confirmed_sessions.contains(peer_id) {
1113            return;
1114        }
1115        let Some(record) = self.welcome_lifecycles.get(peer_id).cloned() else {
1116            return;
1117        };
1118        match record.state {
1119            WelcomeDeliveryState::Sent | WelcomeDeliveryState::SendAttempted => {}
1120            WelcomeDeliveryState::Created | WelcomeDeliveryState::Failed => {
1121                if let Some(entry) = self.welcome_lifecycles.get_mut(peer_id) {
1122                    entry.attempt = 0;
1123                    // A reachability edge resets the unreachable-park
1124                    // escalation: the next relay verdict starts the interval
1125                    // ladder over from its base.
1126                    entry.unreachable_parks = 0;
1127                    entry.next_retry_at = Some(Utc::now());
1128                    entry.last_reason_code = None;
1129                    entry.last_transport_error = None;
1130                    entry.expires_at =
1131                        Utc::now() + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS);
1132                }
1133                if let Err(err) = self.try_send_welcome(peer_id, source_event) {
1134                    warn!(
1135                        peer_id = %peer_id,
1136                        error = %err,
1137                        "Failed to re-arm welcome on peer reachability"
1138                    );
1139                }
1140            }
1141            WelcomeDeliveryState::Expired => {
1142                // Expired is terminal in the lifecycle state machine, so rebuild
1143                // from the retained Welcome message (upsert permits overwrite
1144                // from Expired) before re-sending.
1145                let welcome_message = record.welcome_message.clone();
1146                let group_id = record.group_id.clone();
1147                if let Err(err) =
1148                    self.upsert_welcome_lifecycle(peer_id, &group_id, welcome_message, source_event)
1149                {
1150                    warn!(
1151                        peer_id = %peer_id,
1152                        error = %err,
1153                        "Failed to rebuild expired welcome on peer reachability"
1154                    );
1155                    return;
1156                }
1157                if let Err(err) = self.try_send_welcome(peer_id, source_event) {
1158                    warn!(
1159                        peer_id = %peer_id,
1160                        error = %err,
1161                        "Failed to re-arm expired welcome on peer reachability"
1162                    );
1163                }
1164            }
1165        }
1166    }
1167
1168    /// Ingests an authoritative per-peer presence signal (the internet relay's
1169    /// `PresenceStatusWithLastSeen` answer, bridged by the platform layer).
1170    ///
1171    /// `online` drives the same reachability machinery as a transport-level
1172    /// discovery — outbox flush, welcome re-arm, auto key exchange — plus a
1173    /// rescue for the store-less-relay hole: a welcome whose wire send was
1174    /// confirmed (`Sent`) while the peer was actually offline was dropped by
1175    /// the relay (it forwards or pushes, never stores), and
1176    /// `Self::rearm_welcome_for_peer` deliberately never touches `Sent`.
1177    /// The peer being provably online is the safe moment to rebuild and
1178    /// re-send it: if the original did land, the receiver dedups by message
1179    /// id and the confirmation probe resolves the lifecycle.
1180    ///
1181    /// `offline` parks retry-pending welcomes pending a reachability edge,
1182    /// without burning budget. In-flight records (`SendAttempted`) are left
1183    /// to their confirm deadline — a `DeliveryError`-driven failure or the
1184    /// confirm timeout resolves them, after which the next offline tick parks.
1185    ///
1186    /// The welcome re-arm/re-send on the online edge is throttled per peer
1187    /// with exponential backoff (`WELCOME_PRESENCE_RESCUE_BASE_SECS` ..
1188    /// `WELCOME_PRESENCE_RESCUE_MAX_SECS`): presence answers arrive on the
1189    /// platform's ~20s watch tick, and a peer that is online but can never
1190    /// prove the session (stale key package after a reinstall, incompatible
1191    /// version) must not be re-sent the multi-frame MLS welcome on every
1192    /// tick forever. A throttled online tick still flushes queued data-plane
1193    /// traffic for the peer.
1194    ///
1195    /// Emits `presence_updated` with `source: Internet` — this function is
1196    /// the relay-sourced half of the unified stream and must only be fed
1197    /// relay-observed signals (bridged via `internet_peer_presence`);
1198    /// peer-sent `__PRESENCE__` self-reports are handled in
1199    /// `message_dispatch` and emit `source: Peer`. Self, blocked, or empty
1200    /// peer ids are dropped entirely: an app waiting on `presence_updated`
1201    /// for a blocked peer will never see it.
1202    pub fn on_peer_presence(&mut self, peer_id: &str, online: bool, last_seen_ms: Option<i64>) {
1203        if peer_id.is_empty() || peer_id == self.local_id || self.is_user_blocked(peer_id) {
1204            return;
1205        }
1206        if online {
1207            // Relay-scoped reachability proof: un-park and re-drive the
1208            // peer's DMs over the internet transport before anything else
1209            // (see `flush_outbox_for_peer_via` — this also cancels
1210            // unanswerable mesh-probe ACKs that would otherwise hold the
1211            // messages hostage past this edge). The rescue branch's inner
1212            // flush then finds successfully re-driven entries awaiting
1213            // fresh ACKs and leaves them alone; entries whose forced send
1214            // *failed* are picked up again, so the inner flush keeps the
1215            // internet override — DORS must not re-route them into the
1216            // mesh void with the park counter already cleared.
1217            self.flush_outbox_for_peer_via(peer_id, Some(TransportType::Internet));
1218            if self.welcome_rescue_permitted(peer_id) {
1219                self.on_neighbor_discovered_via(peer_id, Some(TransportType::Internet));
1220                self.resend_unconfirmed_sent_welcome(peer_id, "peer_presence_online");
1221                self.note_welcome_rescue_attempt(peer_id);
1222            }
1223        } else {
1224            self.park_welcome_peer_unreachable(peer_id);
1225        }
1226
1227        let status = if online {
1228            crate::events::PresenceStatus::Online
1229        } else {
1230            crate::events::PresenceStatus::Offline
1231        };
1232        self.emit_event(Event::presence_updated_with_last_seen(
1233            peer_id.to_string(),
1234            status,
1235            Utc::now().timestamp_millis(),
1236            last_seen_ms,
1237        ));
1238    }
1239
1240    /// Peers with a welcome lifecycle whose session has not been proven —
1241    /// the internet presence watch set. Includes `Sent` records (wire-
1242    /// confirmed but never session-confirmed): only a presence signal can
1243    /// rescue those over a store-less relay.
1244    /// Lifecycles older than `WELCOME_WATCHLIST_MAX_AGE_SECS` are excluded:
1245    /// a permanently-dead peer must not occupy watch-rotation slots (and keep
1246    /// its parked record alive via `expires_at` pushes) forever.
1247    pub fn welcome_pending_peers(&self) -> Vec<String> {
1248        let watch_cutoff = Utc::now() - ChronoDuration::seconds(WELCOME_WATCHLIST_MAX_AGE_SECS);
1249        self.welcome_lifecycles
1250            .iter()
1251            .filter(|(peer_id, record)| {
1252                !self.confirmed_sessions.contains(*peer_id) && record.created_at > watch_cutoff
1253            })
1254            .map(|(peer_id, _)| peer_id.clone())
1255            .collect()
1256    }
1257
1258    /// Rebuilds and re-sends a `Sent`-but-session-unconfirmed welcome (see
1259    /// [`Self::on_peer_presence`]). No-op for any other state.
1260    fn resend_unconfirmed_sent_welcome(&mut self, peer_id: &str, source_event: &str) {
1261        if self.confirmed_sessions.contains(peer_id) {
1262            return;
1263        }
1264        let Some(record) = self.welcome_lifecycles.get(peer_id) else {
1265            return;
1266        };
1267        if !matches!(record.state, WelcomeDeliveryState::Sent) {
1268            return;
1269        }
1270        let welcome_message = record.welcome_message.clone();
1271        let group_id = record.group_id.clone();
1272        if let Err(err) =
1273            self.upsert_welcome_lifecycle(peer_id, &group_id, welcome_message, source_event)
1274        {
1275            warn!(
1276                peer_id = %peer_id,
1277                error = %err,
1278                "Failed to rebuild unconfirmed sent welcome on presence"
1279            );
1280            return;
1281        }
1282        if let Err(err) = self.try_send_welcome(peer_id, source_event) {
1283            warn!(
1284                peer_id = %peer_id,
1285                error = %err,
1286                "Failed to re-send unconfirmed welcome on presence"
1287            );
1288        }
1289    }
1290
1291    /// True when a presence-online rescue for this peer is currently allowed
1292    /// by the per-peer backoff. Trivially true (and the throttle entry is
1293    /// dropped) once the peer has no unconfirmed welcome — there is nothing
1294    /// left to throttle.
1295    fn welcome_rescue_permitted(&mut self, peer_id: &str) -> bool {
1296        let pending = self.welcome_lifecycles.contains_key(peer_id)
1297            && !self.confirmed_sessions.contains(peer_id);
1298        if !pending {
1299            self.welcome_presence_rescue.remove(peer_id);
1300            return true;
1301        }
1302        self.welcome_presence_rescue
1303            .get(peer_id)
1304            .is_none_or(|throttle| throttle.next_allowed_at <= Utc::now())
1305    }
1306
1307    /// Records that a presence-online tick ran the rescue actions for a peer
1308    /// that still has an unconfirmed welcome, doubling the wait before the
1309    /// next one (base 40s, capped at 10 minutes). The counter is per
1310    /// convergence attempt: the entry is dropped (via
1311    /// [`Self::welcome_rescue_permitted`]) as soon as the session confirms
1312    /// or the lifecycle goes away.
1313    fn note_welcome_rescue_attempt(&mut self, peer_id: &str) {
1314        let pending = self.welcome_lifecycles.contains_key(peer_id)
1315            && !self.confirmed_sessions.contains(peer_id);
1316        if !pending {
1317            self.welcome_presence_rescue.remove(peer_id);
1318            return;
1319        }
1320        let throttle = self
1321            .welcome_presence_rescue
1322            .entry(peer_id.to_string())
1323            .or_insert(PresenceRescueThrottle {
1324                next_allowed_at: Utc::now(),
1325                rescues: 0,
1326            });
1327        // Shift is clamped to 8, so 40 << 8 = 10240 max before the cap —
1328        // no overflow risk.
1329        let backoff_secs = (WELCOME_PRESENCE_RESCUE_BASE_SECS << throttle.rescues.min(8))
1330            .min(WELCOME_PRESENCE_RESCUE_MAX_SECS);
1331        throttle.next_allowed_at = Utc::now() + ChronoDuration::seconds(backoff_secs);
1332        throttle.rescues = throttle.rescues.saturating_add(1);
1333    }
1334
1335    /// Parks a welcome pending a peer-reachability edge: reason
1336    /// `PeerUnreachable`, **no timed retry**. The carrier (relay socket) is
1337    /// healthy — only this peer is unreachable on it — so the *data-plane*
1338    /// retry this cancels would re-send the welcome into another
1339    /// `DeliveryError` while burning the real-delivery budget that a
1340    /// carrier-backed failure already charged. Recovery is edge-driven
1341    /// instead: `on_peer_presence(online)` and `on_neighbor_discovered` re-arm
1342    /// via `rearm_welcome_for_peer`, and the peer stays on the presence
1343    /// watchlist (`welcome_pending_peers`) so the platform keeps polling for
1344    /// that edge. The TTL is pushed like a no-carrier park: an unreachable
1345    /// peer must not age the welcome out.
1346    ///
1347    /// Its caller is responsible for not applying this to a record already
1348    /// holding an escalating unreachable probe — see
1349    /// [`Self::park_welcome_peer_unreachable`], which is what distinguishes
1350    /// the budget-burning data-plane retry cancelled here from the
1351    /// budget-refunded probe that must survive.
1352    fn park_welcome_awaiting_peer(&mut self, peer_id: &str) -> Result<()> {
1353        let snapshot = {
1354            let Some(record) = self.welcome_lifecycles.get_mut(peer_id) else {
1355                return Ok(());
1356            };
1357            record.next_retry_at = None;
1358            record.expires_at = Utc::now() + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS);
1359            record.last_reason_code = Some(crate::events::WelcomeReasonCode::PeerUnreachable);
1360            record.clone()
1361        };
1362        self.persist_welcome_lifecycle_entry(&snapshot)?;
1363        debug!(
1364            peer_id = %peer_id,
1365            state = snapshot.state.as_str(),
1366            "Welcome parked: peer unreachable on carrier, awaiting reachability edge"
1367        );
1368        Ok(())
1369    }
1370
1371    /// Parks a retry-pending welcome for a peer the relay reports offline.
1372    /// Only `Created`/`Failed` records park: `SendAttempted` keeps its
1373    /// confirm deadline, and `Sent`/`Expired` are handled on the online edge
1374    /// (or corrected by an attributed `DeliveryError`). Skipped entirely when
1375    /// a mesh carrier is also available — relay presence is only
1376    /// authoritative for the internet path, and the data-plane retry track
1377    /// may still deliver over BLE / WiFi-Direct.
1378    ///
1379    /// Never cancels an escalating unreachable probe: a record that already
1380    /// holds one was parked by [`Self::apply_recipient_unreachable_failure`],
1381    /// which already set the same reason code and pushed the same TTL — so this
1382    /// adds nothing and would only downgrade the probe to edge-only. That
1383    /// matters because on an internet-only device this runs once per presence
1384    /// rotation for as long as the peer is down, which would otherwise
1385    /// re-strand the welcome after every single verdict. The distinction is
1386    /// budget: the data-plane retry this *does* cancel was charged an attempt
1387    /// by a carrier-backed failure, while the probe refunds its attempt on
1388    /// every verdict.
1389    ///
1390    /// `last_reason_code` — not `unreachable_parks` alone — is what encodes
1391    /// that distinction. The counter is sticky (only `rearm_welcome_for_peer`
1392    /// clears it), so gating on it by itself would keep shielding the record
1393    /// after the probe track has stopped protecting it: past
1394    /// [`WELCOME_WATCHLIST_MAX_AGE_SECS`] a confirm timeout no longer re-parks
1395    /// (see [`Self::welcome_probe_repark_permitted`]) and instead charges an
1396    /// attempt and arms a plain data-plane retry — precisely the budget-burner
1397    /// this park exists to cancel, and left running it expires the welcome at
1398    /// `max_retries`. Note `park_welcome_awaiting_peer` also stamps
1399    /// `PeerUnreachable` but leaves `next_retry_at = None`, so the
1400    /// `next_retry_at.is_some()` conjunct below is load-bearing, not
1401    /// redundant: the reason code *and* a scheduled retry together are what
1402    /// identify a live probe. A carrier-backed failure stamps
1403    /// `Timeout`/`SendFailed` and a successful send clears the code entirely.
1404    fn park_welcome_peer_unreachable(&mut self, peer_id: &str) {
1405        if self.confirmed_sessions.contains(peer_id) {
1406            return;
1407        }
1408        let Some(record) = self.welcome_lifecycles.get(peer_id) else {
1409            return;
1410        };
1411        if !matches!(
1412            record.state,
1413            WelcomeDeliveryState::Created | WelcomeDeliveryState::Failed
1414        ) {
1415            return;
1416        }
1417        if record.last_reason_code == Some(crate::events::WelcomeReasonCode::PeerUnreachable)
1418            && record.unreachable_parks > 0
1419            && record.next_retry_at.is_some()
1420        {
1421            return;
1422        }
1423        // Local mesh carriers only (BLE / WiFi-Direct): Nostr and Reticulum
1424        // are internet-dependent, so their availability must not veto the
1425        // park — mirrors the carrier guard in
1426        // `apply_recipient_unreachable_failure`.
1427        if self
1428            .transport_manager
1429            .get_available_transports()
1430            .keys()
1431            .any(|transport| matches!(transport, TransportType::BLE | TransportType::WiFiDirect))
1432        {
1433            return;
1434        }
1435        let _ = self.park_welcome_awaiting_peer(peer_id);
1436    }
1437
1438    /// True when an unconfirmed welcome send should resolve as another
1439    /// unreachable verdict ([`Self::apply_recipient_unreachable_failure`])
1440    /// rather than as a carrier-backed failure that ages the record.
1441    ///
1442    /// A live `unreachable_parks` counter means the relay declared the peer
1443    /// offline and no reachability edge has fired since (every edge clears it
1444    /// via `rearm_welcome_for_peer`), so the send that just went unconfirmed
1445    /// was the escalating probe, not a delivery attempt. Without this the
1446    /// probe destroys the very welcome it exists to recover: the relay
1447    /// accepts the frame whenever its push fallback succeeds, which returns
1448    /// *no* `DeliveryError` at all, so the probe resolves at the 10s confirm
1449    /// timeout instead. Treated as carrier-backed that charges the attempt
1450    /// and arms a plain data-plane retry, and since nothing in that ladder
1451    /// pushes `expires_at`, the record walks into `should_expire` within one
1452    /// TTL window — terminal `welcome_send_expired` + `secure_session_failed`
1453    /// for a peer that is merely offline. Re-parking instead refunds the
1454    /// attempt, escalates the interval and pushes the TTL, mirroring the DM
1455    /// path's [`Self::try_repark_exhausted_dm`]: settlement stays reserved
1456    /// for delivery or the record genuinely ageing out below.
1457    ///
1458    /// Only the *presence-offline* answer cancelled that retry before, which
1459    /// is not a defense at all for the case this whole probe exists to serve
1460    /// — a headless consumer that never polls presence.
1461    ///
1462    /// Bounded by [`WELCOME_WATCHLIST_MAX_AGE_SECS`] measured from
1463    /// `created_at`, the same threshold that stops watching a peer as
1464    /// permanently dead: past it the counter no longer shields the record and
1465    /// normal ageing expires it. This is the welcome's twin of the DM probe's
1466    /// absolute bound (`first_sent_at` vs the outbox absolute lifetime) —
1467    /// without it a peer that never returns keeps a welcome probing at the
1468    /// 600s cap forever.
1469    ///
1470    /// Deliberately narrow to `Timeout`: a `SendFailed`/`TransportUnavailable`
1471    /// failure is evidence about the *carrier*, not about this peer, and
1472    /// keeps ageing the record normally.
1473    fn welcome_probe_repark_permitted(&self, peer_id: &str) -> bool {
1474        let Some(record) = self.welcome_lifecycles.get(peer_id) else {
1475            return false;
1476        };
1477        record.unreachable_parks > 0
1478            && record.created_at
1479                > Utc::now() - ChronoDuration::seconds(WELCOME_WATCHLIST_MAX_AGE_SECS)
1480    }
1481
1482    /// Applies the relay's authoritative "recipient offline" verdict
1483    /// (a `recipient_unreachable`-tagged transport failure) to a welcome
1484    /// lifecycle. Also reached *without* a relay verdict when a probe's
1485    /// confirm timeout resolves while the park counter is still live — the
1486    /// same conclusion inferred rather than relayed, see
1487    /// [`Self::welcome_probe_repark_permitted`].
1488    ///
1489    /// The bridge wire-confirms on socket-write success, so by the time the
1490    /// relay's `DeliveryError` arrives the record is normally already `Sent`
1491    /// — a *false* Sent: the store-less relay dropped the content. Correct
1492    /// it back to `Failed` (emitting `welcome_send_failed` so the app's
1493    /// earlier `welcome_send_succeeded` is superseded), refund the attempt
1494    /// (the peer never saw the frame), and park pending a reachability edge.
1495    /// `SendAttempted` (the failure raced ahead of the wire confirm) gets
1496    /// the same treatment; `Created`/`Failed` records just park. Terminal
1497    /// `Expired` records are left to the online edge, which rebuilds them.
1498    ///
1499    /// The record always keeps a timed retry whose interval escalates per
1500    /// consecutive park. Relay presence is authoritative only for the
1501    /// internet path — with a mesh carrier live the peer may still be
1502    /// reachable over BLE / WiFi-Direct (DORS can pick internet even with
1503    /// mesh present) — but an internet-only device needs the timed track just
1504    /// as much: parking it edge-only left the welcome dependent solely on a
1505    /// presence-online answer, i.e. on the platform's presence-polling
1506    /// cadence, which is the same dead end that stalled parked DMs (see
1507    /// [`Self::park_unreachable_dm`]). The escalation (15s → 600s cap) is
1508    /// what bounds the retry: DORS may keep routing to the internet path,
1509    /// where each round trips another budget-refunded `DeliveryError`, and
1510    /// without escalation that would be an unbounded resend loop into the
1511    /// relay. Every verdict refunds the attempt and pushes the TTL — past the
1512    /// scheduled probe, see the invariant on [`Self::park_welcome_no_carrier`]
1513    /// — so the probes never age the welcome out or burn its real-delivery
1514    /// budget, and the counter resets on any reachability edge
1515    /// (`rearm_welcome_for_peer`).
1516    pub(super) fn apply_recipient_unreachable_failure(
1517        &mut self,
1518        peer_id: &str,
1519        transport_error: Option<&'static str>,
1520    ) -> Result<()> {
1521        // A late relay verdict for a session that has since been proven (the
1522        // welcome was rescued over another path and the peer confirmed) must
1523        // not corrupt the converged lifecycle: without this guard the stale
1524        // DeliveryError flips the record back to Failed, refunds an attempt,
1525        // persists the stale state, and emits welcome_send_failed AFTER the
1526        // app already saw secure_session_established.
1527        if self.confirmed_sessions.contains(peer_id) {
1528            return Ok(());
1529        }
1530        let Some(state) = self.welcome_lifecycles.get(peer_id).map(|r| r.state) else {
1531            return Ok(());
1532        };
1533        match state {
1534            WelcomeDeliveryState::Sent | WelcomeDeliveryState::SendAttempted => {
1535                self.transition_welcome_state(
1536                    peer_id,
1537                    WelcomeDeliveryState::Failed,
1538                    "recipient_unreachable",
1539                )?;
1540                if let Some(record) = self.welcome_lifecycles.get_mut(peer_id) {
1541                    record.attempt = record.attempt.saturating_sub(1);
1542                    record.last_transport_error = transport_error.map(str::to_string);
1543                }
1544            }
1545            WelcomeDeliveryState::Created | WelcomeDeliveryState::Failed => {
1546                if let Some(record) = self.welcome_lifecycles.get_mut(peer_id) {
1547                    record.last_transport_error = transport_error.map(str::to_string);
1548                }
1549            }
1550            WelcomeDeliveryState::Expired => return Ok(()),
1551        }
1552        // Escalating timed retry on every carrier (see the doc comment): the
1553        // ladder, not a carrier guard, is what bounds resends into the relay.
1554        let parks = {
1555            let Some(record) = self.welcome_lifecycles.get_mut(peer_id) else {
1556                return Ok(());
1557            };
1558            record.unreachable_parks = record.unreachable_parks.saturating_add(1);
1559            record.unreachable_parks
1560        };
1561        // parks >= 1 here; shift clamped so 15 << 6 = 960 is the largest
1562        // pre-cap value — no overflow risk.
1563        let retry_in_secs = (WELCOME_NO_CARRIER_RETRY_SECS << (parks - 1).min(6))
1564            .min(WELCOME_UNREACHABLE_RETRY_CAP_SECS);
1565        self.park_welcome_no_carrier(
1566            peer_id,
1567            crate::events::WelcomeReasonCode::PeerUnreachable,
1568            transport_error,
1569            retry_in_secs,
1570        )?;
1571
1572        let Some(snapshot) = self.welcome_lifecycles.get(peer_id).cloned() else {
1573            return Ok(());
1574        };
1575        if let Ok(shared) = lock_shared_state(&self.shared_state) {
1576            shared.emit_event(Event::welcome_send_failed(
1577                peer_id.to_string(),
1578                snapshot.welcome_message.id.as_str().to_string(),
1579                snapshot.group_id.clone(),
1580                snapshot.attempt,
1581                crate::events::WelcomeReasonCode::PeerUnreachable,
1582                snapshot
1583                    .last_transport_error
1584                    .as_deref()
1585                    .map(classify_transport_send_error),
1586                // Retryable, and now always with a scheduled time: the park
1587                // keeps an escalating timed probe on every carrier. The
1588                // reachability edges (presence online / peer discovery) still
1589                // re-arm it sooner when they fire.
1590                true,
1591                snapshot.next_retry_at.map(|at| at.timestamp_millis()),
1592            ));
1593        }
1594        Ok(())
1595    }
1596
1597    /// Records a Welcome send failure and decides whether to retry or expire.
1598    ///
1599    /// `no_carrier` is `true` when the failure was simply that no transport
1600    /// carrier exists yet (vs. a present carrier that failed mid-send). A
1601    /// no-carrier failure must NOT age the Welcome: it neither counts toward
1602    /// the retry budget nor lets the TTL expire it, because the peer is merely
1603    /// unreachable for now and will be retried once a carrier appears. The
1604    /// speculative attempt increment from [`Self::try_send_welcome`] is rolled
1605    /// back so a long offline period leaves the real-delivery budget intact.
1606    pub(super) fn apply_welcome_send_failure(
1607        &mut self,
1608        peer_id: &str,
1609        reason: crate::events::WelcomeReasonCode,
1610        transport_error: Option<&'static str>,
1611        no_carrier: bool,
1612        source_event: &str,
1613    ) -> Result<bool> {
1614        let mut updated = self
1615            .welcome_lifecycles
1616            .get(peer_id)
1617            .cloned()
1618            .ok_or_else(|| Error::Other(format!("Missing welcome lifecycle for {}", peer_id)))?;
1619
1620        if matches!(
1621            updated.state,
1622            WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
1623        ) {
1624            return Ok(matches!(updated.state, WelcomeDeliveryState::Sent));
1625        }
1626
1627        // A confirm timeout on a welcome that still holds a live
1628        // unreachable-park counter is a *probe* resolving, not a delivery
1629        // failing — re-park it as another unreachable verdict instead of
1630        // ageing it (see [`Self::welcome_probe_repark_permitted`]).
1631        if !no_carrier
1632            && matches!(reason, crate::events::WelcomeReasonCode::Timeout)
1633            && self.welcome_probe_repark_permitted(peer_id)
1634        {
1635            self.apply_recipient_unreachable_failure(peer_id, transport_error)?;
1636            return Ok(false);
1637        }
1638
1639        let max_attempts = self.config.reliability.retry.max_retries.max(1);
1640        // A no-carrier failure never expires the Welcome: neither the retry
1641        // budget nor the TTL applies while the peer is simply unreachable. Only
1642        // a carrier-backed failure (SendFailed / Timeout) ages it toward expiry.
1643        let should_expire =
1644            !no_carrier && (updated.attempt >= max_attempts || updated.expires_at <= Utc::now());
1645        if should_expire {
1646            let terminal_reason = crate::events::WelcomeReasonCode::RetryExhausted;
1647            {
1648                let record = self.welcome_lifecycles.get_mut(peer_id).ok_or_else(|| {
1649                    Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
1650                })?;
1651                record.last_reason_code = Some(terminal_reason);
1652                record.last_transport_error = transport_error.map(str::to_string);
1653                record.next_retry_at = None;
1654            }
1655
1656            if !matches!(updated.state, WelcomeDeliveryState::Failed) {
1657                self.transition_welcome_state(peer_id, WelcomeDeliveryState::Failed, source_event)?;
1658            }
1659            self.transition_welcome_state(
1660                peer_id,
1661                WelcomeDeliveryState::Expired,
1662                "welcome_retry_exhausted",
1663            )?;
1664
1665            let expired_snapshot =
1666                self.welcome_lifecycles
1667                    .get(peer_id)
1668                    .cloned()
1669                    .ok_or_else(|| {
1670                        Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
1671                    })?;
1672            if let Ok(state) = lock_shared_state(&self.shared_state) {
1673                state.emit_event(Event::welcome_send_failed(
1674                    peer_id.to_string(),
1675                    expired_snapshot.welcome_message.id.as_str().to_string(),
1676                    expired_snapshot.group_id.clone(),
1677                    expired_snapshot.attempt,
1678                    terminal_reason,
1679                    expired_snapshot
1680                        .last_transport_error
1681                        .as_deref()
1682                        .map(classify_transport_send_error),
1683                    false,
1684                    None,
1685                ));
1686                state.emit_event(Event::welcome_send_expired(
1687                    peer_id.to_string(),
1688                    expired_snapshot.welcome_message.id.as_str().to_string(),
1689                    expired_snapshot.attempt,
1690                    terminal_reason,
1691                ));
1692            }
1693            self.abort_pending_session_for_peer(peer_id, terminal_reason);
1694            return Ok(false);
1695        }
1696
1697        // A no-carrier failure (the carrier raced away after the pre-send check,
1698        // or an async transport-failed callback with no carrier) re-checks on the
1699        // slow no-carrier interval rather than the data-plane backoff: there is
1700        // nothing to deliver over until a carrier returns, and `try_send_welcome`
1701        // parks subsequent no-carrier ticks cheaply anyway.
1702        let retry_at = if no_carrier {
1703            Utc::now() + ChronoDuration::seconds(WELCOME_NO_CARRIER_RETRY_SECS)
1704        } else {
1705            let delay_ms = self.compute_welcome_retry_delay_ms(peer_id, updated.attempt);
1706            Utc::now() + ChronoDuration::milliseconds(delay_ms as i64)
1707        };
1708
1709        {
1710            let record = self.welcome_lifecycles.get_mut(peer_id).ok_or_else(|| {
1711                Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
1712            })?;
1713            // Roll back the speculative attempt increment from try_send_welcome
1714            // for a no-carrier failure so the retry budget stays reserved for
1715            // real delivery attempts over an available carrier, and push the TTL
1716            // forward so a never-deliverable Welcome does not age out — the TTL
1717            // clock is carrier-relative, not creation-relative.
1718            if no_carrier {
1719                record.attempt = record.attempt.saturating_sub(1);
1720                record.expires_at =
1721                    Utc::now() + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS);
1722            }
1723            record.last_reason_code = Some(reason);
1724            record.last_transport_error = transport_error.map(str::to_string);
1725            record.next_retry_at = Some(retry_at);
1726        }
1727
1728        if !matches!(updated.state, WelcomeDeliveryState::Failed) {
1729            self.transition_welcome_state(peer_id, WelcomeDeliveryState::Failed, source_event)?;
1730        } else if let Some(record) = self.welcome_lifecycles.get(peer_id).cloned() {
1731            self.persist_welcome_lifecycle_entry(&record)?;
1732        }
1733
1734        updated = self
1735            .welcome_lifecycles
1736            .get(peer_id)
1737            .cloned()
1738            .ok_or_else(|| Error::Other(format!("Missing welcome lifecycle for {}", peer_id)))?;
1739        if let Ok(state) = lock_shared_state(&self.shared_state) {
1740            state.emit_event(Event::welcome_send_failed(
1741                peer_id.to_string(),
1742                updated.welcome_message.id.as_str().to_string(),
1743                updated.group_id,
1744                updated.attempt,
1745                reason,
1746                updated
1747                    .last_transport_error
1748                    .as_deref()
1749                    .map(classify_transport_send_error),
1750                true,
1751                Some(retry_at.timestamp_millis()),
1752            ));
1753        }
1754        Ok(false)
1755    }
1756
1757    pub(super) fn compute_welcome_retry_delay_ms(&self, peer_id: &str, attempt: u32) -> u64 {
1758        let config = &self.config.reliability.retry;
1759        let capped_attempt = attempt.saturating_sub(1);
1760        let base_ms = if capped_attempt == 0 {
1761            config.initial_delay_ms
1762        } else {
1763            let multiplier = config.backoff_multiplier.powi(capped_attempt as i32);
1764            (config.initial_delay_ms as f64 * multiplier as f64) as u64
1765        }
1766        .min(config.max_delay_ms);
1767
1768        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1769        peer_id.hash(&mut hasher);
1770        attempt.hash(&mut hasher);
1771        Utc::now().timestamp_millis().hash(&mut hasher);
1772        let bucket = (hasher.finish() % 10_000) as f64 / 10_000.0;
1773        let jitter_factor = 1.0 + ((bucket * 2.0 - 1.0) * WELCOME_RETRY_JITTER_RATIO);
1774        let jittered = (base_ms as f64 * jitter_factor).round() as i64;
1775        jittered.max(1) as u64
1776    }
1777
1778    pub(super) fn can_transition_welcome_state(
1779        current: WelcomeDeliveryState,
1780        next: WelcomeDeliveryState,
1781    ) -> bool {
1782        matches!(
1783            (current, next),
1784            (
1785                WelcomeDeliveryState::Created,
1786                WelcomeDeliveryState::SendAttempted
1787            ) | (WelcomeDeliveryState::Created, WelcomeDeliveryState::Expired)
1788                | (
1789                    WelcomeDeliveryState::SendAttempted,
1790                    WelcomeDeliveryState::Sent
1791                )
1792                | (
1793                    WelcomeDeliveryState::SendAttempted,
1794                    WelcomeDeliveryState::Failed
1795                )
1796                | (
1797                    WelcomeDeliveryState::Failed,
1798                    WelcomeDeliveryState::SendAttempted
1799                )
1800                | (WelcomeDeliveryState::Failed, WelcomeDeliveryState::Sent)
1801                | (WelcomeDeliveryState::Failed, WelcomeDeliveryState::Expired)
1802                // Sent is normally a sink, but the relay's DeliveryError is
1803                // authoritative proof that a wire-confirmed frame was dropped
1804                // (store-less relay, recipient offline). That correction is
1805                // the one legal way back out of Sent — see
1806                // `on_transport_send_failed`.
1807                | (WelcomeDeliveryState::Sent, WelcomeDeliveryState::Failed)
1808        )
1809    }
1810
1811    pub(super) fn transition_welcome_state(
1812        &mut self,
1813        peer_id: &str,
1814        next_state: WelcomeDeliveryState,
1815        source_event: &str,
1816    ) -> Result<()> {
1817        let (previous_state, record_snapshot) = {
1818            let record = self.welcome_lifecycles.get_mut(peer_id).ok_or_else(|| {
1819                Error::Other(format!(
1820                    "Missing welcome lifecycle for transition: {}",
1821                    peer_id
1822                ))
1823            })?;
1824
1825            if record.state == next_state {
1826                return Ok(());
1827            }
1828
1829            if !Self::can_transition_welcome_state(record.state, next_state) {
1830                return Err(Error::Other(format!(
1831                    "Illegal welcome lifecycle transition for {}: {} -> {}",
1832                    peer_id,
1833                    record.state.as_str(),
1834                    next_state.as_str()
1835                )));
1836            }
1837
1838            let previous = record.state;
1839            record.state = next_state;
1840            if matches!(
1841                next_state,
1842                WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
1843            ) {
1844                record.next_retry_at = None;
1845            }
1846            (previous, record.clone())
1847        };
1848
1849        self.persist_welcome_lifecycle_entry(&record_snapshot)?;
1850        info!(
1851            event = "welcome_lifecycle_transition",
1852            session_or_group_id = %peer_id,
1853            previous_state = previous_state.as_str(),
1854            new_state = next_state.as_str(),
1855            source_event = %source_event,
1856            attempt = record_snapshot.attempt,
1857            "welcome_lifecycle_transition"
1858        );
1859        Ok(())
1860    }
1861
1862    pub(super) fn upsert_welcome_lifecycle(
1863        &mut self,
1864        peer_id: &str,
1865        group_id: &str,
1866        welcome_message: Message,
1867        source_event: &str,
1868    ) -> Result<()> {
1869        if let Some(existing) = self.welcome_lifecycles.get(peer_id) {
1870            if !matches!(
1871                existing.state,
1872                WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
1873            ) {
1874                return Err(Error::Other(format!(
1875                    "Refusing to overwrite active welcome lifecycle for {} in state {}",
1876                    peer_id,
1877                    existing.state.as_str()
1878                )));
1879            }
1880        }
1881
1882        let now = Utc::now();
1883        let record = WelcomeLifecycleRecord {
1884            peer_id: peer_id.to_string(),
1885            group_id: group_id.to_string(),
1886            state: WelcomeDeliveryState::Created,
1887            attempt: 0,
1888            unreachable_parks: 0,
1889            welcome_message,
1890            next_retry_at: None,
1891            last_reason_code: None,
1892            last_transport_error: None,
1893            created_at: now,
1894            expires_at: now + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS),
1895        };
1896        self.welcome_lifecycles
1897            .insert(peer_id.to_string(), record.clone());
1898        self.persist_welcome_lifecycle_entry(&record)?;
1899        info!(
1900            event = "welcome_lifecycle_transition",
1901            session_or_group_id = %peer_id,
1902            previous_state = "Absent",
1903            new_state = WelcomeDeliveryState::Created.as_str(),
1904            source_event = %source_event,
1905            attempt = 0,
1906            "welcome_lifecycle_transition"
1907        );
1908        Ok(())
1909    }
1910
1911    pub(super) fn map_welcome_reason_code(error: &Error) -> crate::events::WelcomeReasonCode {
1912        SessionStateError::classify(error).to_welcome_reason_code()
1913    }
1914
1915    /// True when a send failed because no transport carrier exists yet, as
1916    /// opposed to a present-but-unhealthy carrier (`SendFailed`) or a
1917    /// sent-but-unconfirmed timeout. A no-carrier failure means the peer is
1918    /// simply unreachable right now, so the Welcome must be kept alive and
1919    /// retried rather than counted against its budget — see
1920    /// [`Self::apply_welcome_send_failure`].
1921    pub(super) fn is_no_carrier_error(error: &Error) -> bool {
1922        matches!(
1923            error,
1924            Error::Transport(offline_protocol_transport::Error::TransportNotAvailable(_))
1925        )
1926    }
1927
1928    pub(super) fn session_ready_context_for_source(source_event: &str) -> MlsOperationContext {
1929        match source_event {
1930            "confirmation_ack_received" | "confirmation_probe_received" | "decrypt_success" => {
1931                MlsOperationContext::Receive
1932            }
1933            "welcome_received" => MlsOperationContext::Welcome,
1934            _ => MlsOperationContext::Send,
1935        }
1936    }
1937
1938    pub(super) fn find_welcome_peer_by_message_id(&self, message_id: &str) -> Option<String> {
1939        self.welcome_lifecycles
1940            .iter()
1941            .find_map(|(peer_id, record)| {
1942                if record.welcome_message.id.as_str() == message_id {
1943                    return Some(peer_id.clone());
1944                }
1945                None
1946            })
1947    }
1948
1949    pub(super) fn process_welcome_retry_queue(&mut self) -> Result<()> {
1950        let now = Utc::now();
1951        let timed_out_attempts: Vec<String> = self
1952            .welcome_lifecycles
1953            .iter()
1954            .filter_map(|(peer_id, record)| {
1955                // Skip peers whose session is already confirmed: the welcome is
1956                // delivered, mark_welcome_confirmed should have marked it Sent,
1957                // and re-sending would be wasted bandwidth (defensive against any
1958                // keying mismatch that left the lifecycle non-terminal).
1959                if self.confirmed_sessions.contains(peer_id) {
1960                    return None;
1961                }
1962                if matches!(record.state, WelcomeDeliveryState::SendAttempted)
1963                    && record.next_retry_at.is_some_and(|retry_at| retry_at <= now)
1964                {
1965                    return Some(peer_id.clone());
1966                }
1967                None
1968            })
1969            .take(WELCOME_RETRY_BATCH_SIZE)
1970            .collect();
1971
1972        for peer_id in timed_out_attempts {
1973            let _ = self.apply_welcome_send_failure(
1974                &peer_id,
1975                crate::events::WelcomeReasonCode::Timeout,
1976                Some(SEND_FAIL_REASON_CONFIRM_TIMEOUT),
1977                // A confirm timeout means the Welcome was sent over a carrier;
1978                // this is not a no-carrier failure, so it ages normally.
1979                false,
1980                "welcome_confirm_timeout",
1981            )?;
1982        }
1983
1984        let due_peers: Vec<String> = self
1985            .welcome_lifecycles
1986            .iter()
1987            .filter_map(|(peer_id, record)| {
1988                if self.confirmed_sessions.contains(peer_id) {
1989                    return None;
1990                }
1991                // Failed = a normal retry-due Welcome. Created with a due
1992                // next_retry_at = a no-carrier-parked Welcome (see
1993                // `park_welcome_no_carrier`): a fresh Created entry has
1994                // next_retry_at == None, so only parked ones match here.
1995                if matches!(
1996                    record.state,
1997                    WelcomeDeliveryState::Failed | WelcomeDeliveryState::Created
1998                ) && record.next_retry_at.is_some_and(|retry_at| retry_at <= now)
1999                {
2000                    return Some(peer_id.clone());
2001                }
2002                None
2003            })
2004            .take(WELCOME_RETRY_BATCH_SIZE)
2005            .collect();
2006
2007        for peer_id in due_peers {
2008            if let Err(err) = self.try_send_welcome(&peer_id, "welcome_retry") {
2009                warn!(
2010                    peer_id = %peer_id,
2011                    error = %err,
2012                    "Failed to process welcome retry"
2013                );
2014            }
2015        }
2016
2017        Ok(())
2018    }
2019
2020    pub(super) fn has_terminal_welcome_failure(&self, peer_id: &str) -> bool {
2021        self.welcome_lifecycles
2022            .get(peer_id)
2023            .is_some_and(|record| matches!(record.state, WelcomeDeliveryState::Expired))
2024    }
2025}