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