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, key_package_resend_wait_secs,
5    lock_shared_state, send_failure_token, GatewayCarrier, OfflineProtocol, PresenceRescueThrottle,
6    PruneAllowance, RestorableRecord, SessionState, WelcomeDeliveryState, WelcomeLifecycleRecord,
7    CONFIRMATION_PROBE_INTERVAL_SECS, CONFIRMATION_RETRY_INTERVAL_SECS, MAX_REKEY_TRACKED_PEERS,
8    RECONCILIATION_THROTTLE_MS, REKEY_INTERVAL_SECS, SEND_FAIL_REASON_CONFIRM_TIMEOUT,
9    WELCOME_INTERNET_CONFIRM_TIMEOUT_SECS, WELCOME_LIFECYCLE_TTL_SECS,
10    WELCOME_MESH_CONFIRM_TIMEOUT_SECS, WELCOME_NO_CARRIER_RETRY_SECS,
11    WELCOME_PRESENCE_RESCUE_BASE_SECS, WELCOME_PRESENCE_RESCUE_MAX_SECS, WELCOME_RETRY_BATCH_SIZE,
12    WELCOME_RETRY_JITTER_RATIO, WELCOME_UNREACHABLE_RETRY_CAP_SECS, WELCOME_WATCHLIST_MAX_AGE_SECS,
13};
14use crate::events::SecurityWarningCode;
15use crate::mls_observability::MlsOperationContext;
16use crate::protocol::reachability::{Claim, FactSource};
17use crate::{Error, EstablishmentState, Event, Result, SessionStateError};
18use chrono::{Duration as ChronoDuration, Utc};
19use offline_protocol_core::time::{Duration as StdDuration, Instant};
20use offline_protocol_core::{Message, MessageId, MessagePriority};
21use offline_protocol_mls::{MlsManager, WelcomeMessage};
22use offline_protocol_sealed::SessionCorroboration;
23use offline_protocol_transport::TransportType;
24use std::collections::HashSet;
25use std::hash::{Hash, Hasher};
26use std::sync::{Arc, RwLock};
27use tracing::{debug, info, warn};
28
29/// The confirmation source for a probe or acknowledgement whose
30/// corroboration matched: group-aware proof, like a successful decrypt.
31pub(super) const SESSION_CORROBORATED: &str = "session_corroborated";
32
33/// What a confirmation probe or acknowledgement says about the session the
34/// two sides hold.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub(super) enum Corroboration {
37    /// No corroboration in the frame: an older peer, or a leaf. The plaintext
38    /// rules apply as they always did.
39    Absent,
40    /// The same group at the same epoch.
41    Agrees,
42    /// The same epoch and a different group: a split session.
43    Split,
44    /// A different epoch, which the desync path owns, or no session or MLS
45    /// on this side to compare against. Evidence of nothing.
46    Inconclusive,
47}
48
49impl OfflineProtocol {
50    // ========================================================================
51    // SESSION STATE
52    // ========================================================================
53
54    /// Returns true when persisted state marks this peer session as confirmed.
55    ///
56    /// Uses the in-memory `confirmed_sessions` cache as a fast path to avoid
57    /// hitting persistent storage on every message send. The cache is populated
58    /// when sessions are confirmed and cleared when sessions are invalidated.
59    pub(super) fn is_session_confirmed(&mut self, peer_id: &str) -> Result<bool> {
60        // Fast path: check in-memory cache only — no I/O. The cache is kept in
61        // sync by confirm_session_state() and all invalidation paths (blocking,
62        // stale cleanup, etc.). We deliberately skip the has_mls_session() guard
63        // here because it calls load_group() which hits MlsStorage (10-100ms on
64        // mobile Keychain/Keystore). If the session was wiped externally, the
65        // encrypt call will fail and we handle it there.
66        if self.confirmed_sessions.contains(peer_id) {
67            return Ok(true);
68        }
69
70        let persisted = self
71            .load_session_state_entry(peer_id)?
72            .unwrap_or(SessionState::Pending);
73        if matches!(persisted, SessionState::Confirmed) {
74            if !self.has_mls_session(peer_id)? {
75                warn!(
76                    peer_id = %peer_id,
77                    "Persisted confirmed state has no matching MLS session; clearing stale state"
78                );
79                self.confirmed_sessions.remove(peer_id);
80                self.clear_confirmation_recovery_tracking(peer_id);
81                self.welcome_lifecycles.remove(peer_id);
82                // The MLS session is gone, so any both-create owner gate for this
83                // peer is stale; clear it (memory + storage) or it would block
84                // `welcome_received` confirmation on the next re-pairing.
85                self.clear_both_create_awaiting_decrypt(peer_id);
86                if let Err(err) = self.clear_session_state_entry(peer_id) {
87                    warn!(
88                        peer_id = %peer_id,
89                        error = %err,
90                        "Failed to clear stale persisted session state"
91                    );
92                }
93                if let Err(err) = self.clear_welcome_lifecycle_entry(peer_id) {
94                    warn!(
95                        peer_id = %peer_id,
96                        error = %err,
97                        "Failed to clear stale persisted welcome lifecycle"
98                    );
99                }
100                return Ok(false);
101            }
102            self.confirmed_sessions.insert(peer_id.to_string());
103            return Ok(true);
104        }
105        Ok(false)
106    }
107
108    pub(super) fn has_mls_session(&self, peer_id: &str) -> Result<bool> {
109        let Some(mls) = self.mls_manager.clone() else {
110            return Ok(false);
111        };
112
113        let manager = mls
114            .read()
115            .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
116        manager.has_session(peer_id).map_err(Error::Mls)
117    }
118
119    /// Returns the current establishment state for a peer (for API and error reporting).
120    pub(super) fn establishment_state(&self, peer_id: &str) -> Result<EstablishmentState> {
121        if let Some(mls) = &self.mls_manager {
122            let has_session = {
123                let manager = mls
124                    .read()
125                    .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
126                manager.has_session(peer_id).map_err(Error::Mls)?
127            };
128            if has_session {
129                let state = self
130                    .load_session_state_entry(peer_id)?
131                    .unwrap_or(SessionState::Pending);
132                return Ok(if matches!(state, SessionState::Confirmed) {
133                    EstablishmentState::SessionConfirmed
134                } else {
135                    EstablishmentState::SessionPending
136                });
137            }
138        }
139
140        let now_ms = Utc::now().timestamp_millis() as u64;
141        if let Some(pkg) = self.pending_key_packages.get(peer_id) {
142            if now_ms < pkg.local_expires_at_ms {
143                return Ok(EstablishmentState::HaveKeyPackage);
144            }
145        }
146        if self.load_peer_key_package_from_storage(peer_id).is_some() {
147            return Ok(EstablishmentState::HaveKeyPackage);
148        }
149        Ok(EstablishmentState::NoKeyPackage)
150    }
151
152    /// Monotonic state transition helper: Pending -> Confirmed only.
153    pub(super) fn confirm_session_state(
154        &mut self,
155        peer_id: &str,
156        source_event: &str,
157    ) -> Result<bool> {
158        if !self.can_confirm_from_source(peer_id, source_event) {
159            warn!(
160                event = "session_confirmation_blocked",
161                session_or_group_id = %peer_id,
162                source_event = %source_event,
163                "session_confirmation_blocked"
164            );
165            return Ok(false);
166        }
167
168        let previous = self.ensure_session_state_entry(peer_id, source_event)?;
169
170        if matches!(previous, SessionState::Confirmed) {
171            self.confirmed_sessions.insert(peer_id.to_string());
172            self.clear_confirmation_recovery_tracking(peer_id);
173            self.clear_both_create_awaiting_decrypt(peer_id);
174            info!(
175                event = "session_state_transition",
176                session_or_group_id = %peer_id,
177                previous_state = "Confirmed",
178                new_state = "Confirmed",
179                source_event = %source_event,
180                "session_state_transition"
181            );
182            return Ok(false);
183        }
184
185        // Persist first, then publish in-memory view.
186        if let Err(err) = self.persist_session_state(peer_id, SessionState::Confirmed, source_event)
187        {
188            self.schedule_confirmation_retry(peer_id, source_event);
189            return Err(err);
190        }
191
192        self.confirmed_sessions.insert(peer_id.to_string());
193        self.clear_confirmation_recovery_tracking(peer_id);
194        // Group-aware proof has arrived (decrypt), so we no longer need to gate
195        // confirmation on decrypt for this both-create peer.
196        self.clear_both_create_awaiting_decrypt(peer_id);
197        // The peer has proved the session, so the outbound Welcome — kept
198        // non-terminal so a lost fragment keeps being retried — is delivered.
199        // Mark it Sent so process_welcome_retry_queue stops re-sending it. Mesh
200        // has no transport-level delivery ack, so this session proof is the mesh
201        // equivalent of on_transport_send_confirmed.
202        self.mark_welcome_confirmed(peer_id, source_event);
203        if source_event != "welcome_received" && source_event != "decrypt_success" {
204            self.maybe_emit_local_session_established(
205                peer_id,
206                Self::session_ready_context_for_source(source_event),
207            );
208        }
209        info!(
210            event = "session_state_transition",
211            session_or_group_id = %peer_id,
212            previous_state = previous.as_str(),
213            new_state = "Confirmed",
214            source_event = %source_event,
215            "session_state_transition"
216        );
217
218        // Replication starts here rather than at the four callers that reach
219        // this transition, because the four callers are exactly the shape of
220        // bug that keeps happening: one of them forgets, and the feature is
221        // silently absent on whichever confirmation path that caller owned.
222        // This is also the only signal that covers all four — the session
223        // lifecycle event does not fire on the probe and ack paths.
224        #[cfg(feature = "data")]
225        self.kick_data_sync(peer_id, source_event);
226
227        Ok(true)
228    }
229
230    /// Marks a non-terminal outbound welcome lifecycle as `Sent` once the peer
231    /// has proved the session (probe / ack / welcome / decrypt). The mesh
232    /// equivalent of [`Self::on_transport_send_confirmed`], which only fires for
233    /// Internet. No-op when there is no welcome lifecycle for `peer_id` (e.g. a
234    /// confirmation keyed by a group id rather than a 1:1 peer) or it is already
235    /// terminal, so it is safe to call from every confirmation path.
236    fn mark_welcome_confirmed(&mut self, peer_id: &str, source_event: &str) {
237        match self.welcome_lifecycles.get(peer_id).map(|r| r.state) {
238            // In flight / retried — fall through and mark Sent below.
239            Some(WelcomeDeliveryState::SendAttempted) | Some(WelcomeDeliveryState::Failed) => {}
240            // Parked, never actually sent (no carrier at send time) for a peer
241            // that converged over some other path. `Created -> Sent` is not a
242            // legal transition and there is nothing in flight to confirm, so drop
243            // the now-moot lifecycle (memory + storage) rather than leaving it to
244            // linger until the session is deleted.
245            Some(WelcomeDeliveryState::Created) => {
246                self.welcome_lifecycles.remove(peer_id);
247                if let Err(err) = self.clear_welcome_lifecycle_entry(peer_id) {
248                    warn!(
249                        peer_id = %peer_id,
250                        error = %err,
251                        "Failed to clear parked welcome lifecycle on confirmation"
252                    );
253                }
254                return;
255            }
256            // Already terminal (Sent / Expired) or no lifecycle — nothing to do.
257            _ => return,
258        }
259        if let Some(record) = self.welcome_lifecycles.get_mut(peer_id) {
260            record.next_retry_at = None;
261            record.last_reason_code = None;
262            record.last_transport_error = None;
263        }
264        // transition_welcome_state persists the record and logs the transition.
265        if self
266            .transition_welcome_state(peer_id, WelcomeDeliveryState::Sent, source_event)
267            .is_err()
268        {
269            return;
270        }
271        if let Some(snapshot) = self.welcome_lifecycles.get(peer_id).cloned() {
272            if let Ok(state) = lock_shared_state(&self.shared_state) {
273                state.emit_event(Event::welcome_send_succeeded(
274                    peer_id.to_string(),
275                    snapshot.welcome_message.id.as_str().to_string(),
276                    snapshot.group_id,
277                    snapshot.attempt,
278                ));
279            }
280        }
281    }
282
283    /// Ensures a session has an explicit persisted state entry.
284    pub(super) fn ensure_session_state_entry(
285        &self,
286        peer_id: &str,
287        source_event: &str,
288    ) -> Result<SessionState> {
289        let existing = self.load_session_state_entry(peer_id)?;
290        if let Some(state) = existing {
291            return Ok(state);
292        }
293
294        self.persist_session_state(peer_id, SessionState::Pending, source_event)?;
295        info!(
296            event = "session_state_transition",
297            session_or_group_id = %peer_id,
298            previous_state = "Absent",
299            new_state = "Pending",
300            source_event = %source_event,
301            "session_state_transition"
302        );
303        Ok(SessionState::Pending)
304    }
305
306    /// Reconstructs runtime confirmation cache from persisted session states.
307    /// `allowance` is the launch's shared advisory pool — this walk's deletes
308    /// are advisory (a dropped record re-bootstraps to `Pending`, which is the
309    /// safe answer), and they add up against the same allowance as the four
310    /// category walks. See `storage::MAX_RESTORE_PRUNE_DELETES`.
311    pub(super) fn restore_session_states_from_manager(
312        &mut self,
313        mls: Arc<RwLock<MlsManager>>,
314        allowance: &mut PruneAllowance,
315    ) -> Result<()> {
316        self.confirmed_sessions.clear();
317
318        let sessions = {
319            let manager = mls
320                .read()
321                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
322            manager.list_sessions()?
323        };
324
325        // The session list itself is deliberately not truncated the way a
326        // container-listed walk is: dropping its tail would silently leave
327        // confirmed peers out of `confirmed_sessions`. What needed bounding was
328        // the *deletes* a bad store makes this walk issue — one device barrier
329        // per peer, with nothing counting them until now.
330        let mut budget = allowance.refusing();
331        for peer_id in sessions {
332            // Before anything that can `continue`: an MLS session with this
333            // peer exists, which is the capability fact, and it holds whether
334            // or not the peer's `session_states` record turns out to be
335            // readable. Deriving it from the record instead would reintroduce
336            // exactly the deletable dependency `encryption_capable_peers`
337            // exists to escape.
338            self.mark_encryption_capable(&peer_id);
339
340            // Restore-path read: a record whose bytes will not decode is
341            // dropped and re-bootstrapped as `Pending` rather than failing
342            // initialization forever. The send path deliberately keeps the
343            // strict loader — see `load_restorable_state_record`.
344            let state = match self.load_session_state_for_restore(&peer_id, Some(&mut budget)) {
345                RestorableRecord::Present(state) => state,
346                RestorableRecord::Absent => self.bootstrap_missing_session_state(&peer_id),
347                // A record is still on disk and could not be read *this*
348                // session. Re-bootstrapping would persist `Pending` straight
349                // over one that may say `Confirmed`, and propagating would fail
350                // `initialize_mls` for as long as that single record stays
351                // unreadable — on an install that then cannot send at all.
352                // Skipping is what leaves the record recoverable: the peer is
353                // simply absent from `confirmed_sessions`, and the send path's
354                // own strict loader (`is_session_confirmed`) still fails closed
355                // for it — and re-reads the record, so a transient failure
356                // heals the moment the store answers again.
357                //
358                // Three consequences worth naming rather than rediscovering,
359                // all of them following from the peer being absent from
360                // `confirmed_sessions` for the run:
361                //
362                // 1. The Welcome machinery gates on
363                //    `!confirmed_sessions.contains(peer)` *plus* a live
364                //    `welcome_lifecycles` entry (`welcome_pending_peers`,
365                //    `resend_unconfirmed_sent_welcome`, the retry ladder). So a
366                //    peer whose lifecycle restored as `Sent` while its session
367                //    state did not can draw a redundant Welcome re-send.
368                // 2. `flush_restored_confirmed_pending_messages` will not flush
369                //    this peer's queued pre-session messages this session. They
370                //    stay queued — in memory and on disk — and flush once a
371                //    launch can read the record, or once a live confirmation
372                //    re-adds the peer.
373                // 3. Tier-2 crypto recovery re-seals a resend only for a peer
374                //    in `confirmed_sessions` (`reseal_resend_content`), so a
375                //    resend to this peer replays its original ciphertext for
376                //    the run rather than re-sealing to the current epoch.
377                //
378                // All three are deferrals on an already-degraded store, and all
379                // three are strictly better than the alternative this replaced
380                // — propagating, which failed `initialize_mls` on every launch
381                // for as long as the one record stayed unreadable, on an
382                // install that could then send nothing at all.
383                RestorableRecord::Unavailable => {
384                    warn!(
385                        session_or_group_id = %peer_id,
386                        "Session state could not be read this session; leaving the record in \
387                         place and treating the session as unconfirmed"
388                    );
389                    continue;
390                }
391            };
392            if matches!(state, SessionState::Confirmed) {
393                self.confirmed_sessions.insert(peer_id.clone());
394            }
395            info!(
396                event = "session_state_restored",
397                session_or_group_id = %peer_id,
398                previous_state = "Pending",
399                new_state = %state.as_str(),
400                source_event = "initialize_mls",
401                "session_state_restored"
402            );
403        }
404
405        if budget.exhausted {
406            warn!(
407                deleted = budget.spent,
408                budget = super::storage::MAX_RESTORE_PRUNE_DELETES,
409                "Session state restore hit its share of the launch delete budget; the \
410                 unreadable records left behind are re-bootstrapped as Pending anyway and \
411                 dropped on a later launch"
412            );
413        }
414
415        Ok(())
416    }
417
418    /// The state a session with no readable record starts from.
419    ///
420    /// Infallible: the migration write is best effort, like every other
421    /// persistence call on the restore path. It used to propagate, so one
422    /// `StoreFailed` — a full disk, a container the OS has locked — failed
423    /// `initialize_mls` outright and, with `require_encryption` on by default,
424    /// left the install unable to send. `Pending` is the safe answer in memory
425    /// either way, and a launch that can write re-derives the record.
426    pub(super) fn bootstrap_missing_session_state(&self, peer_id: &str) -> SessionState {
427        // Legacy session records without explicit state are treated as Pending.
428        // Recovery is driven by probe/ack reconciliation, never by implicit inference.
429        let restored_state = SessionState::Pending;
430        if let Err(e) = self.persist_session_state(
431            peer_id,
432            restored_state,
433            "initialize_mls_missing_state_migration",
434        ) {
435            warn!(
436                session_or_group_id = %peer_id,
437                error = %e,
438                "Failed to persist the bootstrapped session state; the session is Pending for \
439                 this run and the record is re-derived on the next launch"
440            );
441        }
442        info!(
443            event = "session_state_transition",
444            session_or_group_id = %peer_id,
445            previous_state = "Absent",
446            new_state = %restored_state.as_str(),
447            source_event = "initialize_mls_missing_state_migration",
448            "session_state_transition"
449        );
450
451        restored_state
452    }
453
454    // ========================================================================
455    // CONFIRMATION RECOVERY
456    // ========================================================================
457
458    pub(super) fn schedule_confirmation_retry(&mut self, peer_id: &str, source_event: &str) {
459        self.confirmation_retry_due_at
460            .insert(peer_id.to_string(), Utc::now());
461        warn!(
462            event = "session_confirmation_retry_scheduled",
463            session_or_group_id = %peer_id,
464            source_event = %source_event,
465            "session_confirmation_retry_scheduled"
466        );
467    }
468
469    pub(super) fn clear_confirmation_recovery_tracking(&mut self, peer_id: &str) {
470        self.confirmation_retry_due_at.remove(peer_id);
471        self.confirmation_probe_due_at.remove(peer_id);
472        self.confirmation_probe_unreachable_parks.remove(peer_id);
473        // Withdrawn with the tracking: once the map forgets it, no later
474        // probe can supersede it, and it would keep its full retry ladder.
475        if let Some(probe) = self.confirmation_probe_outstanding.remove(peer_id) {
476            self.forget_outbound_message(&probe);
477        }
478        self.confirmation_probe_superseded.remove(peer_id);
479    }
480
481    /// Applies a relay verdict on a probe that has since been superseded
482    /// (`confirmation_probe_superseded`) the way the verdict handlers apply
483    /// one on a queued probe: the reachability fact, then the probe back-off.
484    /// Consumed, so one probe's verdict escalates once, as it did while the
485    /// probe was still queued. A no-op for any other id; returns whether
486    /// the verdict was applied.
487    pub(super) fn note_superseded_probe_verdict(
488        &mut self,
489        message_id: &MessageId,
490        carrier: Option<TransportType>,
491    ) -> bool {
492        let Some(peer) = self
493            .confirmation_probe_superseded
494            .iter()
495            .find(|(_, probe)| *probe == message_id)
496            .map(|(peer, _)| peer.clone())
497        else {
498            return false;
499        };
500        self.confirmation_probe_superseded.remove(&peer);
501        if let Some(carrier) = carrier {
502            self.reachability.record(
503                &peer,
504                carrier,
505                Claim::Unreachable,
506                FactSource::GatewayVerdict,
507                Instant::now(),
508            );
509        }
510        self.note_confirmation_probe_unreachable(&peer);
511        true
512    }
513
514    /// Fold a relay "recipient unreachable" verdict into the session-confirmation
515    /// probe schedule: escalate this peer's next probe on the same 15s -> 600s
516    /// ladder the welcome lifecycle uses (see
517    /// [`Self::apply_recipient_unreachable_failure`]). Without this, a peer that
518    /// established an MLS session then vanished before confirming is re-probed
519    /// every `CONFIRMATION_PROBE_INTERVAL_SECS` (5s) indefinitely, because
520    /// `send_session_confirmation_probe` re-arms it on every probe and
521    /// `on_transport_send_failed` otherwise never consults this scheduler. A
522    /// handful of such peers pushes aggregate relay traffic past the
523    /// per-connection rate limit, which disconnects the socket on a loop.
524    ///
525    /// No-op unless the peer is currently a probe target (a `confirmation_probe`
526    /// entry exists, so a probe was just attempted), so ordinary DM failures to
527    /// confirmed peers do not touch the probe schedule. The counter is reset on
528    /// a reachability edge in [`Self::on_peer_presence`] so a returning peer is
529    /// re-probed immediately and its session still converges.
530    pub(super) fn note_confirmation_probe_unreachable(&mut self, peer_id: &str) {
531        if !self.confirmation_probe_due_at.contains_key(peer_id) {
532            return;
533        }
534        let parks = {
535            let counter = self
536                .confirmation_probe_unreachable_parks
537                .entry(peer_id.to_string())
538                .or_insert(0);
539            *counter = counter.saturating_add(1);
540            *counter
541        };
542        // parks >= 1; shift clamped so 15 << 6 = 960 is the largest pre-cap
543        // value (no overflow), then capped — identical to the welcome ladder.
544        let backoff_secs = (WELCOME_NO_CARRIER_RETRY_SECS << (parks - 1).min(6))
545            .min(WELCOME_UNREACHABLE_RETRY_CAP_SECS);
546        self.confirmation_probe_due_at.insert(
547            peer_id.to_string(),
548            Utc::now() + ChronoDuration::seconds(backoff_secs),
549        );
550    }
551
552    pub(super) fn collect_pending_session_peers(&mut self) -> Result<Vec<String>> {
553        let Some(mls) = self.mls_manager.clone() else {
554            return Ok(Vec::new());
555        };
556
557        let sessions = {
558            let manager = mls
559                .read()
560                .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
561            manager.list_sessions()?
562        };
563
564        let mut pending = Vec::new();
565        for peer_id in sessions {
566            if !self.is_session_confirmed(&peer_id)? {
567                pending.push(peer_id);
568            }
569        }
570
571        Ok(pending)
572    }
573
574    pub(super) fn send_session_confirmation_probe(&mut self, peer_id: &str, source_event: &str) {
575        let Some(challenge) = self.session_challenge_body(peer_id) else {
576            debug!(
577                session_or_group_id = %peer_id,
578                source_event = %source_event,
579                "No corroboration to probe with; not probing"
580            );
581            // Next interval rather than the next scan: a tag that keeps
582            // failing would otherwise cost a group load and an export on
583            // every reconciliation pass.
584            let next = Utc::now() + ChronoDuration::seconds(CONFIRMATION_PROBE_INTERVAL_SECS);
585            let due_at = self
586                .confirmation_probe_due_at
587                .entry(peer_id.to_string())
588                .or_insert(next);
589            *due_at = (*due_at).max(next);
590            return;
591        };
592        // Superseded, not failed: the new probe asks the same question, so
593        // the old one's retry ladder would only stack onto it. Here rather
594        // than in the scan because the Welcome fast path sends one too, and
595        // a probe it replaced would keep its full ladder. Silent, and no
596        // delivery failure is recorded against the carrier. Before the send,
597        // not after: if the send errors the peer has no probe in flight
598        // until the next scan, but a new probe can never evict a message at
599        // outbox capacity to make room beside the one it replaces. The
600        // superseded id is kept so a relay verdict that arrives for it late
601        // still backs the schedule off (`note_superseded_probe_verdict`).
602        if let Some(previous) = self.confirmation_probe_outstanding.remove(peer_id) {
603            self.forget_outbound_message(&previous);
604            self.confirmation_probe_superseded
605                .insert(peer_id.to_string(), previous);
606        }
607        // The sender owns the cadence, so a fast-path probe is not superseded
608        // by the next scan before its ACK can arrive, and a relay verdict for
609        // it can back the schedule off (`note_confirmation_probe_unreachable`
610        // only escalates a peer that has a due time). Stamped before the send
611        // so a verdict delivered during it is not overwritten.
612        //
613        // A later due time is kept: the scan only sends once the due time has
614        // passed, but the fast path can fire while the schedule is backed off,
615        // and resetting it would undo the ladder a relay verdict built.
616        let next = Utc::now() + ChronoDuration::seconds(CONFIRMATION_PROBE_INTERVAL_SECS);
617        let due_at = self
618            .confirmation_probe_due_at
619            .entry(peer_id.to_string())
620            .or_insert(next);
621        *due_at = (*due_at).max(next);
622        let probe = format!("{}{}", internal_prefixes::SESSION_CONFIRM_PROBE, challenge);
623        match self.send_internal_message(peer_id, probe, MessagePriority::High) {
624            Ok(message_id) => {
625                self.confirmation_probe_outstanding
626                    .insert(peer_id.to_string(), message_id);
627                info!(
628                    event = "session_confirmation_probe_sent",
629                    session_or_group_id = %peer_id,
630                    source_event = %source_event,
631                    "session_confirmation_probe_sent"
632                );
633            }
634            Err(err) => {
635                // The send can fail after the probe entered the outbox, and
636                // then no id comes back to track. Any probe still queued for
637                // this peer is such an orphan, since the tracked one was
638                // withdrawn above. (A full ACK tracker no longer lands here:
639                // it holds the probe and returns its id.)
640                let orphans: Vec<_> = self
641                    .outbox
642                    .values()
643                    .filter(|entry| {
644                        entry.message.recipient.as_str() == peer_id
645                            && Self::is_confirmation_probe(&entry.message)
646                    })
647                    .map(|entry| entry.message.id.clone())
648                    .collect();
649                for orphan in orphans {
650                    self.forget_outbound_message(&orphan);
651                }
652                warn!(
653                    event = "session_confirmation_probe_failed",
654                    session_or_group_id = %peer_id,
655                    source_event = %source_event,
656                    error = %err,
657                    "session_confirmation_probe_failed"
658                );
659            }
660        }
661    }
662
663    /// Throttled wrapper around session reconciliation. Skips the expensive
664    /// `list_sessions()` storage I/O when there is no pending work or when the
665    /// last scan ran recently (within `RECONCILIATION_THROTTLE_MS`).
666    ///
667    /// This is the entry-point called from `process()` and `receive_message()`
668    /// on every tick/poll. Without throttling, the storage call holds the
669    /// protocol Mutex and blocks `sendMessage()` on mobile platforms.
670    pub(super) fn run_throttled_reconciliation(&mut self, source_event: &str) {
671        // Fast path: nothing pending → skip entirely (no storage I/O)
672        let has_pending_work = !self.pending_encrypted_messages.is_empty()
673            || !self.confirmation_probe_due_at.is_empty()
674            || !self.confirmation_retry_due_at.is_empty()
675            || !self.corroboration_reask_at.is_empty();
676
677        if !has_pending_work {
678            return;
679        }
680
681        // Throttle: only run the full scan every RECONCILIATION_THROTTLE_MS
682        let now = Instant::now();
683        let throttle = StdDuration::from_millis(RECONCILIATION_THROTTLE_MS);
684        if let Some(last) = self.last_reconciliation_at {
685            if now.duration_since(last) < throttle {
686                return;
687            }
688        }
689
690        self.last_reconciliation_at = Some(now);
691        self.retry_pending_session_confirmations();
692        self.kick_pending_session_reconciliation(source_event);
693        self.send_due_corroboration_reasks();
694
695        // A message queued for a session is the standing reason to want one,
696        // so a lost key package is re-armed here too, not only on a discovery
697        // that a quiet pair may never produce.
698        let waiting: Vec<String> = self.pending_encrypted_messages.keys().cloned().collect();
699        for peer_id in waiting {
700            self.rearm_key_package_for_peer(&peer_id);
701        }
702    }
703
704    pub(super) fn kick_pending_session_reconciliation(&mut self, source_event: &str) {
705        let now = Utc::now();
706        let pending_peers = match self.collect_pending_session_peers() {
707            Ok(peers) => peers,
708            Err(err) => {
709                warn!(
710                    event = "session_confirmation_probe_scan_failed",
711                    source_event = %source_event,
712                    error = %err,
713                    "session_confirmation_probe_scan_failed"
714                );
715                return;
716            }
717        };
718
719        let pending_set: HashSet<String> = pending_peers.iter().cloned().collect();
720        self.confirmation_probe_due_at
721            .retain(|peer, _| pending_set.contains(peer));
722        self.confirmation_probe_unreachable_parks
723            .retain(|peer, _| pending_set.contains(peer));
724        self.confirmation_probe_superseded
725            .retain(|peer, _| pending_set.contains(peer));
726        // Not a plain `retain`: a probe whose peer left the pending set
727        // without passing through `clear_confirmation_recovery_tracking`
728        // would be forgotten while still queued.
729        let settled: Vec<String> = self
730            .confirmation_probe_outstanding
731            .keys()
732            .filter(|peer| !pending_set.contains(*peer))
733            .cloned()
734            .collect();
735        for peer in settled {
736            if let Some(probe) = self.confirmation_probe_outstanding.remove(&peer) {
737                self.forget_outbound_message(&probe);
738            }
739        }
740
741        for peer_id in pending_peers {
742            let due_at = self
743                .confirmation_probe_due_at
744                .get(&peer_id)
745                .copied()
746                .unwrap_or(now);
747            if due_at > now {
748                continue;
749            }
750            self.send_session_confirmation_probe(&peer_id, source_event);
751        }
752    }
753
754    /// Triggers a rate-limited re-key of the 1:1 session with `peer_id` after an
755    /// epoch-desync decrypt failure.
756    ///
757    /// The self-healing primitive is a two-part move, matching the unblock
758    /// `session_reset` flow: tear down our *own* stale session **and** advertise
759    /// a `session_reset` key package. The peer, on receiving it, drops its stale
760    /// session, auto-establishes a fresh one from our enclosed key package, and
761    /// Welcomes us back — which we, now session-less, simply *join*.
762    ///
763    /// Tearing down our session alone (without the reset key package) would
764    /// deadlock: the peer's original key package was consumed at first
765    /// establishment, so nothing could rebuild the channel, and the peer (still
766    /// believing its session is healthy) would never re-advertise one. Sending
767    /// the reset key package is what breaks that deadlock.
768    ///
769    /// Deleting the local session is also what makes convergence **symmetric and
770    /// single-round regardless of user-id ordering**. Because we keep no local
771    /// session, the peer's returning Welcome is joined via the fresh-session
772    /// path rather than the greater-id-adopts tiebreaker in
773    /// `handle_welcome_message` — so the lexicographically-smaller detector is no
774    /// longer stranded (the failure mode when the stale session was kept). Both
775    /// orderings are covered end-to-end by
776    /// `test_desync_dm_heals_end_to_end_when_detector_id_is_greater` and
777    /// `test_desync_dm_heals_end_to_end_when_detector_id_is_smaller`.
778    ///
779    /// Rate-limited via `rekey_due_at` to at most one re-key per
780    /// [`REKEY_INTERVAL_SECS`] per peer, so a peer replaying stale-epoch
781    /// ciphertext (or an injected wrong-epoch frame) cannot drive a re-key storm.
782    /// The interval is stamped before the send so a send error still counts
783    /// against the limit, and it is **never reset early** — not even by a
784    /// successful decrypt on the healed session. That is deliberate: a genuine
785    /// re-fork and a replayed old-epoch frame are indistinguishable at this layer
786    /// (both surface as `WrongEpoch`), so clearing the floor on heal would let an
787    /// attacker who lands one legit decrypt between replays defeat the limit and
788    /// force ~one teardown per inbound message. The floor lapses naturally after
789    /// [`REKEY_INTERVAL_SECS`]; during that window Tier 1 (un-ACK + sender
790    /// retries) keeps delivery honest, so the only cost of not resetting is that a
791    /// genuine second desync within the window heals up to one interval later —
792    /// an acceptable trade for a rare event against a bounded-churn guarantee.
793    ///
794    /// **SECURITY — this trigger is unauthenticated, and cannot be made
795    /// otherwise.** `peer_id` is the *wire-claimed* sender. Three facts compose:
796    /// `__MLS_ENC__` is a data-plane prefix, deliberately exempt from the
797    /// Ed25519 + derivation control-message gate (see `DATA_PLANE_PREFIXES`);
798    /// OpenMLS
799    /// validates the framing header — group id, then epoch — *before* any AEAD,
800    /// sender-data decryption or signature check, so `WrongEpoch` /
801    /// `NoPastEpochData` is produced with the sender still entirely unverified;
802    /// and a 1:1 slot id is `session:<a>:<b>` over two public user ids. So the
803    /// classification that lands here is reachable by **anyone who can inject a
804    /// frame** — no key material, no captured ciphertext, no session, no replay
805    /// needed. `test_forged_frame_reaches_session_desync_without_any_key_material`
806    /// builds such a frame from scratch to keep this statement honest. It is
807    /// inherent to MLS framing, not an OpenMLS defect, so no upgrade changes it.
808    ///
809    /// The `SenderIdentityMismatch` gate cannot help: it compares the MLS
810    /// credential to the claimed sender, and that credential only exists once
811    /// `process_message` **succeeds**. Every pre-authentication verdict is
812    /// structurally outside its reach.
813    ///
814    /// Because the trigger cannot be authenticated, the mitigation is that
815    /// acting on it is **harmless**, not that it is trusted:
816    /// - `SessionManager::decrypt_message` requires the envelope to name the
817    ///   slot shared with the claimed sender, so one derivable session id
818    ///   cannot be aimed at arbitrary peers, and `rekey_due_at` cannot be grown
819    ///   with attacker-chosen keys.
820    /// - [`REKEY_INTERVAL_SECS`] bounds this to one re-key per peer per window.
821    /// - The heal destroys nothing: queued plaintext survives a reset (it seals
822    ///   at flush time against the rebuilt session), and Tier 2 re-seals
823    ///   in-flight resends.
824    /// - Each re-key emits `SecurityWarningCode::SessionRekeyTriggered`, so a
825    ///   sustained rate — the signature of injected frames rather than a real
826    ///   fork — is visible to the app.
827    ///
828    /// **Residual:** an injector can still force bounded re-key churn on a pair
829    /// (delayed delivery, never lost). Closing that needs a signed
830    /// epoch-corroboration exchange before teardown; a liveness-only probe does
831    /// not work, since a healthy peer answers and we would tear down anyway.
832    /// Also see `docs/state-machines/session-lifecycle.md` ("Desync and heal")
833    /// and `docs/security/threat-model.md` (residual risk R2).
834    pub(super) fn schedule_session_rekey(&mut self, peer_id: &str) {
835        if !self.claim_rekey_slot(peer_id) {
836            return;
837        }
838
839        // A re-key is remotely triggerable and, until now, entirely silent to
840        // the app. A genuine fork produces these occasionally; a sustained rate
841        // for one peer is the signature of injected frames, which an operator
842        // cannot otherwise distinguish.
843        //
844        // Emitted on this path only. The warning names an epoch desync because
845        // that is what reached it, and an application asking for a re-key on
846        // its own schedule has not seen one: reusing the code there would put a
847        // fixed token in front of two different facts and leave an operator
848        // reading their own maintenance as the attack signature this exists to
849        // surface.
850        self.emit_security_warning(
851            peer_id,
852            SecurityWarningCode::SessionRekeyTriggered,
853            "1:1 session torn down and re-advertised after an epoch desync",
854        );
855        if self
856            .drive_session_rekey(peer_id, "after epoch desync")
857            .is_err()
858        {
859            // The advertisement did not leave, and this path discards the
860            // session anyway. What reached it is a fork: every later frame on
861            // that session decrypts to nothing, so keeping it buys a pair that
862            // stays silently broken rather than one that rebuilds when the
863            // peer is next heard from. The application-driven entry makes the
864            // opposite choice, and for the opposite reason: the session it was
865            // asked to rotate is healthy.
866            self.discard_local_session(peer_id);
867        }
868    }
869
870    /// Takes this peer's re-key slot, or reports that the floor has not lapsed.
871    ///
872    /// The floor is what bounds a peer replaying stale-epoch ciphertext to one
873    /// teardown per window rather than one per frame, and it is shared with the
874    /// application-driven entry deliberately: a re-key costs the same teardown
875    /// and re-exchange whoever asked for it, and a caller looping on it would
876    /// otherwise be able to do to a pair exactly what the bound exists to stop
877    /// an attacker doing.
878    pub(super) fn claim_rekey_slot(&mut self, peer_id: &str) -> bool {
879        let now = Utc::now();
880        if let Some(due_at) = self.rekey_due_at.get(peer_id) {
881            if *due_at > now {
882                return false;
883            }
884        }
885        // Bounded like every other map keyed by a wire-claimed id: the desync
886        // that reaches the caller is classified before MLS authenticates
887        // anything.
888        if !self.rekey_due_at.contains_key(peer_id)
889            && self.rekey_due_at.len() >= MAX_REKEY_TRACKED_PEERS
890        {
891            self.rekey_due_at.clear();
892        }
893        self.rekey_due_at.insert(
894            peer_id.to_string(),
895            now + ChronoDuration::seconds(REKEY_INTERVAL_SECS),
896        );
897        true
898    }
899
900    /// Advertises a reset key package and tears the local session down.
901    ///
902    /// This is the whole of what a re-key does on the wire, shared by the
903    /// desync path and by [`OfflineProtocol::rekey_session`] so the two cannot
904    /// drift into meaning different things to a peer. `reason` appears in the
905    /// log line only.
906    ///
907    /// # Why the advertisement goes first
908    ///
909    /// An `Err` here means **nothing was sent and nothing was torn down**.
910    /// [`TransportManager::send`](crate::TransportManager::send) reports an
911    /// error only once no transport has accepted the frame, so a failure is a
912    /// peer that has been told nothing, and a caller rotating a *healthy*
913    /// session must be able to leave it alone: discarding it on the strength
914    /// of a frame that never left strands the pair, with this side holding no
915    /// session, the peer holding one it will never be told to drop, and
916    /// nothing in either that re-opens the exchange.
917    ///
918    /// The order is invisible to a peer. Both steps run under one `&mut self`,
919    /// so no inbound frame can be processed between them, and what a peer sees
920    /// on success is what this path has always sent. The property the old
921    /// order was written for still holds for the same reason: by the time the
922    /// peer's returning Welcome is processed there is no local session left,
923    /// so it is *joined* rather than gated by the greater-id-adopts tiebreaker
924    /// in `handle_welcome_message`, and both sides rebuild in a single round.
925    pub(super) fn drive_session_rekey(&mut self, peer_id: &str, reason: &str) -> Result<()> {
926        if let Err(err) = self.send_key_package_to(peer_id, true) {
927            warn!(
928                event = "session_rekey_send_failed",
929                peer_id = %peer_id,
930                reason = %reason,
931                error = %err,
932                "session_rekey_send_failed"
933            );
934            return Err(err);
935        }
936        self.discard_local_session(peer_id);
937        info!(
938            event = "session_rekey_triggered",
939            peer_id = %peer_id,
940            reason = %reason,
941            "Triggered 1:1 session re-key"
942        );
943        Ok(())
944    }
945
946    /// Drops this side's 1:1 session with `peer_id`, best effort.
947    ///
948    /// Mirrors the unblock `session_reset` flow. A missing session is a no-op,
949    /// and a delete that fails is logged rather than raised: the peer has
950    /// already been sent the reset by every caller that reaches here, and the
951    /// rebuild plus the auto key-package exchange re-arm establishment either
952    /// way.
953    fn discard_local_session(&mut self, peer_id: &str) {
954        if self.has_mls_session(peer_id).unwrap_or(false) {
955            if let Err(err) = self.manual_mls_delete_session(peer_id) {
956                debug!(
957                    peer_id = %peer_id,
958                    error = %err,
959                    "rekey: failed to delete local stale session"
960                );
961            }
962        }
963    }
964
965    /// Gives back a re-key slot claimed for work that did not happen.
966    ///
967    /// Only the application-driven entry and the split heal may call this,
968    /// and only when the advertisement never left: no teardown happened, so
969    /// nothing was spent and a caller back in range a second later should not
970    /// be made to wait out a window that bounds teardowns. Both drop nothing
971    /// unless the push left. The desync path must **not**, since it discards
972    /// the session whether or not the reset was sent, and that is exactly the
973    /// churn the floor exists to bound.
974    pub(super) fn release_rekey_slot(&mut self, peer_id: &str) {
975        self.rekey_due_at.remove(peer_id);
976    }
977
978    pub(super) fn retry_pending_session_confirmations(&mut self) {
979        let now = Utc::now();
980        let due_peers: Vec<String> = self
981            .confirmation_retry_due_at
982            .iter()
983            .filter_map(|(peer_id, due_at)| {
984                if *due_at <= now {
985                    Some(peer_id.clone())
986                } else {
987                    None
988                }
989            })
990            .collect();
991
992        for peer_id in due_peers {
993            match self.has_mls_session(&peer_id) {
994                Ok(true) => {}
995                Ok(false) => {
996                    self.confirmation_retry_due_at.remove(&peer_id);
997                    continue;
998                }
999                Err(err) => {
1000                    warn!(
1001                        event = "session_confirmation_retry_scan_failed",
1002                        session_or_group_id = %peer_id,
1003                        error = %err,
1004                        "session_confirmation_retry_scan_failed"
1005                    );
1006                    self.confirmation_retry_due_at.insert(
1007                        peer_id,
1008                        now + ChronoDuration::seconds(CONFIRMATION_RETRY_INTERVAL_SECS),
1009                    );
1010                    continue;
1011                }
1012            }
1013
1014            if !self.can_confirm_from_source(&peer_id, "confirmation_retry") {
1015                debug!(
1016                    peer_id = %peer_id,
1017                    "Skipping confirmation retry until welcome send is at least attempted"
1018                );
1019                continue;
1020            }
1021
1022            match self.confirm_session_state(&peer_id, "confirmation_retry") {
1023                Ok(_) => {
1024                    let _ = self.flush_pending_messages(&peer_id);
1025                    self.process_pending_decryption(&peer_id);
1026                }
1027                Err(err) => {
1028                    warn!(
1029                        event = "session_confirmation_retry_failed",
1030                        session_or_group_id = %peer_id,
1031                        error = %err,
1032                        "session_confirmation_retry_failed"
1033                    );
1034                    self.confirmation_retry_due_at.insert(
1035                        peer_id,
1036                        now + ChronoDuration::seconds(CONFIRMATION_RETRY_INTERVAL_SECS),
1037                    );
1038                }
1039            }
1040        }
1041    }
1042
1043    /// Marks `peer_id` as a both-create owner gate — we kept our own group and
1044    /// must observe a group-aware decrypt before confirming — and persists it so
1045    /// an owner restart mid-convergence cannot let a stale plaintext probe/ack
1046    /// confirm prematurely. Only writes storage on a genuine insert.
1047    pub(super) fn mark_both_create_awaiting_decrypt(&mut self, peer_id: &str) {
1048        if self
1049            .both_create_awaiting_decrypt
1050            .insert(peer_id.to_string())
1051        {
1052            self.persist_both_create_awaiting_decrypt(peer_id);
1053        }
1054    }
1055
1056    /// Clears the both-create owner gate for `peer_id` (it has converged) from
1057    /// both memory and storage. Only writes storage on a genuine removal.
1058    pub(super) fn clear_both_create_awaiting_decrypt(&mut self, peer_id: &str) {
1059        if self.both_create_awaiting_decrypt.remove(peer_id) {
1060            self.delete_both_create_awaiting_decrypt(peer_id);
1061        }
1062    }
1063
1064    pub(super) fn can_confirm_from_source(&self, peer_id: &str, source_event: &str) -> bool {
1065        // Both-create owner: we kept our own group and are waiting for the peer to
1066        // prove it adopted *our* group. Only a successful decrypt is group-aware
1067        // proof of that; a plaintext probe/ack proves only that the peer holds
1068        // some session (possibly its own, pre-adoption group), so it must not
1069        // confirm us or we would stop retransmitting and strand the peer.
1070        if self.both_create_awaiting_decrypt.contains(peer_id)
1071            && source_event != "decrypt_success"
1072            && source_event != SESSION_CORROBORATED
1073        {
1074            return false;
1075        }
1076
1077        // A successful group decrypt is definitive, group-aware proof that the
1078        // peer adopted our 1:1 group. `decrypt_success` is raised only inside
1079        // `DecryptResult::Success` (see message_dispatch.rs): ciphertext on the
1080        // single stored `session:<a>:<b>` group actually decrypted, which is
1081        // possible only if the peer is a member. It must therefore confirm the
1082        // session regardless of our *local* Welcome's delivery state. Gating it on
1083        // a still-active local Welcome (the lifecycle match below) strands a
1084        // both-create owner whose own Welcome timed out to `Failed`/`Expired` on a
1085        // lossy or asymmetric BLE link (the Android↔iOS case): the owner decrypts
1086        // the peer's messages but never confirms, so every outbound send fails
1087        // `SessionNotReady` — it can receive but never reply. The both-create gate
1088        // above is preserved, so a both-create owner is still restricted to *only*
1089        // decrypt_success (a plaintext probe/ack can never confirm it).
1090        //
1091        // A corroborated probe or acknowledgement is the same proof without a
1092        // message to carry it: its tag is computable only by a member of this
1093        // very group at this epoch (`SessionCorroboration`).
1094        if source_event == "decrypt_success" || source_event == SESSION_CORROBORATED {
1095            return true;
1096        }
1097
1098        if !matches!(
1099            source_event,
1100            "confirmation_ack_received" | "confirmation_probe_received" | "confirmation_retry"
1101        ) {
1102            return true;
1103        }
1104
1105        match self.welcome_lifecycles.get(peer_id) {
1106            Some(record) => matches!(
1107                record.state,
1108                WelcomeDeliveryState::Sent | WelcomeDeliveryState::SendAttempted
1109            ),
1110            None => matches!(
1111                source_event,
1112                // Compatibility path for sessions created before welcome lifecycle
1113                // persistence existed. A plaintext probe/ack/retry can confirm here
1114                // (it is the only evidence such a legacy session has); a group-aware
1115                // decrypt already returned true above and never reaches this arm.
1116                "confirmation_ack_received" | "confirmation_probe_received" | "confirmation_retry"
1117            ),
1118        }
1119    }
1120
1121    /// Whether this device gives way to `remote_id` when the two hold
1122    /// competing groups for their slot: the greater address adopts.
1123    ///
1124    /// Addresses compare as addresses, hash bytes, the order
1125    /// `GroupId::for_session` canonicalizes slots with, because the bech32
1126    /// charset is not ASCII-monotonic and comparing the rendered strings would
1127    /// give a different answer than every other address comparison. Any total
1128    /// order converges as long as both peers use the same one; ids that are
1129    /// not both addresses keep string order.
1130    pub(super) fn gives_way_to(&self, remote_id: &str) -> bool {
1131        match (
1132            self.local_id.parse::<offline_protocol_core::Address>(),
1133            remote_id.parse::<offline_protocol_core::Address>(),
1134        ) {
1135            (Ok(local), Ok(remote)) => local > remote,
1136            _ => self.local_id.as_str() > remote_id,
1137        }
1138    }
1139
1140    /// This side's corroboration of its session with `peer_id` over `nonce`,
1141    /// as a frame body, or `None` without a session.
1142    fn corroboration_body(&self, peer_id: &str, nonce: &[u8]) -> Option<String> {
1143        let mls = self.mls_manager.as_ref()?;
1144        let manager = mls.read().ok()?;
1145        let (epoch, tag) = manager.session_corroboration(peer_id, nonce).ok()??;
1146        SessionCorroboration {
1147            epoch,
1148            nonce: nonce.to_vec(),
1149            tag,
1150        }
1151        .to_body()
1152    }
1153
1154    /// When this side's session with `peer_id` was established, which names
1155    /// that session: a new one is stamped on every create and join.
1156    fn session_incarnation(&self, peer_id: &str) -> Option<u64> {
1157        self.mls_manager
1158            .as_ref()
1159            .and_then(|mls| mls.read().ok())
1160            .and_then(|manager| manager.session_established_at_ms(peer_id).ok())
1161            .flatten()
1162    }
1163
1164    /// The body of a confirmation probe to `peer_id`: a challenge. `None`
1165    /// when this side cannot compute one (no session, a store read or export
1166    /// that failed), and the probe is then not sent. Never an empty body: the
1167    /// peer reads that as an older device and confirms on the plaintext
1168    /// rules, which is how a split used to confirm.
1169    ///
1170    /// One challenge per peer and session, carried by every probe until it
1171    /// is answered: an unconfirmed session is probed every few seconds, and a
1172    /// fresh nonce per probe would retire each challenge before an answer
1173    /// over a slow path (the mesh, a relay) could come back, so a split
1174    /// there would never heal.
1175    pub(super) fn session_challenge_body(&mut self, peer_id: &str) -> Option<String> {
1176        let incarnation = self.session_incarnation(peer_id);
1177        let nonce = match self.corroboration_challenges.get(peer_id) {
1178            Some((nonce, issued_under)) if *issued_under == incarnation => *nonce,
1179            _ => *uuid::Uuid::new_v4().as_bytes(),
1180        };
1181        let body = self.corroboration_body(peer_id, &nonce)?;
1182        if !self.corroboration_challenges.contains_key(peer_id)
1183            && self.corroboration_challenges.len() >= MAX_REKEY_TRACKED_PEERS
1184        {
1185            self.corroboration_challenges.clear();
1186        }
1187        self.corroboration_challenges
1188            .insert(peer_id.to_string(), (nonce, incarnation));
1189        Some(body)
1190    }
1191
1192    /// The body of an acknowledgement answering `probe`: this side's tag over
1193    /// the prober's own nonce, so the prober can tell an answer to its
1194    /// challenge from any other frame. Empty for a probe with no
1195    /// corroboration, from an older peer that reads no body. `None` when the
1196    /// tag cannot be computed, and the acknowledgement is then not sent: an
1197    /// empty body would be read as an older device and confirmed on the
1198    /// plaintext rules.
1199    pub(super) fn session_answer_body(
1200        &self,
1201        peer_id: &str,
1202        probe: Option<&SessionCorroboration>,
1203    ) -> Option<String> {
1204        match probe {
1205            None => Some(String::new()),
1206            Some(probe) => self.corroboration_body(peer_id, &probe.nonce),
1207        }
1208    }
1209
1210    /// Whether `nonce` is the challenge this side has out to `peer_id`, issued
1211    /// under the session it holds now. Spent by the asking: the first answer
1212    /// retires it, and the next probe carries a new one.
1213    pub(super) fn answers_live_challenge(&mut self, peer_id: &str, nonce: &[u8]) -> bool {
1214        let incarnation = self.session_incarnation(peer_id);
1215        match self.corroboration_challenges.get(peer_id) {
1216            Some((issued, issued_under)) if issued.as_slice() == nonce => {
1217                let live = *issued_under == incarnation;
1218                self.corroboration_challenges.remove(peer_id);
1219                live
1220            }
1221            _ => false,
1222        }
1223    }
1224
1225    /// What a probe or acknowledgement's corroboration from `peer_id` says
1226    /// about the session the two sides hold.
1227    pub(super) fn corroborate_session(
1228        &self,
1229        peer_id: &str,
1230        theirs: Option<&SessionCorroboration>,
1231    ) -> Corroboration {
1232        let Some(theirs) = theirs else {
1233            return Corroboration::Absent;
1234        };
1235        let ours = self
1236            .mls_manager
1237            .as_ref()
1238            .and_then(|mls| mls.read().ok())
1239            .and_then(|manager| manager.session_corroboration(peer_id, &theirs.nonce).ok())
1240            .flatten();
1241        match ours {
1242            Some((epoch, tag)) if epoch == theirs.epoch && tag == theirs.tag => {
1243                Corroboration::Agrees
1244            }
1245            Some((epoch, _)) if epoch == theirs.epoch => Corroboration::Split,
1246            _ => Corroboration::Inconclusive,
1247        }
1248    }
1249
1250    /// Asks `peer_id` to corroborate a session that just failed an ordinary
1251    /// decrypt, or that a probe showed split, at most once per re-key
1252    /// interval.
1253    ///
1254    /// Invariant: a split between two sides that have both confirmed is
1255    /// found by the first frame that fails between them. Confirmation probes
1256    /// stop once a session confirms, so without this a split that formed
1257    /// another way (an older peer's plaintext confirmation, a restored
1258    /// store) would fail every frame for the life of the pair.
1259    ///
1260    /// A failure only asks; it never tears anything down, which is what ADR
1261    /// 0006 requires of a class anyone corrupting a frame can reach. A
1262    /// healthy session answers with a matching tag and nothing happens.
1263    ///
1264    /// Sent apart from the confirmation probes, because a confirmed session
1265    /// has none: one tracked as a confirmation probe was withdrawn from the
1266    /// outbox by the next reconciliation scan, so a first send that did not
1267    /// leave was never retried. A confirmation probe whose own send fails
1268    /// still clears every probe queued for the peer, this one included; only
1269    /// an unconfirmed session reaches that, and its next probe carries the
1270    /// same challenge.
1271    pub(super) fn request_session_corroboration(&mut self, peer_id: &str) {
1272        let now = Utc::now();
1273        if self
1274            .corroboration_due_at
1275            .get(peer_id)
1276            .is_some_and(|due_at| *due_at > now)
1277        {
1278            return;
1279        }
1280        // Built first, so a tag that could not be computed this time does not
1281        // spend the window: the next failure asks again.
1282        let Some(challenge) = self.session_challenge_body(peer_id) else {
1283            return;
1284        };
1285        if !self.corroboration_due_at.contains_key(peer_id)
1286            && self.corroboration_due_at.len() >= MAX_REKEY_TRACKED_PEERS
1287        {
1288            self.corroboration_due_at.clear();
1289        }
1290        self.corroboration_due_at.insert(
1291            peer_id.to_string(),
1292            now + ChronoDuration::seconds(REKEY_INTERVAL_SECS),
1293        );
1294        let probe = format!("{}{}", internal_prefixes::SESSION_CONFIRM_PROBE, challenge);
1295        if let Err(err) = self.send_internal_message(peer_id, probe, MessagePriority::High) {
1296            debug!(peer_id = %peer_id, error = %err, "Corroboration probe deferred");
1297        }
1298    }
1299
1300    /// Asks again each peer whose split heal the re-key floor held back, once
1301    /// the floor has opened. The ask's own limit is lifted first: it was set
1302    /// by the ask that drew the held answer, and would otherwise swallow this
1303    /// one.
1304    pub(super) fn send_due_corroboration_reasks(&mut self) {
1305        let now = Utc::now();
1306        let due: Vec<String> = self
1307            .corroboration_reask_at
1308            .iter()
1309            .filter(|(_, at)| **at <= now)
1310            .map(|(peer, _)| peer.clone())
1311            .collect();
1312        for peer_id in due {
1313            self.corroboration_reask_at.remove(&peer_id);
1314            self.corroboration_due_at.remove(&peer_id);
1315            self.request_session_corroboration(&peer_id);
1316        }
1317    }
1318
1319    /// Acts on a corroboration from `peer_id` that shows a split.
1320    ///
1321    /// Invariant: a split is ended by exactly one side, the one that
1322    /// [`gives_way_to`](Self::gives_way_to) the other, and only on an
1323    /// acknowledgement answering a challenge it issued under the session it
1324    /// holds now (`answered_our_challenge`). Anything else, a probe, or an
1325    /// acknowledgement of an older challenge, may be a frame sealed before a
1326    /// heal and delivered late, an outbox retry carrying an earlier group's
1327    /// tag, or a replay: acting on those tore down healthy sessions. Such a
1328    /// frame only makes the greater side ask for itself, so a real split is
1329    /// still healed one round trip later.
1330    ///
1331    /// The heal gives up this side's group: a key package saying this side
1332    /// holds no session goes out first, then the local group is dropped. The
1333    /// peer answers as for any `no_session` package. When its own Welcome
1334    /// names the package, which it does after a both-create race because
1335    /// that package was never consumed, it sends that Welcome again and
1336    /// keeps its group, and this side joins it. A Welcome merely delayed
1337    /// rather than lost is then joined too, where a reset would have made the
1338    /// peer rebuild on the same package and left the delayed Welcome to split
1339    /// the pair again. Otherwise the peer rebuilds from the package. It
1340    /// shares the re-key floor, and a push that could not be sent drops
1341    /// nothing and gives the slot back: the peer was told nothing, and the
1342    /// next challenge asks again.
1343    pub(super) fn heal_split_session(&mut self, peer_id: &str, answered_our_challenge: bool) {
1344        let gives_way = self.gives_way_to(peer_id);
1345        if !(gives_way && answered_our_challenge) {
1346            if gives_way {
1347                self.request_session_corroboration(peer_id);
1348            }
1349            return;
1350        }
1351        if !self.claim_rekey_slot(peer_id) {
1352            // The answer spent the challenge, and a quiet confirmed pair has no
1353            // failing frame to ask again. Asking now would do nothing (the ask
1354            // that drew this answer has just set its own limit) and an answer
1355            // would meet the same floor, so ask once it opens.
1356            let opens = self
1357                .rekey_due_at
1358                .get(peer_id)
1359                .copied()
1360                .unwrap_or_else(|| Utc::now() + ChronoDuration::seconds(REKEY_INTERVAL_SECS));
1361            if !self.corroboration_reask_at.contains_key(peer_id)
1362                && self.corroboration_reask_at.len() >= MAX_REKEY_TRACKED_PEERS
1363            {
1364                self.corroboration_reask_at.clear();
1365            }
1366            self.corroboration_reask_at
1367                .insert(peer_id.to_string(), opens);
1368            return;
1369        }
1370        if let Err(err) = self.push_key_package(peer_id, false, true) {
1371            debug!(peer_id = %peer_id, error = %err, "Split heal deferred");
1372            self.release_rekey_slot(peer_id);
1373            return;
1374        }
1375        // Here and not on every mismatching frame: an unconfirmed split side
1376        // is probed every few seconds, and the event reaches the application
1377        // and telemetry. Once per heal that went out, bounded by the re-key
1378        // floor.
1379        self.emit_event(Event::convergence_diag(
1380            "session_split_detected".to_string(),
1381            peer_id.to_string(),
1382            "heals=true".to_string(),
1383        ));
1384        info!(peer_id = %peer_id, "Gave up our group of a split session to join the peer's");
1385        self.discard_local_session(peer_id);
1386    }
1387
1388    pub(super) fn abort_pending_session_for_peer(
1389        &mut self,
1390        peer_id: &str,
1391        reason: crate::events::WelcomeReasonCode,
1392    ) {
1393        self.drop_pending_queue_for_peer(peer_id, reason.welcome_failure_reason());
1394        if let Ok(state) = lock_shared_state(&self.shared_state) {
1395            state.emit_event(Event::secure_session_failed(
1396                peer_id.to_string(),
1397                format!("Welcome delivery failed: {}", reason.as_str()),
1398            ));
1399        }
1400    }
1401
1402    pub(super) fn maybe_emit_local_session_established(
1403        &self,
1404        peer_id: &str,
1405        context: MlsOperationContext,
1406    ) {
1407        let Some(record) = self.welcome_lifecycles.get(peer_id) else {
1408            return;
1409        };
1410        if !matches!(record.state, WelcomeDeliveryState::Sent) {
1411            return;
1412        }
1413        self.emit_mls_session_ready(peer_id, &record.group_id, context);
1414        if let Ok(state) = lock_shared_state(&self.shared_state) {
1415            state.emit_event(Event::secure_session_established(
1416                peer_id.to_string(),
1417                record.group_id.clone(),
1418                record.group_id.starts_with("session:"),
1419                true,
1420            ));
1421        }
1422    }
1423
1424    // ========================================================================
1425    // WELCOME LIFECYCLE
1426    // ========================================================================
1427
1428    /// `user_send` is true when a user's own send created the session: their
1429    /// message waits on this Welcome, so its first send must alert (see
1430    /// [`Self::try_send_welcome`]).
1431    pub(super) fn send_welcome_message(
1432        &mut self,
1433        recipient: &str,
1434        welcome: &WelcomeMessage,
1435        user_send: bool,
1436    ) -> Result<bool> {
1437        let serialized =
1438            serde_json::to_string(welcome).map_err(|e| Error::Serialization(e.to_string()))?;
1439        let content = format!("{}{}", internal_prefixes::WELCOME, serialized);
1440        let mut message =
1441            self.create_message(recipient, content, Some(MessagePriority::High), None)?;
1442        self.sign_control_message(&mut message)?;
1443        let group_id = welcome.group_id.as_str().to_string();
1444
1445        self.upsert_welcome_lifecycle(recipient, &group_id, message, "welcome_created")?;
1446        self.try_send_welcome(recipient, "welcome_initial_send", user_send)
1447    }
1448
1449    /// Parks a Welcome that currently has no transport carrier: refresh the
1450    /// carrier-relative TTL and schedule a slow re-check, WITHOUT consuming a
1451    /// retry attempt, transitioning state, or emitting a `WelcomeSendAttempted`
1452    /// event. A no-carrier send is a guaranteed failure, so re-attempting it at
1453    /// the data-plane retry rate only churns storage I/O and app events while a
1454    /// device is offline. The lifecycle stays in its current non-terminal state
1455    /// (Created on the first send, Failed on a later retry) so the retry queue
1456    /// re-polls it on [`WELCOME_NO_CARRIER_RETRY_SECS`]; `on_neighbor_discovered`
1457    /// re-arms it immediately when a carrier surfaces the peer. Returns
1458    /// `Ok(false)` (not sent).
1459    ///
1460    /// **Invariant: the refreshed TTL always outlives the retry being
1461    /// scheduled.** [`Self::try_send_welcome`] checks `expires_at` *before*
1462    /// running a due retry, so a record whose TTL lapsed while it waited gets
1463    /// expired terminally (`welcome_send_expired` + `secure_session_failed`) by
1464    /// the very timer meant to recover it. That is not hypothetical at the far
1465    /// end of the unreachable ladder in
1466    /// [`Self::apply_recipient_unreachable_failure`]: it reaches 480s at six
1467    /// consecutive parks and [`WELCOME_UNREACHABLE_RETRY_CAP_SECS`] (600s)
1468    /// beyond that, both above the 300s [`WELCOME_LIFECYCLE_TTL_SECS`]. A full
1469    /// extra interval of slack keeps the probe's own outcome — a verdict
1470    /// re-park or a confirm timeout — inside the window too. No-op for the 15s
1471    /// no-carrier park, whose interval is far below the TTL.
1472    fn park_welcome_no_carrier(
1473        &mut self,
1474        peer_id: &str,
1475        reason: crate::events::WelcomeReasonCode,
1476        transport_error: Option<&'static str>,
1477        retry_in_secs: i64,
1478    ) -> Result<bool> {
1479        let snapshot = {
1480            let Some(record) = self.welcome_lifecycles.get_mut(peer_id) else {
1481                return Ok(false);
1482            };
1483            let now = Utc::now();
1484            record.next_retry_at = Some(now + ChronoDuration::seconds(retry_in_secs));
1485            record.expires_at = now
1486                + ChronoDuration::seconds(
1487                    WELCOME_LIFECYCLE_TTL_SECS.max(retry_in_secs.saturating_mul(2)),
1488                );
1489            record.last_reason_code = Some(reason);
1490            record.last_transport_error = transport_error.map(str::to_string);
1491            record.clone()
1492        };
1493        self.persist_welcome_lifecycle_entry(&snapshot)?;
1494        debug!(
1495            peer_id = %peer_id,
1496            state = snapshot.state.as_str(),
1497            retry_in_secs,
1498            "Welcome parked: no transport carrier, re-checking on slow interval"
1499        );
1500        Ok(false)
1501    }
1502
1503    /// The Welcome is the frame that wakes an offline peer for a first
1504    /// message queued behind it, so the relay push is decided per send rather
1505    /// than baked into the stored frame: it alerts when `user_send` (the
1506    /// message is being queued right now) or when user content is already
1507    /// waiting for the peer, and is otherwise
1508    /// [`crate::constants::NO_ALERT_KEY`]-marked. Metadata is outside the
1509    /// control signature, so changing it here leaves the frame verifiable.
1510    pub(super) fn try_send_welcome(
1511        &mut self,
1512        peer_id: &str,
1513        source_event: &str,
1514        user_send: bool,
1515    ) -> Result<bool> {
1516        let now = Utc::now();
1517        let mut record = self
1518            .welcome_lifecycles
1519            .get(peer_id)
1520            .cloned()
1521            .ok_or_else(|| Error::Other(format!("Missing welcome lifecycle for {}", peer_id)))?;
1522
1523        if matches!(record.state, WelcomeDeliveryState::Sent) {
1524            return Ok(true);
1525        }
1526        if matches!(record.state, WelcomeDeliveryState::Expired) {
1527            return Ok(false);
1528        }
1529
1530        // Only honor the TTL when a carrier actually exists: a Welcome that has
1531        // never had a deliverable transport must not age out.
1532        let carrier_available = !self.transport_manager.get_available_transports().is_empty();
1533        if carrier_available && record.expires_at <= now {
1534            self.transition_welcome_state(peer_id, WelcomeDeliveryState::Expired, source_event)?;
1535            if let Ok(state) = lock_shared_state(&self.shared_state) {
1536                state.emit_event(Event::welcome_send_expired(
1537                    peer_id.to_string(),
1538                    record.welcome_message.id.as_str().to_string(),
1539                    record.attempt,
1540                    crate::events::WelcomeReasonCode::RetryExhausted,
1541                ));
1542            }
1543            self.abort_pending_session_for_peer(
1544                peer_id,
1545                crate::events::WelcomeReasonCode::RetryExhausted,
1546            );
1547            return Ok(false);
1548        }
1549
1550        // No carrier at all: a send is guaranteed to fail, so do NOT burn the
1551        // speculative attempt increment, the SendAttempted/Failed transitions
1552        // (two persisted state changes + two info logs), and a
1553        // `WelcomeSendAttempted` event on it every retry tick. Park the
1554        // lifecycle on a slow carrier-relative interval instead and re-check
1555        // later; `on_neighbor_discovered` re-arms it immediately when a carrier
1556        // surfaces the peer. This keeps an offline device quiet rather than
1557        // spinning storage I/O + app events at the data-plane retry rate.
1558        if !carrier_available {
1559            return self.park_welcome_no_carrier(
1560                peer_id,
1561                crate::events::WelcomeReasonCode::TransportUnavailable,
1562                None,
1563                WELCOME_NO_CARRIER_RETRY_SECS,
1564            );
1565        }
1566
1567        record.attempt = record.attempt.saturating_add(1);
1568        self.welcome_lifecycles
1569            .insert(peer_id.to_string(), record.clone());
1570        self.persist_welcome_lifecycle_entry(&record)?;
1571        self.transition_welcome_state(peer_id, WelcomeDeliveryState::SendAttempted, source_event)?;
1572
1573        if let Ok(state) = lock_shared_state(&self.shared_state) {
1574            state.emit_event(Event::welcome_send_attempted(
1575                peer_id.to_string(),
1576                record.welcome_message.id.as_str().to_string(),
1577                record.group_id.clone(),
1578                record.attempt,
1579            ));
1580        }
1581
1582        // A Welcome for a peer no carrier is known to reach is handed to
1583        // neighbours to carry, as the key package that produced it was: a
1584        // session between two devices that only reach each other through a
1585        // third is built from exactly these two frames crossing it. Carried
1586        // or sent, it is equally unproved, so it takes the same in-flight
1587        // path below and the peer's probe or decrypt is still what settles
1588        // it. A carried one waits the mesh confirmation timeout, since a
1589        // neighbour is a mesh link whatever the selector last picked.
1590        let mut welcome = record.welcome_message.clone();
1591        if user_send || self.has_user_content_waiting_for(peer_id) {
1592            welcome.metadata.remove(crate::constants::NO_ALERT_KEY);
1593        } else {
1594            welcome
1595                .metadata
1596                .insert(crate::constants::NO_ALERT_KEY.to_string(), "1".to_string());
1597        }
1598        let sent = self
1599            .send_or_carry_handshake(&welcome)
1600            .map(|handed_to_mesh| {
1601                if handed_to_mesh > 0 {
1602                    None
1603                } else {
1604                    self.transport_manager.current_transport()
1605                }
1606            });
1607
1608        match sent {
1609            Ok(transport_used) => {
1610                let mut updated =
1611                    self.welcome_lifecycles
1612                        .get(peer_id)
1613                        .cloned()
1614                        .ok_or_else(|| {
1615                            Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
1616                        })?;
1617
1618                // No transport's send() proves the peer reassembled and joined
1619                // the group, so keep the lifecycle NON-TERMINAL until an explicit
1620                // confirmation arrives and let the retry queue re-send on timeout.
1621                //
1622                //   - Internet send() only enqueues for platform polling; the
1623                //     confirmation is on_transport_send_confirmed.
1624                //   - A BLE / WiFi-Direct GATT WRITE_TYPE_NO_RESPONSE returning Ok
1625                //     only means the local stack accepted the bytes. A single lost
1626                //     fragment of the multi-fragment Welcome would otherwise leave
1627                //     the sender believing the session exists while the peer holds
1628                //     undecryptable ciphertext, with NO retry — permanently
1629                //     bricking the pair. The mesh confirmation is the peer proving
1630                //     the session (its probe / ack / welcome / decrypt), which marks
1631                //     the welcome Sent via confirm_session_state.
1632                let confirm_timeout_secs =
1633                    if matches!(transport_used, Some(TransportType::Internet)) {
1634                        WELCOME_INTERNET_CONFIRM_TIMEOUT_SECS
1635                    } else {
1636                        WELCOME_MESH_CONFIRM_TIMEOUT_SECS
1637                    };
1638                updated.next_retry_at =
1639                    Some(Utc::now() + ChronoDuration::seconds(confirm_timeout_secs));
1640                updated.last_reason_code = None;
1641                updated.last_transport_error = None;
1642                self.welcome_lifecycles
1643                    .insert(peer_id.to_string(), updated.clone());
1644                self.persist_welcome_lifecycle_entry(&updated)?;
1645
1646                // Seed the confirmation-probe scheduler for this pending peer so
1647                // the SENDER actively reconciles instead of waiting passively.
1648                //
1649                // The Welcome is now in flight but unconfirmed. The sender's only
1650                // built-in convergence path would otherwise be the receiver's
1651                // single proactive encrypted confirm on first Welcome receipt — a
1652                // single point of failure with no retry (a lost fragment, a
1653                // not-yet-ready encryptor, or the retransmit/owner_keep path on
1654                // the receiver all strand it). By marking a probe due now,
1655                // `run_throttled_reconciliation` (whose `has_pending_work` gate is
1656                // otherwise false on the sender, since it has no pending encrypted
1657                // messages) starts emitting `SESSION_CONFIRM_PROBE`s. The peer —
1658                // which holds the session — replies with `SESSION_CONFIRM_ACK`,
1659                // confirming us and marking this Welcome `Sent`. The entry is
1660                // dropped once confirmed (`clear_confirmation_recovery_tracking`)
1661                // or once the peer is no longer pending (`kick`'s retain), so this
1662                // adds no steady-state work after convergence.
1663                self.confirmation_probe_due_at
1664                    .insert(peer_id.to_string(), Utc::now());
1665
1666                Ok(false)
1667            }
1668            Err(err) => {
1669                let no_carrier = Self::is_no_carrier_error(&err);
1670                let reason = Self::map_welcome_reason_code(&err);
1671                // Classified, not rendered: the transport layer interpolates
1672                // the peer into `PeerNotReachable`, and this value is both
1673                // persisted with the lifecycle record and emitted on
1674                // `WelcomeSendFailed.transport_error`, where the scrubber
1675                // hashes `peer_id` beside it. The full error is logged above.
1676                self.apply_welcome_send_failure(
1677                    peer_id,
1678                    reason,
1679                    Some(send_failure_token(&err)),
1680                    no_carrier,
1681                    source_event,
1682                )
1683            }
1684        }
1685    }
1686
1687    /// Re-arms a stalled or expired outbound Welcome when a peer becomes
1688    /// reachable again, so a session that never converged while the peer was
1689    /// offline gets a fresh delivery attempt over the now-available carrier.
1690    ///
1691    /// No-op when the peer's session is already confirmed, when there is no
1692    /// Welcome lifecycle, or when the Welcome is already `Sent` or still
1693    /// in-flight (`SendAttempted`). An `Expired` lifecycle is rebuilt from its
1694    /// retained Welcome message — the MLS group is not torn down on expiry, so
1695    /// the stored Welcome is still valid to re-send — while a `Created`/`Failed`
1696    /// lifecycle has its budget and TTL reset and is retried immediately.
1697    pub(super) fn rearm_welcome_for_peer(&mut self, peer_id: &str, source_event: &str) {
1698        if self.confirmed_sessions.contains(peer_id) {
1699            return;
1700        }
1701        self.resend_welcome(peer_id, source_event, false);
1702    }
1703
1704    /// Sends the retained Welcome to `peer_id` again, unless it is in flight
1705    /// or delivered. When `peer_never_got_it`, one in flight or delivered is
1706    /// sent again too: the peer has said it holds no session, so neither
1707    /// attempt reached it.
1708    pub(super) fn resend_welcome(
1709        &mut self,
1710        peer_id: &str,
1711        source_event: &str,
1712        peer_never_got_it: bool,
1713    ) {
1714        let Some(record) = self.welcome_lifecycles.get(peer_id).cloned() else {
1715            return;
1716        };
1717        match record.state {
1718            WelcomeDeliveryState::SendAttempted | WelcomeDeliveryState::Sent
1719                if !peer_never_got_it => {}
1720            WelcomeDeliveryState::SendAttempted => {
1721                // Settled as lost, the one legal way back to a send.
1722                if self
1723                    .transition_welcome_state(peer_id, WelcomeDeliveryState::Failed, source_event)
1724                    .is_ok()
1725                {
1726                    self.resend_welcome(peer_id, source_event, false);
1727                }
1728            }
1729            WelcomeDeliveryState::Created | WelcomeDeliveryState::Failed => {
1730                if let Some(entry) = self.welcome_lifecycles.get_mut(peer_id) {
1731                    entry.attempt = 0;
1732                    // A reachability edge resets the unreachable-park
1733                    // escalation: the next relay verdict starts the interval
1734                    // ladder over from its base.
1735                    entry.unreachable_parks = 0;
1736                    entry.next_retry_at = Some(Utc::now());
1737                    entry.last_reason_code = None;
1738                    entry.last_transport_error = None;
1739                    entry.expires_at =
1740                        Utc::now() + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS);
1741                }
1742                if let Err(err) = self.try_send_welcome(peer_id, source_event, false) {
1743                    warn!(
1744                        peer_id = %peer_id,
1745                        error = %err,
1746                        "Failed to re-arm welcome on peer reachability"
1747                    );
1748                }
1749            }
1750            WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired => {
1751                // Expired is terminal in the lifecycle state machine, and Sent
1752                // is settled, so rebuild from the retained Welcome message
1753                // (upsert permits overwrite from either) before re-sending.
1754                let welcome_message = record.welcome_message.clone();
1755                let group_id = record.group_id.clone();
1756                if let Err(err) =
1757                    self.upsert_welcome_lifecycle(peer_id, &group_id, welcome_message, source_event)
1758                {
1759                    warn!(
1760                        peer_id = %peer_id,
1761                        error = %err,
1762                        "Failed to rebuild expired welcome on peer reachability"
1763                    );
1764                    return;
1765                }
1766                if let Err(err) = self.try_send_welcome(peer_id, source_event, false) {
1767                    warn!(
1768                        peer_id = %peer_id,
1769                        error = %err,
1770                        "Failed to re-arm expired welcome on peer reachability"
1771                    );
1772                }
1773            }
1774        }
1775    }
1776
1777    /// Pushes our key package to `peer_id` again when an earlier push produced
1778    /// no session.
1779    ///
1780    /// Invariant: a key package we pushed stands in for an exchange only until
1781    /// the interval lapses; past it, no session with the peer means the push
1782    /// is treated as lost. Without this the push is fire-once: a carrier can
1783    /// drop that one frame without reporting it (a superseded peer stream
1784    /// drops its late frames, `docs/spec/stream-framing.md`), the peer is already marked
1785    /// as sent to, and when both sides' pushes are lost neither ever sends
1786    /// again, so the pair never forms a session.
1787    ///
1788    /// The re-push is the same package (ADR 0012): the pool hands a peer its
1789    /// own live package until a Welcome consumes it, so no key material is
1790    /// minted. A session that exists but is unconfirmed is left to the Welcome
1791    /// lifecycle and the confirmation probes, which own that half.
1792    ///
1793    /// Only a peer already pushed to qualifies; the first push belongs to
1794    /// discovery, except for a peer only the mesh reaches, which discovery
1795    /// never names (see [`Self::push_key_package_across_mesh`]), and a peer
1796    /// only a relay reaches, whose published package is fetched instead (see
1797    /// [`Self::request_nostr_key_package_for`]). The stamp is taken before the send, so a send that fails
1798    /// still waits out the interval instead of being retried on every tick.
1799    /// A send that fails does not count as a repeat, though: the backoff is
1800    /// for a peer that was asked and did not answer, and a peer whose frame
1801    /// never left was not asked. Counting it would walk an unreachable peer
1802    /// up to the cap while it is away, and nothing resets the count when it
1803    /// comes back over a carrier with no neighbour-lost event.
1804    ///
1805    /// A blocked peer is never pushed to. Blocking is bidirectional, and the
1806    /// tick reaches this for any peer with a queued message, which a block
1807    /// leaves in place; without the check the tick would advertise our
1808    /// presence and key package to the blocked peer every window.
1809    pub(super) fn rearm_key_package_for_peer(&mut self, peer_id: &str) {
1810        if !self.config.encryption.enabled
1811            || !self.config.encryption.auto_key_exchange
1812            || self.confirmed_sessions.contains(peer_id)
1813            || self.is_user_blocked(peer_id)
1814        {
1815            return;
1816        }
1817        let Some((sent_at, repeats)) = self.key_package_sent_to.get_mut(peer_id) else {
1818            self.push_key_package_across_mesh(peer_id);
1819            // A peer only a relay reaches is asked about instead, on the
1820            // transport's own interval: a query no relay answered (none held
1821            // a record yet, or none was live) is not asked again by anything
1822            // else while the message waits.
1823            if !self.pending_key_packages.contains_key(peer_id) {
1824                self.request_nostr_key_package_for(peer_id);
1825            }
1826            return;
1827        };
1828        if sent_at.elapsed() < StdDuration::from_secs(key_package_resend_wait_secs(*repeats)) {
1829            return;
1830        }
1831        // Stamped whatever the storage check below says: it is storage I/O,
1832        // and discovery runs on every inbound body, so an unconfirmed session
1833        // (or a failing read) must cost one check per wait, not one per
1834        // frame. A check that finds a session, and a push that leaves, each
1835        // double the wait, so a peer that never answers settles at one push
1836        // per cap.
1837        *sent_at = Instant::now();
1838        let backed_off = match self.has_mls_session(peer_id) {
1839            Ok(false) => {
1840                debug!(peer_id = %peer_id, "Key package produced no session, pushing it again");
1841                match self.send_key_package_to(peer_id, false) {
1842                    Ok(()) => true,
1843                    Err(e) => {
1844                        debug!(peer_id = %peer_id, error = %e, "Key package re-push deferred");
1845                        false
1846                    }
1847                }
1848            }
1849            Ok(true) | Err(_) => true,
1850        };
1851        if backed_off {
1852            if let Some((_, repeats)) = self.key_package_sent_to.get_mut(peer_id) {
1853                *repeats = repeats.saturating_add(1);
1854            }
1855        }
1856    }
1857
1858    /// Makes the first key-package push to a peer that only the mesh can
1859    /// reach, for whom a message is waiting.
1860    ///
1861    /// Invariant: a message queued for a session is enough to start that
1862    /// session over any path that can carry a frame to the peer. The first
1863    /// push is otherwise discovery's, and discovery names only the device a
1864    /// frame arrived from: on a peer two hops away every frame arrives
1865    /// attributed to the device in the middle, so the pair is never
1866    /// discovered and, before this, never started a session. A message to a
1867    /// device the sender had never been next to waited out its seven days.
1868    ///
1869    /// Three things keep it narrow:
1870    ///
1871    /// - **Only with a message waiting.** The caller is the reconciliation
1872    ///   tick, for peers holding a queued message, so the package goes to
1873    ///   addresses the application asked to reach, never to whoever is heard.
1874    /// - **Only when nothing else reaches the peer.** A direct link means
1875    ///   discovery has already pushed, and an infrastructure carrier has its
1876    ///   own exchange (for Nostr, fetching the peer's published package; see
1877    ///   [`Self::request_nostr_key_package_for`]); both are left alone.
1878    /// - **Only once by this route.** A push that a neighbour took is recorded
1879    ///   like any other, so every repeat after it is the re-push ladder's, on
1880    ///   its doubling wait. A push nobody took is tried again on the next
1881    ///   tick, and costs nothing new: the pool hands the peer the same
1882    ///   package until a Welcome consumes it (ADR 0012).
1883    fn push_key_package_across_mesh(&mut self, peer_id: &str) {
1884        if self.can_reach_recipient(peer_id) || self.transport_manager.mesh_neighbors().is_empty() {
1885            return;
1886        }
1887        if !matches!(self.has_mls_session(peer_id), Ok(false)) {
1888            return;
1889        }
1890        debug!(peer_id = %peer_id, "Message waiting for a peer only the mesh reaches, pushing our key package");
1891        if let Err(e) = self.send_key_package_to(peer_id, false) {
1892            debug!(peer_id = %peer_id, error = %e, "Key package push across the mesh deferred");
1893        }
1894    }
1895
1896    /// Ingests an authoritative per-peer presence signal (the internet relay's
1897    /// `PresenceStatusWithLastSeen` answer, bridged by the platform layer).
1898    ///
1899    /// `online` drives the same reachability machinery as a transport-level
1900    /// discovery — outbox flush, welcome re-arm, auto key exchange — plus a
1901    /// rescue for the store-less-relay hole: a welcome whose wire send was
1902    /// confirmed (`Sent`) while the peer was actually offline was dropped by
1903    /// the relay (it forwards or pushes, never stores), and
1904    /// `Self::rearm_welcome_for_peer` deliberately never touches `Sent`.
1905    /// The peer being provably online is the safe moment to rebuild and
1906    /// re-send it: if the original did land, the receiver dedups by message
1907    /// id and the confirmation probe resolves the lifecycle.
1908    ///
1909    /// `offline` parks retry-pending welcomes pending a reachability edge,
1910    /// without burning budget. In-flight records (`SendAttempted`) are left
1911    /// to their confirm deadline — a `DeliveryError`-driven failure or the
1912    /// confirm timeout resolves them, after which the next offline tick parks.
1913    ///
1914    /// The welcome re-arm/re-send on the online edge is throttled per peer
1915    /// with exponential backoff (`WELCOME_PRESENCE_RESCUE_BASE_SECS` ..
1916    /// `WELCOME_PRESENCE_RESCUE_MAX_SECS`): presence answers arrive on the
1917    /// platform's ~20s watch tick, and a peer that is online but can never
1918    /// prove the session (stale key package after a reinstall, incompatible
1919    /// version) must not be re-sent the multi-frame MLS welcome on every
1920    /// tick forever. A throttled online tick still flushes queued data-plane
1921    /// traffic for the peer.
1922    ///
1923    /// Emits `presence_updated` naming the carrier that answered — this
1924    /// function is the gateway-sourced half of the unified stream and must
1925    /// only be fed a gateway's own observation (bridged via
1926    /// `internet_peer_presence` or `reticulum_peer_presence`); peer-sent
1927    /// `__PRESENCE__` self-reports are handled in `message_dispatch` and emit
1928    /// `source: Peer`. Self, blocked, or empty peer ids are dropped entirely:
1929    /// an app waiting on `presence_updated` for a blocked peer will never see
1930    /// it.
1931    pub fn on_peer_presence(&mut self, peer_id: &str, online: bool, last_seen_ms: Option<i64>) {
1932        self.on_peer_presence_via(peer_id, online, last_seen_ms, GatewayCarrier::Internet);
1933    }
1934
1935    /// [`Self::on_peer_presence`], for whichever gateway answered.
1936    ///
1937    /// The carrier is threaded rather than assumed because every effect below
1938    /// is carrier-scoped and silently wrong under the wrong one: the fact is
1939    /// recorded against the carrier that made the claim, the un-park flush is
1940    /// pinned to the carrier that just proved it can reach the peer, and the
1941    /// event names the carrier so an app filtering on relay-observed presence
1942    /// still can. A Reticulum gateway's answer forcing a send over the
1943    /// Internet transport would be a reachability proof about one carrier
1944    /// spent on another.
1945    pub fn on_peer_presence_via(
1946        &mut self,
1947        peer_id: &str,
1948        online: bool,
1949        last_seen_ms: Option<i64>,
1950        carrier: GatewayCarrier,
1951    ) {
1952        if peer_id.is_empty() || peer_id == self.local_id || self.is_user_blocked(peer_id) {
1953            return;
1954        }
1955        // A presence answer is a claim about this recipient on the carrier
1956        // that answered, so it is recorded as one. It supersedes an earlier
1957        // verdict for that carrier: both answer the same question and this is
1958        // the later answer. Presence facts decay faster than verdicts, since
1959        // this is a third party's report about someone else's connection.
1960        self.reachability.record(
1961            peer_id,
1962            carrier.transport(),
1963            if online {
1964                Claim::Reachable
1965            } else {
1966                Claim::Unreachable
1967            },
1968            FactSource::GatewayPresence,
1969            offline_protocol_core::time::Instant::now(),
1970        );
1971        if online {
1972            // Carrier-scoped reachability proof: un-park and re-drive the
1973            // peer's DMs over that carrier before anything else
1974            // (see `flush_outbox_for_peer_via` — this also cancels
1975            // unanswerable mesh-probe ACKs that would otherwise hold the
1976            // messages hostage past this edge). The rescue branch's inner
1977            // flush then finds successfully re-driven entries awaiting
1978            // fresh ACKs and leaves them alone; entries whose forced send
1979            // *failed* are picked up again, so the inner flush keeps the
1980            // carrier override — DORS must not re-route them into the
1981            // mesh void with the park counter already cleared.
1982            self.flush_outbox_for_peer_via(peer_id, Some(carrier.transport()));
1983            if self.welcome_rescue_permitted(peer_id) {
1984                self.on_neighbor_discovered_via(peer_id, Some(carrier.transport()));
1985                self.resend_unconfirmed_sent_welcome(peer_id, "peer_presence_online");
1986                self.note_welcome_rescue_attempt(peer_id);
1987            }
1988            // A reachability edge resets the session-confirmation probe's
1989            // unreachable backoff: clear the escalation counter and mark a probe
1990            // due now, so a peer that went unreachable while unconfirmed is
1991            // re-probed immediately and its session converges rather than
1992            // waiting out the (up to 600s) backoff.
1993            if self.confirmation_probe_due_at.contains_key(peer_id) {
1994                self.confirmation_probe_unreachable_parks.remove(peer_id);
1995                self.confirmation_probe_due_at
1996                    .insert(peer_id.to_string(), Utc::now());
1997            }
1998        } else {
1999            self.park_welcome_peer_unreachable(peer_id);
2000        }
2001
2002        let status = if online {
2003            crate::events::PresenceStatus::Online
2004        } else {
2005            crate::events::PresenceStatus::Offline
2006        };
2007        self.emit_event(Event::presence_updated_from(
2008            peer_id.to_string(),
2009            status,
2010            Utc::now().timestamp_millis(),
2011            last_seen_ms,
2012            carrier.presence_source(),
2013        ));
2014    }
2015
2016    /// Peers with a welcome lifecycle whose session has not been proven —
2017    /// the internet presence watch set. Includes `Sent` records (wire-
2018    /// confirmed but never session-confirmed): only a presence signal can
2019    /// rescue those over a store-less relay.
2020    /// Lifecycles older than `WELCOME_WATCHLIST_MAX_AGE_SECS` are excluded:
2021    /// a permanently-dead peer must not occupy watch-rotation slots (and keep
2022    /// its parked record alive via `expires_at` pushes) forever.
2023    pub fn welcome_pending_peers(&self) -> Vec<String> {
2024        let watch_cutoff = Utc::now() - ChronoDuration::seconds(WELCOME_WATCHLIST_MAX_AGE_SECS);
2025        self.welcome_lifecycles
2026            .iter()
2027            .filter(|(peer_id, record)| {
2028                !self.confirmed_sessions.contains(*peer_id) && record.created_at > watch_cutoff
2029            })
2030            .map(|(peer_id, _)| peer_id.clone())
2031            .collect()
2032    }
2033
2034    /// Rebuilds and re-sends a `Sent`-but-session-unconfirmed welcome (see
2035    /// [`Self::on_peer_presence`]). No-op for any other state.
2036    fn resend_unconfirmed_sent_welcome(&mut self, peer_id: &str, source_event: &str) {
2037        if self.confirmed_sessions.contains(peer_id) {
2038            return;
2039        }
2040        let Some(record) = self.welcome_lifecycles.get(peer_id) else {
2041            return;
2042        };
2043        if !matches!(record.state, WelcomeDeliveryState::Sent) {
2044            return;
2045        }
2046        let welcome_message = record.welcome_message.clone();
2047        let group_id = record.group_id.clone();
2048        if let Err(err) =
2049            self.upsert_welcome_lifecycle(peer_id, &group_id, welcome_message, source_event)
2050        {
2051            warn!(
2052                peer_id = %peer_id,
2053                error = %err,
2054                "Failed to rebuild unconfirmed sent welcome on presence"
2055            );
2056            return;
2057        }
2058        if let Err(err) = self.try_send_welcome(peer_id, source_event, false) {
2059            warn!(
2060                peer_id = %peer_id,
2061                error = %err,
2062                "Failed to re-send unconfirmed welcome on presence"
2063            );
2064        }
2065    }
2066
2067    /// True when a presence-online rescue for this peer is currently allowed
2068    /// by the per-peer backoff. Trivially true (and the throttle entry is
2069    /// dropped) once the peer has no unconfirmed welcome — there is nothing
2070    /// left to throttle.
2071    fn welcome_rescue_permitted(&mut self, peer_id: &str) -> bool {
2072        let pending = self.welcome_lifecycles.contains_key(peer_id)
2073            && !self.confirmed_sessions.contains(peer_id);
2074        if !pending {
2075            self.welcome_presence_rescue.remove(peer_id);
2076            return true;
2077        }
2078        self.welcome_presence_rescue
2079            .get(peer_id)
2080            .is_none_or(|throttle| throttle.next_allowed_at <= Utc::now())
2081    }
2082
2083    /// Records that a presence-online tick ran the rescue actions for a peer
2084    /// that still has an unconfirmed welcome, doubling the wait before the
2085    /// next one (base 40s, capped at 10 minutes). The counter is per
2086    /// convergence attempt: the entry is dropped (via
2087    /// [`Self::welcome_rescue_permitted`]) as soon as the session confirms
2088    /// or the lifecycle goes away.
2089    fn note_welcome_rescue_attempt(&mut self, peer_id: &str) {
2090        let pending = self.welcome_lifecycles.contains_key(peer_id)
2091            && !self.confirmed_sessions.contains(peer_id);
2092        if !pending {
2093            self.welcome_presence_rescue.remove(peer_id);
2094            return;
2095        }
2096        let throttle = self
2097            .welcome_presence_rescue
2098            .entry(peer_id.to_string())
2099            .or_insert(PresenceRescueThrottle {
2100                next_allowed_at: Utc::now(),
2101                rescues: 0,
2102            });
2103        // Shift is clamped to 8, so 40 << 8 = 10240 max before the cap —
2104        // no overflow risk.
2105        let backoff_secs = (WELCOME_PRESENCE_RESCUE_BASE_SECS << throttle.rescues.min(8))
2106            .min(WELCOME_PRESENCE_RESCUE_MAX_SECS);
2107        throttle.next_allowed_at = Utc::now() + ChronoDuration::seconds(backoff_secs);
2108        throttle.rescues = throttle.rescues.saturating_add(1);
2109    }
2110
2111    /// Parks a welcome pending a peer-reachability edge: reason
2112    /// `PeerUnreachable`, **no timed retry**. The carrier (relay socket) is
2113    /// healthy — only this peer is unreachable on it — so the *data-plane*
2114    /// retry this cancels would re-send the welcome into another
2115    /// `DeliveryError` while burning the real-delivery budget that a
2116    /// carrier-backed failure already charged. Recovery is edge-driven
2117    /// instead: `on_peer_presence(online)` and `on_neighbor_discovered` re-arm
2118    /// via `rearm_welcome_for_peer`, and the peer stays on the presence
2119    /// watchlist (`welcome_pending_peers`) so the platform keeps polling for
2120    /// that edge. The TTL is pushed like a no-carrier park: an unreachable
2121    /// peer must not age the welcome out.
2122    ///
2123    /// Its caller is responsible for not applying this to a record already
2124    /// holding an escalating unreachable probe — see
2125    /// [`Self::park_welcome_peer_unreachable`], which is what distinguishes
2126    /// the budget-burning data-plane retry cancelled here from the
2127    /// budget-refunded probe that must survive.
2128    fn park_welcome_awaiting_peer(&mut self, peer_id: &str) -> Result<()> {
2129        let snapshot = {
2130            let Some(record) = self.welcome_lifecycles.get_mut(peer_id) else {
2131                return Ok(());
2132            };
2133            record.next_retry_at = None;
2134            record.expires_at = Utc::now() + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS);
2135            record.last_reason_code = Some(crate::events::WelcomeReasonCode::PeerUnreachable);
2136            record.clone()
2137        };
2138        self.persist_welcome_lifecycle_entry(&snapshot)?;
2139        debug!(
2140            peer_id = %peer_id,
2141            state = snapshot.state.as_str(),
2142            "Welcome parked: peer unreachable on carrier, awaiting reachability edge"
2143        );
2144        Ok(())
2145    }
2146
2147    /// Parks a retry-pending welcome for a peer the relay reports offline.
2148    /// Only `Created`/`Failed` records park: `SendAttempted` keeps its
2149    /// confirm deadline, and `Sent`/`Expired` are handled on the online edge
2150    /// (or corrected by an attributed `DeliveryError`). Skipped entirely when
2151    /// a mesh carrier is also available — relay presence is only
2152    /// authoritative for the internet path, and the data-plane retry track
2153    /// may still deliver over BLE / WiFi-Direct.
2154    ///
2155    /// Never cancels an escalating unreachable probe: a record that already
2156    /// holds one was parked by [`Self::apply_recipient_unreachable_failure`],
2157    /// which already set the same reason code and pushed the same TTL — so this
2158    /// adds nothing and would only downgrade the probe to edge-only. That
2159    /// matters because on an internet-only device this runs once per presence
2160    /// rotation for as long as the peer is down, which would otherwise
2161    /// re-strand the welcome after every single verdict. The distinction is
2162    /// budget: the data-plane retry this *does* cancel was charged an attempt
2163    /// by a carrier-backed failure, while the probe refunds its attempt on
2164    /// every verdict.
2165    ///
2166    /// `last_reason_code` — not `unreachable_parks` alone — is what encodes
2167    /// that distinction. The counter is sticky (only `rearm_welcome_for_peer`
2168    /// clears it), so gating on it by itself would keep shielding the record
2169    /// after the probe track has stopped protecting it: past
2170    /// [`WELCOME_WATCHLIST_MAX_AGE_SECS`] a confirm timeout no longer re-parks
2171    /// (see [`Self::welcome_probe_repark_permitted`]) and instead charges an
2172    /// attempt and arms a plain data-plane retry — precisely the budget-burner
2173    /// this park exists to cancel, and left running it expires the welcome at
2174    /// `max_retries`. Note `park_welcome_awaiting_peer` also stamps
2175    /// `PeerUnreachable` but leaves `next_retry_at = None`, so the
2176    /// `next_retry_at.is_some()` conjunct below is load-bearing, not
2177    /// redundant: the reason code *and* a scheduled retry together are what
2178    /// identify a live probe. A carrier-backed failure stamps
2179    /// `Timeout`/`SendFailed` and a successful send clears the code entirely.
2180    fn park_welcome_peer_unreachable(&mut self, peer_id: &str) {
2181        if self.confirmed_sessions.contains(peer_id) {
2182            return;
2183        }
2184        let Some(record) = self.welcome_lifecycles.get(peer_id) else {
2185            return;
2186        };
2187        if !matches!(
2188            record.state,
2189            WelcomeDeliveryState::Created | WelcomeDeliveryState::Failed
2190        ) {
2191            return;
2192        }
2193        if record.last_reason_code == Some(crate::events::WelcomeReasonCode::PeerUnreachable)
2194            && record.unreachable_parks > 0
2195            && record.next_retry_at.is_some()
2196        {
2197            return;
2198        }
2199        // Local mesh carriers only (BLE / WiFi-Direct): Nostr and Reticulum
2200        // are internet-dependent, so their availability must not veto the
2201        // park — mirrors the carrier guard in
2202        // `apply_recipient_unreachable_failure`.
2203        if self
2204            .transport_manager
2205            .get_available_transports()
2206            .keys()
2207            .any(|transport| matches!(transport, TransportType::BLE | TransportType::WiFiDirect))
2208        {
2209            return;
2210        }
2211        let _ = self.park_welcome_awaiting_peer(peer_id);
2212    }
2213
2214    /// True when an unconfirmed welcome send should resolve as another
2215    /// unreachable verdict ([`Self::apply_recipient_unreachable_failure`])
2216    /// rather than as a carrier-backed failure that ages the record.
2217    ///
2218    /// A live `unreachable_parks` counter means the relay declared the peer
2219    /// offline and no reachability edge has fired since (every edge clears it
2220    /// via `rearm_welcome_for_peer`), so the send that just went unconfirmed
2221    /// was the escalating probe, not a delivery attempt. Without this the
2222    /// probe destroys the very welcome it exists to recover: the relay
2223    /// accepts the frame whenever its push fallback succeeds, which returns
2224    /// *no* `DeliveryError` at all, so the probe resolves at the 10s confirm
2225    /// timeout instead. Treated as carrier-backed that charges the attempt
2226    /// and arms a plain data-plane retry, and since nothing in that ladder
2227    /// pushes `expires_at`, the record walks into `should_expire` within one
2228    /// TTL window — terminal `welcome_send_expired` + `secure_session_failed`
2229    /// for a peer that is merely offline. Re-parking instead refunds the
2230    /// attempt, escalates the interval and pushes the TTL, mirroring the DM
2231    /// path's [`Self::try_repark_exhausted_dm`]: settlement stays reserved
2232    /// for delivery or the record genuinely ageing out below.
2233    ///
2234    /// Only the *presence-offline* answer cancelled that retry before, which
2235    /// is not a defense at all for the case this whole probe exists to serve
2236    /// — a headless consumer that never polls presence.
2237    ///
2238    /// Bounded by [`WELCOME_WATCHLIST_MAX_AGE_SECS`] measured from
2239    /// `created_at`, the same threshold that stops watching a peer as
2240    /// permanently dead: past it the counter no longer shields the record and
2241    /// normal ageing expires it. This is the welcome's twin of the DM probe's
2242    /// absolute bound (`first_sent_at` vs the outbox absolute lifetime) —
2243    /// without it a peer that never returns keeps a welcome probing at the
2244    /// 600s cap forever.
2245    ///
2246    /// Deliberately narrow to `Timeout`: a `SendFailed`/`TransportUnavailable`
2247    /// failure is evidence about the *carrier*, not about this peer, and
2248    /// keeps ageing the record normally.
2249    fn welcome_probe_repark_permitted(&self, peer_id: &str) -> bool {
2250        let Some(record) = self.welcome_lifecycles.get(peer_id) else {
2251            return false;
2252        };
2253        record.unreachable_parks > 0
2254            && record.created_at
2255                > Utc::now() - ChronoDuration::seconds(WELCOME_WATCHLIST_MAX_AGE_SECS)
2256    }
2257
2258    /// Applies the relay's authoritative "recipient offline" verdict
2259    /// (a `recipient_unreachable`-tagged transport failure) to a welcome
2260    /// lifecycle. Also reached *without* a relay verdict when a probe's
2261    /// confirm timeout resolves while the park counter is still live — the
2262    /// same conclusion inferred rather than relayed, see
2263    /// [`Self::welcome_probe_repark_permitted`].
2264    ///
2265    /// The bridge wire-confirms on socket-write success, so by the time the
2266    /// relay's `DeliveryError` arrives the record is normally already `Sent`
2267    /// — a *false* Sent: the store-less relay dropped the content. Correct
2268    /// it back to `Failed` (emitting `welcome_send_failed` so the app's
2269    /// earlier `welcome_send_succeeded` is superseded), refund the attempt
2270    /// (the peer never saw the frame), and park pending a reachability edge.
2271    /// `SendAttempted` (the failure raced ahead of the wire confirm) gets
2272    /// the same treatment; `Created`/`Failed` records just park. Terminal
2273    /// `Expired` records are left to the online edge, which rebuilds them.
2274    ///
2275    /// The record always keeps a timed retry whose interval escalates per
2276    /// consecutive park. Relay presence is authoritative only for the
2277    /// internet path — with a mesh carrier live the peer may still be
2278    /// reachable over BLE / WiFi-Direct (DORS can pick internet even with
2279    /// mesh present) — but an internet-only device needs the timed track just
2280    /// as much: parking it edge-only left the welcome dependent solely on a
2281    /// presence-online answer, i.e. on the platform's presence-polling
2282    /// cadence, which is the same dead end that stalled parked DMs (see
2283    /// [`Self::park_unreachable_dm`]). The escalation (15s → 600s cap) is
2284    /// what bounds the retry: DORS may keep routing to the internet path,
2285    /// where each round trips another budget-refunded `DeliveryError`, and
2286    /// without escalation that would be an unbounded resend loop into the
2287    /// relay. Every verdict refunds the attempt and pushes the TTL — past the
2288    /// scheduled probe, see the invariant on [`Self::park_welcome_no_carrier`]
2289    /// — so the probes never age the welcome out or burn its real-delivery
2290    /// budget, and the counter resets on any reachability edge
2291    /// (`rearm_welcome_for_peer`).
2292    pub(super) fn apply_recipient_unreachable_failure(
2293        &mut self,
2294        peer_id: &str,
2295        transport_error: Option<&'static str>,
2296    ) -> Result<()> {
2297        // A late relay verdict for a session that has since been proven (the
2298        // welcome was rescued over another path and the peer confirmed) must
2299        // not corrupt the converged lifecycle: without this guard the stale
2300        // DeliveryError flips the record back to Failed, refunds an attempt,
2301        // persists the stale state, and emits welcome_send_failed AFTER the
2302        // app already saw secure_session_established.
2303        if self.confirmed_sessions.contains(peer_id) {
2304            return Ok(());
2305        }
2306        let Some(state) = self.welcome_lifecycles.get(peer_id).map(|r| r.state) else {
2307            return Ok(());
2308        };
2309        match state {
2310            WelcomeDeliveryState::Sent | WelcomeDeliveryState::SendAttempted => {
2311                self.transition_welcome_state(
2312                    peer_id,
2313                    WelcomeDeliveryState::Failed,
2314                    "recipient_unreachable",
2315                )?;
2316                if let Some(record) = self.welcome_lifecycles.get_mut(peer_id) {
2317                    record.attempt = record.attempt.saturating_sub(1);
2318                    record.last_transport_error = transport_error.map(str::to_string);
2319                }
2320            }
2321            WelcomeDeliveryState::Created | WelcomeDeliveryState::Failed => {
2322                if let Some(record) = self.welcome_lifecycles.get_mut(peer_id) {
2323                    record.last_transport_error = transport_error.map(str::to_string);
2324                }
2325            }
2326            WelcomeDeliveryState::Expired => return Ok(()),
2327        }
2328        // Escalating timed retry on every carrier (see the doc comment): the
2329        // ladder, not a carrier guard, is what bounds resends into the relay.
2330        let parks = {
2331            let Some(record) = self.welcome_lifecycles.get_mut(peer_id) else {
2332                return Ok(());
2333            };
2334            record.unreachable_parks = record.unreachable_parks.saturating_add(1);
2335            record.unreachable_parks
2336        };
2337        // parks >= 1 here; shift clamped so 15 << 6 = 960 is the largest
2338        // pre-cap value — no overflow risk.
2339        let retry_in_secs = (WELCOME_NO_CARRIER_RETRY_SECS << (parks - 1).min(6))
2340            .min(WELCOME_UNREACHABLE_RETRY_CAP_SECS);
2341        self.park_welcome_no_carrier(
2342            peer_id,
2343            crate::events::WelcomeReasonCode::PeerUnreachable,
2344            transport_error,
2345            retry_in_secs,
2346        )?;
2347
2348        let Some(snapshot) = self.welcome_lifecycles.get(peer_id).cloned() else {
2349            return Ok(());
2350        };
2351        if let Ok(shared) = lock_shared_state(&self.shared_state) {
2352            shared.emit_event(Event::welcome_send_failed(
2353                peer_id.to_string(),
2354                snapshot.welcome_message.id.as_str().to_string(),
2355                snapshot.group_id.clone(),
2356                snapshot.attempt,
2357                crate::events::WelcomeReasonCode::PeerUnreachable,
2358                snapshot
2359                    .last_transport_error
2360                    .as_deref()
2361                    .map(classify_transport_send_error),
2362                // Retryable, and now always with a scheduled time: the park
2363                // keeps an escalating timed probe on every carrier. The
2364                // reachability edges (presence online / peer discovery) still
2365                // re-arm it sooner when they fire.
2366                true,
2367                snapshot.next_retry_at.map(|at| at.timestamp_millis()),
2368            ));
2369        }
2370        Ok(())
2371    }
2372
2373    /// Records a Welcome send failure and decides whether to retry or expire.
2374    ///
2375    /// `no_carrier` is `true` when the failure was simply that no transport
2376    /// carrier exists yet (vs. a present carrier that failed mid-send). A
2377    /// no-carrier failure must NOT age the Welcome: it neither counts toward
2378    /// the retry budget nor lets the TTL expire it, because the peer is merely
2379    /// unreachable for now and will be retried once a carrier appears. The
2380    /// speculative attempt increment from [`Self::try_send_welcome`] is rolled
2381    /// back so a long offline period leaves the real-delivery budget intact.
2382    pub(super) fn apply_welcome_send_failure(
2383        &mut self,
2384        peer_id: &str,
2385        reason: crate::events::WelcomeReasonCode,
2386        transport_error: Option<&'static str>,
2387        no_carrier: bool,
2388        source_event: &str,
2389    ) -> Result<bool> {
2390        let mut updated = self
2391            .welcome_lifecycles
2392            .get(peer_id)
2393            .cloned()
2394            .ok_or_else(|| Error::Other(format!("Missing welcome lifecycle for {}", peer_id)))?;
2395
2396        if matches!(
2397            updated.state,
2398            WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
2399        ) {
2400            return Ok(matches!(updated.state, WelcomeDeliveryState::Sent));
2401        }
2402
2403        // A confirm timeout on a welcome that still holds a live
2404        // unreachable-park counter is a *probe* resolving, not a delivery
2405        // failing — re-park it as another unreachable verdict instead of
2406        // ageing it (see [`Self::welcome_probe_repark_permitted`]).
2407        if !no_carrier
2408            && matches!(reason, crate::events::WelcomeReasonCode::Timeout)
2409            && self.welcome_probe_repark_permitted(peer_id)
2410        {
2411            self.apply_recipient_unreachable_failure(peer_id, transport_error)?;
2412            return Ok(false);
2413        }
2414
2415        let max_attempts = self.config.reliability.retry.max_retries.max(1);
2416        // A no-carrier failure never expires the Welcome: neither the retry
2417        // budget nor the TTL applies while the peer is simply unreachable. Only
2418        // a carrier-backed failure (SendFailed / Timeout) ages it toward expiry.
2419        let should_expire =
2420            !no_carrier && (updated.attempt >= max_attempts || updated.expires_at <= Utc::now());
2421        if should_expire {
2422            let terminal_reason = crate::events::WelcomeReasonCode::RetryExhausted;
2423            {
2424                let record = self.welcome_lifecycles.get_mut(peer_id).ok_or_else(|| {
2425                    Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
2426                })?;
2427                record.last_reason_code = Some(terminal_reason);
2428                record.last_transport_error = transport_error.map(str::to_string);
2429                record.next_retry_at = None;
2430            }
2431
2432            if !matches!(updated.state, WelcomeDeliveryState::Failed) {
2433                self.transition_welcome_state(peer_id, WelcomeDeliveryState::Failed, source_event)?;
2434            }
2435            self.transition_welcome_state(
2436                peer_id,
2437                WelcomeDeliveryState::Expired,
2438                "welcome_retry_exhausted",
2439            )?;
2440
2441            let expired_snapshot =
2442                self.welcome_lifecycles
2443                    .get(peer_id)
2444                    .cloned()
2445                    .ok_or_else(|| {
2446                        Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
2447                    })?;
2448            if let Ok(state) = lock_shared_state(&self.shared_state) {
2449                state.emit_event(Event::welcome_send_failed(
2450                    peer_id.to_string(),
2451                    expired_snapshot.welcome_message.id.as_str().to_string(),
2452                    expired_snapshot.group_id.clone(),
2453                    expired_snapshot.attempt,
2454                    terminal_reason,
2455                    expired_snapshot
2456                        .last_transport_error
2457                        .as_deref()
2458                        .map(classify_transport_send_error),
2459                    false,
2460                    None,
2461                ));
2462                state.emit_event(Event::welcome_send_expired(
2463                    peer_id.to_string(),
2464                    expired_snapshot.welcome_message.id.as_str().to_string(),
2465                    expired_snapshot.attempt,
2466                    terminal_reason,
2467                ));
2468            }
2469            self.abort_pending_session_for_peer(peer_id, terminal_reason);
2470            return Ok(false);
2471        }
2472
2473        // A no-carrier failure (the carrier raced away after the pre-send check,
2474        // or an async transport-failed callback with no carrier) re-checks on the
2475        // slow no-carrier interval rather than the data-plane backoff: there is
2476        // nothing to deliver over until a carrier returns, and `try_send_welcome`
2477        // parks subsequent no-carrier ticks cheaply anyway.
2478        let retry_at = if no_carrier {
2479            Utc::now() + ChronoDuration::seconds(WELCOME_NO_CARRIER_RETRY_SECS)
2480        } else {
2481            let delay_ms = self.compute_welcome_retry_delay_ms(peer_id, updated.attempt);
2482            Utc::now() + ChronoDuration::milliseconds(delay_ms as i64)
2483        };
2484
2485        {
2486            let record = self.welcome_lifecycles.get_mut(peer_id).ok_or_else(|| {
2487                Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
2488            })?;
2489            // Roll back the speculative attempt increment from try_send_welcome
2490            // for a no-carrier failure so the retry budget stays reserved for
2491            // real delivery attempts over an available carrier, and push the TTL
2492            // forward so a never-deliverable Welcome does not age out — the TTL
2493            // clock is carrier-relative, not creation-relative.
2494            if no_carrier {
2495                record.attempt = record.attempt.saturating_sub(1);
2496                record.expires_at =
2497                    Utc::now() + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS);
2498            }
2499            record.last_reason_code = Some(reason);
2500            record.last_transport_error = transport_error.map(str::to_string);
2501            record.next_retry_at = Some(retry_at);
2502        }
2503
2504        if !matches!(updated.state, WelcomeDeliveryState::Failed) {
2505            self.transition_welcome_state(peer_id, WelcomeDeliveryState::Failed, source_event)?;
2506        } else if let Some(record) = self.welcome_lifecycles.get(peer_id).cloned() {
2507            self.persist_welcome_lifecycle_entry(&record)?;
2508        }
2509
2510        updated = self
2511            .welcome_lifecycles
2512            .get(peer_id)
2513            .cloned()
2514            .ok_or_else(|| Error::Other(format!("Missing welcome lifecycle for {}", peer_id)))?;
2515        if let Ok(state) = lock_shared_state(&self.shared_state) {
2516            state.emit_event(Event::welcome_send_failed(
2517                peer_id.to_string(),
2518                updated.welcome_message.id.as_str().to_string(),
2519                updated.group_id,
2520                updated.attempt,
2521                reason,
2522                updated
2523                    .last_transport_error
2524                    .as_deref()
2525                    .map(classify_transport_send_error),
2526                true,
2527                Some(retry_at.timestamp_millis()),
2528            ));
2529        }
2530        Ok(false)
2531    }
2532
2533    pub(super) fn compute_welcome_retry_delay_ms(&self, peer_id: &str, attempt: u32) -> u64 {
2534        let config = &self.config.reliability.retry;
2535        let capped_attempt = attempt.saturating_sub(1);
2536        let base_ms = if capped_attempt == 0 {
2537            config.initial_delay_ms
2538        } else {
2539            let multiplier = config.backoff_multiplier.powi(capped_attempt as i32);
2540            (config.initial_delay_ms as f64 * multiplier as f64) as u64
2541        }
2542        .min(config.max_delay_ms);
2543
2544        let mut hasher = std::collections::hash_map::DefaultHasher::new();
2545        peer_id.hash(&mut hasher);
2546        attempt.hash(&mut hasher);
2547        Utc::now().timestamp_millis().hash(&mut hasher);
2548        let bucket = (hasher.finish() % 10_000) as f64 / 10_000.0;
2549        let jitter_factor = 1.0 + ((bucket * 2.0 - 1.0) * WELCOME_RETRY_JITTER_RATIO);
2550        let jittered = (base_ms as f64 * jitter_factor).round() as i64;
2551        jittered.max(1) as u64
2552    }
2553
2554    pub(super) fn can_transition_welcome_state(
2555        current: WelcomeDeliveryState,
2556        next: WelcomeDeliveryState,
2557    ) -> bool {
2558        matches!(
2559            (current, next),
2560            (
2561                WelcomeDeliveryState::Created,
2562                WelcomeDeliveryState::SendAttempted
2563            ) | (WelcomeDeliveryState::Created, WelcomeDeliveryState::Expired)
2564                | (
2565                    WelcomeDeliveryState::SendAttempted,
2566                    WelcomeDeliveryState::Sent
2567                )
2568                | (
2569                    WelcomeDeliveryState::SendAttempted,
2570                    WelcomeDeliveryState::Failed
2571                )
2572                | (
2573                    WelcomeDeliveryState::Failed,
2574                    WelcomeDeliveryState::SendAttempted
2575                )
2576                | (WelcomeDeliveryState::Failed, WelcomeDeliveryState::Sent)
2577                | (WelcomeDeliveryState::Failed, WelcomeDeliveryState::Expired)
2578                // Sent is normally a sink, but the relay's DeliveryError is
2579                // authoritative proof that a wire-confirmed frame was dropped
2580                // (store-less relay, recipient offline). That correction is
2581                // the one legal way back out of Sent — see
2582                // `on_transport_send_failed`.
2583                | (WelcomeDeliveryState::Sent, WelcomeDeliveryState::Failed)
2584        )
2585    }
2586
2587    pub(super) fn transition_welcome_state(
2588        &mut self,
2589        peer_id: &str,
2590        next_state: WelcomeDeliveryState,
2591        source_event: &str,
2592    ) -> Result<()> {
2593        let (previous_state, record_snapshot) = {
2594            let record = self.welcome_lifecycles.get_mut(peer_id).ok_or_else(|| {
2595                Error::Other(format!(
2596                    "Missing welcome lifecycle for transition: {}",
2597                    peer_id
2598                ))
2599            })?;
2600
2601            if record.state == next_state {
2602                return Ok(());
2603            }
2604
2605            if !Self::can_transition_welcome_state(record.state, next_state) {
2606                return Err(Error::Other(format!(
2607                    "Illegal welcome lifecycle transition for {}: {} -> {}",
2608                    peer_id,
2609                    record.state.as_str(),
2610                    next_state.as_str()
2611                )));
2612            }
2613
2614            let previous = record.state;
2615            record.state = next_state;
2616            if matches!(
2617                next_state,
2618                WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
2619            ) {
2620                record.next_retry_at = None;
2621            }
2622            (previous, record.clone())
2623        };
2624
2625        self.persist_welcome_lifecycle_entry(&record_snapshot)?;
2626        info!(
2627            event = "welcome_lifecycle_transition",
2628            session_or_group_id = %peer_id,
2629            previous_state = previous_state.as_str(),
2630            new_state = next_state.as_str(),
2631            source_event = %source_event,
2632            attempt = record_snapshot.attempt,
2633            "welcome_lifecycle_transition"
2634        );
2635        Ok(())
2636    }
2637
2638    pub(super) fn upsert_welcome_lifecycle(
2639        &mut self,
2640        peer_id: &str,
2641        group_id: &str,
2642        welcome_message: Message,
2643        source_event: &str,
2644    ) -> Result<()> {
2645        if let Some(existing) = self.welcome_lifecycles.get(peer_id) {
2646            if !matches!(
2647                existing.state,
2648                WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
2649            ) {
2650                return Err(Error::Other(format!(
2651                    "Refusing to overwrite active welcome lifecycle for {} in state {}",
2652                    peer_id,
2653                    existing.state.as_str()
2654                )));
2655            }
2656        }
2657
2658        let now = Utc::now();
2659        let record = WelcomeLifecycleRecord {
2660            peer_id: peer_id.to_string(),
2661            group_id: group_id.to_string(),
2662            state: WelcomeDeliveryState::Created,
2663            attempt: 0,
2664            unreachable_parks: 0,
2665            welcome_message,
2666            next_retry_at: None,
2667            last_reason_code: None,
2668            last_transport_error: None,
2669            created_at: now,
2670            expires_at: now + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS),
2671        };
2672        self.welcome_lifecycles
2673            .insert(peer_id.to_string(), record.clone());
2674        self.persist_welcome_lifecycle_entry(&record)?;
2675        info!(
2676            event = "welcome_lifecycle_transition",
2677            session_or_group_id = %peer_id,
2678            previous_state = "Absent",
2679            new_state = WelcomeDeliveryState::Created.as_str(),
2680            source_event = %source_event,
2681            attempt = 0,
2682            "welcome_lifecycle_transition"
2683        );
2684        Ok(())
2685    }
2686
2687    pub(super) fn map_welcome_reason_code(error: &Error) -> crate::events::WelcomeReasonCode {
2688        SessionStateError::classify(error).to_welcome_reason_code()
2689    }
2690
2691    /// True when a send failed because no transport carrier exists yet, as
2692    /// opposed to a present-but-unhealthy carrier (`SendFailed`) or a
2693    /// sent-but-unconfirmed timeout. A no-carrier failure means the peer is
2694    /// simply unreachable right now, so the Welcome must be kept alive and
2695    /// retried rather than counted against its budget — see
2696    /// [`Self::apply_welcome_send_failure`].
2697    pub(super) fn is_no_carrier_error(error: &Error) -> bool {
2698        matches!(
2699            error,
2700            Error::Transport(offline_protocol_transport::Error::TransportNotAvailable(_))
2701        )
2702    }
2703
2704    pub(super) fn session_ready_context_for_source(source_event: &str) -> MlsOperationContext {
2705        match source_event {
2706            "confirmation_ack_received"
2707            | "confirmation_probe_received"
2708            | "decrypt_success"
2709            | SESSION_CORROBORATED => MlsOperationContext::Receive,
2710            "welcome_received" => MlsOperationContext::Welcome,
2711            _ => MlsOperationContext::Send,
2712        }
2713    }
2714
2715    pub(super) fn find_welcome_peer_by_message_id(&self, message_id: &str) -> Option<String> {
2716        self.welcome_lifecycles
2717            .iter()
2718            .find_map(|(peer_id, record)| {
2719                if record.welcome_message.id.as_str() == message_id {
2720                    return Some(peer_id.clone());
2721                }
2722                None
2723            })
2724    }
2725
2726    pub(super) fn process_welcome_retry_queue(&mut self) -> Result<()> {
2727        let now = Utc::now();
2728        let timed_out_attempts: Vec<String> = self
2729            .welcome_lifecycles
2730            .iter()
2731            .filter_map(|(peer_id, record)| {
2732                // Skip peers whose session is already confirmed: the welcome is
2733                // delivered, mark_welcome_confirmed should have marked it Sent,
2734                // and re-sending would be wasted bandwidth (defensive against any
2735                // keying mismatch that left the lifecycle non-terminal).
2736                if self.confirmed_sessions.contains(peer_id) {
2737                    return None;
2738                }
2739                if matches!(record.state, WelcomeDeliveryState::SendAttempted)
2740                    && record.next_retry_at.is_some_and(|retry_at| retry_at <= now)
2741                {
2742                    return Some(peer_id.clone());
2743                }
2744                None
2745            })
2746            .take(WELCOME_RETRY_BATCH_SIZE)
2747            .collect();
2748
2749        for peer_id in timed_out_attempts {
2750            let _ = self.apply_welcome_send_failure(
2751                &peer_id,
2752                crate::events::WelcomeReasonCode::Timeout,
2753                Some(SEND_FAIL_REASON_CONFIRM_TIMEOUT),
2754                // A confirm timeout means the Welcome was sent over a carrier;
2755                // this is not a no-carrier failure, so it ages normally.
2756                false,
2757                "welcome_confirm_timeout",
2758            )?;
2759        }
2760
2761        let due_peers: Vec<String> = self
2762            .welcome_lifecycles
2763            .iter()
2764            .filter_map(|(peer_id, record)| {
2765                if self.confirmed_sessions.contains(peer_id) {
2766                    return None;
2767                }
2768                // Failed = a normal retry-due Welcome. Created with a due
2769                // next_retry_at = a no-carrier-parked Welcome (see
2770                // `park_welcome_no_carrier`): a fresh Created entry has
2771                // next_retry_at == None, so only parked ones match here.
2772                if matches!(
2773                    record.state,
2774                    WelcomeDeliveryState::Failed | WelcomeDeliveryState::Created
2775                ) && record.next_retry_at.is_some_and(|retry_at| retry_at <= now)
2776                {
2777                    return Some(peer_id.clone());
2778                }
2779                None
2780            })
2781            .take(WELCOME_RETRY_BATCH_SIZE)
2782            .collect();
2783
2784        for peer_id in due_peers {
2785            if let Err(err) = self.try_send_welcome(&peer_id, "welcome_retry", false) {
2786                warn!(
2787                    peer_id = %peer_id,
2788                    error = %err,
2789                    "Failed to process welcome retry"
2790                );
2791            }
2792        }
2793
2794        Ok(())
2795    }
2796
2797    pub(super) fn has_terminal_welcome_failure(&self, peer_id: &str) -> bool {
2798        self.welcome_lifecycles
2799            .get(peer_id)
2800            .is_some_and(|record| matches!(record.state, WelcomeDeliveryState::Expired))
2801    }
2802
2803    /// Whether a message the user sent is waiting for `peer_id`: queued
2804    /// behind session establishment, or on the outbox unmarked (an entry
2805    /// restored from before the marker existed counts, the safe direction).
2806    fn has_user_content_waiting_for(&self, peer_id: &str) -> bool {
2807        self.pending_encrypted_messages
2808            .get(peer_id)
2809            .is_some_and(|queued| !queued.is_empty())
2810            || self.outbox.values().any(|entry| {
2811                entry.message.recipient.as_str() == peer_id
2812                    && !entry
2813                        .message
2814                        .metadata
2815                        .contains_key(crate::constants::NO_ALERT_KEY)
2816            })
2817    }
2818}