Skip to main content

xmtp_mls/groups/
welcome_sync.rs

1//! Durable Welcome processing for the incoming coordinator and client sync APIs.
2//!
3//! `WelcomeService` installs independent joins, resolves their dependencies, and
4//! holds Welcomes with unsupported formats, unavailable keys, unresolved pointers,
5//! or unreached group prefixes for a bounded window.
6//! `WelcomeHeadOutcome` tells the coordinator which work remains; `GroupSyncSummary`
7//! reports completed group synchronization.
8
9use crate::context::XmtpSharedContext;
10use crate::groups::InitialMembershipValidator;
11#[cfg(test)]
12use crate::groups::ValidateGroupMembership;
13use crate::groups::XmtpWelcome;
14use crate::groups::mls_ext::ResolvedWelcome;
15use crate::groups::{GroupError, MlsGroup};
16use crate::identity_updates::{
17    IdentityDependencyError, IdentityRequirement, InstallationDiffError,
18    resolve_identity_requirements,
19};
20#[cfg(test)]
21use crate::intents::ProcessIntentError;
22#[cfg(test)]
23use crate::mls_store::MlsStore;
24use futures::stream::{self, StreamExt};
25use prost::Message;
26use std::collections::{BTreeSet, HashSet};
27#[cfg(test)]
28use xmtp_common::Event;
29use xmtp_common::RetryableError;
30use xmtp_configuration::WELCOME_RETENTION_NS;
31use xmtp_db::incoming_envelope::{
32    IncomingRetry, NetworkEntityKind, StoredIncomingEnvelope, StreamTopic,
33};
34#[cfg(test)]
35use xmtp_db::refresh_state::EntityKind;
36use xmtp_db::{
37    consent_record::ConsentState,
38    group::{GroupMembershipState, GroupScope, StoredGroupRef},
39    prelude::*,
40};
41#[cfg(test)]
42use xmtp_macro::log_event;
43use xmtp_proto::types::Topic;
44use xmtp_proto::types::{Cursor, GroupId};
45
46/// How long a Welcome this build cannot process is retained before it is
47/// completed and removed. A client that supports it within the window still
48/// joins; past the window the row must not keep the Welcome barrier, the
49/// Welcome budget, and key-package retirement blocked forever.
50const UNSUPPORTED_WELCOME_RETENTION_NS: i64 = xmtp_common::NS_IN_DAY;
51
52/// Work needed before one durable Welcome can be attempted again.
53pub(crate) enum WelcomeRequirement {
54    /// Every exact inbox and sequence proof the staged membership lacks.
55    Identity(BTreeSet<IdentityRequirement>),
56    /// Pointer data that must be fetched without a database writer.
57    Pointee,
58    /// An active older group must process its ordered prefix before replacement.
59    GroupPrefix {
60        /// Existing group whose active state prevents the join.
61        group_id: GroupId,
62        /// Last group log position already covered by the joined state.
63        anchor: Cursor,
64        /// Stored retry deadline. At or after it the coordinator reads the
65        /// group topic's newest position before it may expire the hold.
66        deadline_ns: i64,
67    },
68}
69
70/// Local attempt result. A waiting Welcome does not stop independent joins.
71pub(crate) enum WelcomeHeadOutcome<C> {
72    /// The pending row was already completed by another attempt.
73    Idle { cursor: Cursor },
74    /// The row remains durable with a retry or blocked diagnostic.
75    Waiting {
76        cursor: Cursor,
77        code: String,
78        /// Blocked rows are retried at coordinator startup and on periodic scans.
79        blocked: bool,
80    },
81    /// The scheduler must resolve a dependency outside the state writer.
82    Need {
83        cursor: Cursor,
84        requirement: WelcomeRequirement,
85    },
86    /// The join or safe rejection and pending-row completion have committed.
87    Progress {
88        cursor: Cursor,
89        result: Result<Option<MlsGroup<C>>, GroupError>,
90    },
91}
92
93fn unsupported_welcome_wire(wire: &xmtp_proto::backend_v1::ServerEnvelope) -> bool {
94    use xmtp_proto::backend_v1::{client_envelope::Payload, welcome_message::Version};
95    use xmtp_proto::xmtp::mls::message_contents::{
96        WelcomePointerWrapperAlgorithm, WelcomeWrapperAlgorithm,
97    };
98    match wire
99        .envelope
100        .as_ref()
101        .and_then(|envelope| envelope.payload.as_ref())
102    {
103        Some(Payload::WelcomeMessage(welcome)) => match &welcome.version {
104            None => true,
105            Some(Version::V1(message)) => {
106                WelcomeWrapperAlgorithm::try_from(message.wrapper_algorithm).is_err()
107            }
108            Some(Version::WelcomePointer(message)) => {
109                WelcomePointerWrapperAlgorithm::try_from(message.wrapper_algorithm).is_err()
110            }
111        },
112        _ => false,
113    }
114}
115
116/// The backend's timestamp on a stored Welcome, the base of its retention deadlines.
117fn welcome_timestamp_ns(pending: &StoredIncomingEnvelope) -> Result<i64, GroupError> {
118    let wire = xmtp_proto::backend_v1::ServerEnvelope::decode(pending.envelope.as_slice())
119        .map_err(|_| xmtp_db::StorageError::DbDeserialize)?;
120    let welcome = xmtp_api_backend::envelope::decode_welcome_message(wire)
121        .map_err(xmtp_api::ApiError::from)?;
122    Ok(welcome.timestamp())
123}
124
125#[derive(Debug, Clone)]
126/// Counts returned only after all selected sync phases succeed.
127pub struct GroupSyncSummary {
128    /// Distinct selected groups, including groups found through the fixed Welcome target.
129    pub num_eligible: usize,
130    /// Selected groups that remain active after required outgoing work completes.
131    pub num_synced: usize,
132}
133
134impl GroupSyncSummary {
135    pub fn new(num_eligible: usize, num_synced: usize) -> Self {
136        Self {
137            num_eligible,
138            num_synced,
139        }
140    }
141}
142
143// Outcome of span-instrumented welcome processing: the expected
144// already-processed duplicate is an `Ok` variant so it cannot mark the
145// `mls.process_new_welcome` span as status:error.
146#[cfg(test)]
147enum WelcomeOutcome<Context> {
148    Processed(Option<MlsGroup<Context>>),
149    AlreadyProcessed(Cursor),
150}
151
152#[derive(Clone)]
153/// Welcome processing and group sync through the context's shared incoming coordinator.
154pub struct WelcomeService<Context> {
155    context: Context,
156}
157
158impl<Context> WelcomeService<Context> {
159    pub fn new(context: Context) -> Self {
160        Self { context }
161    }
162}
163
164impl<Context> WelcomeService<Context>
165where
166    Context: XmtpSharedContext,
167{
168    /// Admit a test Welcome, then atomically join or record a safe rejection.
169    // Callers still receive `Err(WelcomeAlreadyProcessed)` for the routine
170    // duplicate-delivery case, but the span lives on the inner fn where that
171    // expected outcome exits as `Ok` — so it never flags span status:error.
172    #[cfg(test)]
173    pub(crate) async fn process_new_welcome(
174        &self,
175        welcome: &xmtp_proto::types::WelcomeMessage,
176        validator: impl ValidateGroupMembership,
177    ) -> Result<Option<MlsGroup<Context>>, GroupError> {
178        match self.process_new_welcome_spanned(welcome, validator).await? {
179            WelcomeOutcome::Processed(group) => Ok(group),
180            WelcomeOutcome::AlreadyProcessed(cursor) => Err(GroupError::ProcessIntent(
181                ProcessIntentError::WelcomeAlreadyProcessed(cursor),
182            )),
183        }
184    }
185
186    #[cfg(test)]
187    #[tracing::instrument(err, skip_all, fields(operation = "mls.process_new_welcome"))]
188    async fn process_new_welcome_spanned(
189        &self,
190        welcome: &xmtp_proto::types::WelcomeMessage,
191        validator: impl ValidateGroupMembership,
192    ) -> Result<WelcomeOutcome<Context>, GroupError> {
193        let pending = self
194            .context
195            .db()
196            .pending_envelope(&self.topic(), welcome.cursor)?
197            .ok_or(ProcessIntentError::WelcomeAlreadyProcessed(welcome.cursor))?;
198        let result = XmtpWelcome::builder()
199            .context(self.context.clone())
200            .welcome(welcome)
201            .pending(pending)
202            .validator(validator)
203            .process()
204            .await;
205
206        match result {
207            Ok(mls_group) => {
208                if let Some(mls_group) = &mls_group {
209                    if let (Ok(epoch), Ok(auth)) = (
210                        mls_group.epoch().await,
211                        mls_group.epoch_authenticator().await,
212                    ) {
213                        log_event!(
214                            Event::ReceivedWelcome,
215                            self.context.installation_id(),
216                            group_id = mls_group.group_id.as_slice(),
217                            conversation_type = %mls_group.conversation_type,
218                            epoch,
219                            epoch_auth = auth
220                        );
221                    } else {
222                        tracing::warn!(
223                            "Failed to lock the mls group for logging ProcessedWelcome."
224                        );
225                    }
226                }
227
228                Ok(WelcomeOutcome::Processed(mls_group))
229            }
230            Err(err) => {
231                use crate::DuplicateItem::*;
232                use crate::StorageError::*;
233
234                if matches!(err, GroupError::Storage(Duplicate(WelcomeId(_)))) {
235                    tracing::warn!(
236                        welcome_id = %welcome.cursor,
237                        "Welcome ID already stored: {}",
238                        err
239                    );
240                    return Ok(WelcomeOutcome::AlreadyProcessed(welcome.cursor));
241                } else if let GroupError::ProcessIntent(
242                    ProcessIntentError::WelcomeAlreadyProcessed(cursor),
243                ) = err
244                {
245                    // Expected, non-retryable condition: the welcome was already
246                    // processed (e.g. duplicate delivery for a group we are already
247                    // in). It is handled gracefully upstream (cursor incremented,
248                    // welcome skipped), so log at warn rather than error to avoid
249                    // marking the span as status:error and inflating the error rate.
250                    tracing::warn!(
251                        welcome_id = %welcome.cursor,
252                        "welcome already processed, skipping: {}",
253                        err
254                    );
255                    return Ok(WelcomeOutcome::AlreadyProcessed(cursor));
256                } else {
257                    tracing::error!(
258                        "failed to create group from welcome={} created at {}: {}",
259                        welcome.cursor,
260                        welcome.created_ns.timestamp(),
261                        err
262                    );
263                }
264
265                Err(err)
266            }
267        }
268    }
269
270    fn topic(&self) -> StreamTopic {
271        StreamTopic {
272            entity_id: self.context.installation_id().to_vec(),
273            kind: NetworkEntityKind::Welcome,
274        }
275    }
276
277    /// Attempt independent Welcomes excluding those already owned by dependency work.
278    /// Apply row/byte budgets after exclusions. This method does no network I/O.
279    pub(crate) fn process_pending_welcomes_once(
280        &self,
281        excluded: &[Cursor],
282    ) -> Result<Vec<WelcomeHeadOutcome<Context>>, GroupError> {
283        let settings = self.context.incoming_runtime().policy();
284        self.context
285            .db()
286            .ready_welcomes_bounded(
287                xmtp_common::time::now_ns(),
288                settings.max_fetched_rows,
289                settings.max_fetched_bytes,
290                excluded,
291            )?
292            .into_iter()
293            .filter(|pending| pending.entity_id == self.context.installation_id())
294            .map(|pending| self.attempt_pending_welcome(pending, None, None))
295            .collect()
296    }
297
298    /// Reread one durable row after a dependency completes.
299    /// Only the exact resolver result can mark an identity reference as absent.
300    pub(crate) fn retry_pending_welcome(
301        &self,
302        cursor: Cursor,
303        missing_reference: Option<&IdentityRequirement>,
304    ) -> Result<WelcomeHeadOutcome<Context>, GroupError> {
305        let Some(pending) = self.context.db().pending_envelope(&self.topic(), cursor)? else {
306            return Ok(WelcomeHeadOutcome::Idle { cursor });
307        };
308        self.attempt_pending_welcome(pending, None, missing_reference)
309    }
310
311    /// Recheck blocked Welcomes at startup and on periodic coordinator scans.
312    /// Advance `after` to each returned cursor until a batch is empty.
313    /// Complete this scan before processing ready Welcomes in that coordinator.
314    pub(crate) fn retry_blocked_welcomes_after(
315        &self,
316        after: Cursor,
317    ) -> Result<Vec<WelcomeHeadOutcome<Context>>, GroupError> {
318        let settings = self.context.incoming_runtime().policy();
319        self.context
320            .db()
321            .blocked_welcomes_bounded(
322                &self.topic(),
323                after,
324                settings.max_fetched_rows,
325                settings.max_fetched_bytes,
326            )?
327            .into_iter()
328            .map(|pending| self.attempt_pending_welcome(pending, None, None))
329            .collect()
330    }
331
332    fn attempt_pending_welcome(
333        &self,
334        pending: StoredIncomingEnvelope,
335        resolved: Option<&ResolvedWelcome>,
336        missing_reference: Option<&IdentityRequirement>,
337    ) -> Result<WelcomeHeadOutcome<Context>, GroupError> {
338        let cursor = Cursor(pending.sequence_id as u64);
339        let wire = xmtp_proto::backend_v1::ServerEnvelope::decode(pending.envelope.as_slice())
340            .map_err(|_| xmtp_db::StorageError::DbDeserialize)?;
341        if unsupported_welcome_wire(&wire) {
342            return self.retain_unreadable_welcome(
343                &pending,
344                GroupError::UnsupportedWelcomeVersion("unsupported wrapper algorithm".into()),
345                "unsupported_welcome",
346            );
347        }
348        let welcome = match xmtp_api_backend::envelope::decode_welcome_message(wire) {
349            Ok(welcome) => welcome,
350            Err(error) => {
351                let error = GroupError::WrappedApi(xmtp_api::ApiError::Envelope(error));
352                self.complete_rejected_pending(&pending, "invalid_welcome_envelope")?;
353                return Ok(WelcomeHeadOutcome::Progress {
354                    cursor,
355                    result: Err(error),
356                });
357            }
358        };
359        // Unreadable Welcomes get a real attempt even after their deadline: an
360        // upgrade or recovered key may now allow them to join.
361        if let Some(deadline) = pending.retry_expires_at_ns
362            && deadline <= xmtp_common::time::now_ns()
363            && pending.error_code.as_deref() == Some("welcome_pointee")
364            && pending.unreadable_expires_at_ns.is_none()
365        {
366            self.complete_rejected_pending(&pending, "welcome_pointer_expired")?;
367            return Ok(WelcomeHeadOutcome::Progress {
368                cursor,
369                result: Err(GroupError::WelcomeDataNotFound("expired pointer".into())),
370            });
371        }
372        let inline = ResolvedWelcome::inline(&welcome);
373        let Some(resolved) = resolved.or(inline.as_ref()) else {
374            // Only a failed fetch establishes a pointee hold. Overwriting an
375            // unreadable hold here would skip its final resolution attempt.
376            return Ok(WelcomeHeadOutcome::Need {
377                cursor,
378                requirement: WelcomeRequirement::Pointee,
379            });
380        };
381        let result = XmtpWelcome::builder()
382            .context(self.context.clone())
383            .welcome(&welcome)
384            .pending(pending.clone())
385            .validator(InitialMembershipValidator::new(&self.context))
386            .process_resolved(resolved, missing_reference);
387        self.classify_pending_result(&pending, result)
388    }
389
390    // implements: JOIN-084, JOIN-085
391    fn classify_pending_result(
392        &self,
393        pending: &StoredIncomingEnvelope,
394        result: Result<Option<MlsGroup<Context>>, GroupError>,
395    ) -> Result<WelcomeHeadOutcome<Context>, GroupError> {
396        let cursor = Cursor(pending.sequence_id as u64);
397        match result {
398            Ok(group) => Ok(WelcomeHeadOutcome::Progress {
399                cursor,
400                result: Ok(group),
401            }),
402            Err(GroupError::InstallationDiff(InstallationDiffError::IdentityDependency(
403                IdentityDependencyError::Need(requirements),
404            ))) => {
405                self.defer_pending(pending, "identity_dependency", false, None, None)?;
406                Ok(WelcomeHeadOutcome::Need {
407                    cursor,
408                    requirement: WelcomeRequirement::Identity(requirements),
409                })
410            }
411            // implements: JOIN-092
412            // A deadline alone never rejects: only the coordinator's final
413            // catch-up can, so a restart past the deadline reads the group
414            // topic before it decides.
415            Err(GroupError::WelcomeGroupPrefixPending { group_id, anchor }) => {
416                let deadline_ns = match pending.retry_expires_at_ns {
417                    Some(deadline) => deadline,
418                    None => welcome_timestamp_ns(pending)?.saturating_add(WELCOME_RETENTION_NS),
419                };
420                self.defer_pending(pending, "group_prefix", false, Some(deadline_ns), None)?;
421                Ok(WelcomeHeadOutcome::Need {
422                    cursor,
423                    requirement: WelcomeRequirement::GroupPrefix {
424                        group_id,
425                        anchor: Cursor(anchor),
426                        deadline_ns,
427                    },
428                })
429            }
430            Err(
431                error @ (GroupError::WelcomeError(
432                    openmls::prelude::WelcomeError::UnsupportedMlsVersion
433                    | openmls::prelude::WelcomeError::UnsupportedExtensions
434                    | openmls::prelude::WelcomeError::UnsupportedCapability
435                    | openmls::prelude::WelcomeError::UnsupportedCiphersuite(_),
436                )
437                | GroupError::NotFound(
438                    xmtp_db::NotFound::KeyPackageReference(_)
439                    | xmtp_db::NotFound::KeyPackage(_)
440                    | xmtp_db::NotFound::PostQuantumPrivateKey,
441                )),
442            ) => self.retain_unreadable_welcome(pending, error, "welcome_blocked"),
443            Err(error) => {
444                if crate::groups::welcomes::terminal_welcome_error(&error) {
445                    self.complete_rejected_pending(pending, "invalid_welcome")?;
446                }
447                if self
448                    .context
449                    .db()
450                    .pending_envelope(&self.topic(), cursor)?
451                    .is_none()
452                {
453                    return Ok(WelcomeHeadOutcome::Progress {
454                        cursor,
455                        result: Err(error),
456                    });
457                }
458                let blocked = !error.is_retryable()
459                    || matches!(error, GroupError::UnsupportedWelcomeVersion(_));
460                let code = if blocked {
461                    "welcome_blocked"
462                } else {
463                    "welcome_retry"
464                };
465                self.defer_pending(pending, code, blocked, None, error.rate_limit_delay())?;
466                tracing::warn!(sequence_id = cursor.0, code, error = %error, "Welcome remains pending");
467                Ok(WelcomeHeadOutcome::Waiting {
468                    cursor,
469                    code: code.into(),
470                    blocked,
471                })
472            }
473        }
474    }
475
476    /// Bound unreadable input only after attempting it with the current code and keys.
477    // implements: JOIN-087, JOIN-063
478    fn retain_unreadable_welcome(
479        &self,
480        pending: &StoredIncomingEnvelope,
481        error: GroupError,
482        code: &'static str,
483    ) -> Result<WelcomeHeadOutcome<Context>, GroupError> {
484        let cursor = Cursor(pending.sequence_id as u64);
485        let now = xmtp_common::time::now_ns();
486        let Some(deadline) = self.context.db().set_unreadable_welcome_deadline(
487            self.context.installation_id().as_slice(),
488            cursor,
489            now.saturating_add(UNSUPPORTED_WELCOME_RETENTION_NS),
490        )?
491        else {
492            return Ok(WelcomeHeadOutcome::Idle { cursor });
493        };
494        if deadline <= now {
495            self.complete_rejected_pending(pending, "unsupported_welcome_expired")?;
496            return Ok(WelcomeHeadOutcome::Progress {
497                cursor,
498                result: Err(error),
499            });
500        }
501        self.defer_pending(pending, code, true, None, None)?;
502        Ok(WelcomeHeadOutcome::Waiting {
503            cursor,
504            code: code.into(),
505            blocked: true,
506        })
507    }
508
509    /// Keep retry state without extending the original expiry deadline. A
510    /// rate-limited request is not retried before `floor`, its delay, but never
511    /// later than `deadline`: an expired pointer is then rejected without a
512    /// fetch, so a delay longer than its retention cannot stall it.
513    // implements: RATE-041
514    fn defer_pending(
515        &self,
516        pending: &StoredIncomingEnvelope,
517        code: &'static str,
518        blocked: bool,
519        deadline: Option<i64>,
520        floor: Option<xmtp_common::time::Duration>,
521    ) -> Result<(), GroupError> {
522        let policy = self.context.incoming_runtime().policy();
523        let delay = policy
524            .active_database_poll_interval
525            .max(floor.unwrap_or_default());
526        self.context.db().set_incoming_retry(
527            &self.topic(),
528            Cursor(pending.sequence_id as u64),
529            &IncomingRetry {
530                retry_at_ns: xmtp_common::time::now_ns()
531                    .saturating_add(i64::try_from(delay.as_nanos()).unwrap_or(i64::MAX))
532                    .min(deadline.unwrap_or(i64::MAX)),
533                blocked,
534                error_code: Some(code.into()),
535                retry_expires_at_ns: deadline,
536            },
537        )?;
538        if blocked || deadline.is_some() || pending.unreadable_expires_at_ns.is_some() {
539            // implements: PROC-020
540            // A full held allowance gives up the oldest held Welcome.
541            let allowance = policy.incoming_limits(NetworkEntityKind::Welcome).kind;
542            while let Some(oldest) = self
543                .context
544                .db()
545                .oldest_held_welcome_over(&self.topic(), allowance)?
546            {
547                self.complete_rejected_pending(&oldest, "held_capacity")?;
548            }
549        }
550        #[cfg(any(test, feature = "diagnostics"))]
551        crate::groups::diagnostics::record_welcome_retry(code);
552        Ok(())
553    }
554
555    /// Commit rejection progress only if the exact pending bytes still match.
556    fn complete_rejected_pending(
557        &self,
558        pending: &StoredIncomingEnvelope,
559        code: &'static str,
560    ) -> Result<(), GroupError> {
561        crate::state_tx::state_write(self.context.mls_storage(), |tx| {
562            let storage = tx.storage();
563            let db = storage.db();
564            let cursor = Cursor(pending.sequence_id as u64);
565            if db
566                .pending_envelope(&self.topic(), cursor)?
567                .is_some_and(|current| current.envelope == pending.envelope)
568            {
569                db.record_terminal_rejection(&self.topic(), cursor, code)?;
570                db.complete_pending_envelope(&self.topic(), cursor)?;
571            }
572            Ok::<_, GroupError>(xmtp_db::TransactionOutcome::Continue(()))
573        })?;
574        Ok(())
575    }
576
577    /// Reject a group-prefix hold whose deadline has passed, given `target`, the
578    /// newest position of the group's message topic read at or after that
579    /// deadline. It rejects only when processing has reached `target` and stops
580    /// below `anchor`, and the group's MLS state is still active. Otherwise the
581    /// row stays pending for a later attempt.
582    // implements: JOIN-092
583    pub(crate) fn expire_group_prefix_welcome(
584        &self,
585        cursor: Cursor,
586        group_id: GroupId,
587        anchor: Cursor,
588        target: Cursor,
589    ) -> Result<WelcomeHeadOutcome<Context>, GroupError> {
590        enum Expiry {
591            Gone,
592            Kept { code: String, blocked: bool },
593            Rejected,
594        }
595        let now = xmtp_common::time::now_ns();
596        let expiry = crate::state_tx::state_write(self.context.mls_storage(), |tx| {
597            let storage = tx.storage();
598            let db = storage.db();
599            let Some(pending) = db.pending_envelope(&self.topic(), cursor)? else {
600                return Ok::<_, GroupError>(xmtp_db::TransactionOutcome::Continue(Expiry::Gone));
601            };
602            let processed = db.topic_progress(&StreamTopic::group(group_id))?.processed;
603            let expired = pending.error_code.as_deref() == Some("group_prefix")
604                && pending
605                    .retry_expires_at_ns
606                    .is_some_and(|deadline| deadline <= now)
607                && target <= processed
608                && processed < anchor
609                // Another process may already have processed the removal. An
610                // inactive group lets the re-add replace it, so keep the Welcome.
611                && openmls::group::MlsGroup::load(&storage, &group_id.to_openmls())?
612                    .is_some_and(|group| group.is_active());
613            if !expired {
614                return Ok(xmtp_db::TransactionOutcome::Continue(Expiry::Kept {
615                    code: pending.error_code.unwrap_or_default(),
616                    blocked: pending.blocked,
617                }));
618            }
619            db.record_terminal_rejection(&self.topic(), cursor, "group_prefix_expired")?;
620            db.complete_pending_envelope(&self.topic(), cursor)?;
621            Ok(xmtp_db::TransactionOutcome::Continue(Expiry::Rejected))
622        })?
623        .into_continued();
624        Ok(match expiry {
625            Expiry::Gone => WelcomeHeadOutcome::Idle { cursor },
626            Expiry::Kept { code, blocked } => WelcomeHeadOutcome::Waiting {
627                cursor,
628                code,
629                blocked,
630            },
631            Expiry::Rejected => WelcomeHeadOutcome::Progress {
632                cursor,
633                result: Err(GroupError::WelcomeGroupPrefixPending {
634                    group_id,
635                    anchor: anchor.0,
636                }),
637            },
638        })
639    }
640
641    /// Resolve one Welcome's dependencies. Group-prefix work stays with the coordinator.
642    // implements: JOIN-094
643    pub(crate) async fn resolve_pending_welcome(
644        &self,
645        cursor: Cursor,
646    ) -> Result<WelcomeHeadOutcome<Context>, GroupError> {
647        let Some(pending) = self.context.db().pending_envelope(&self.topic(), cursor)? else {
648            return Ok(WelcomeHeadOutcome::Idle { cursor });
649        };
650        if pending.error_code.as_deref() == Some("welcome_pointee")
651            && pending.unreadable_expires_at_ns.is_none()
652            && pending
653                .retry_expires_at_ns
654                .is_some_and(|deadline| deadline <= xmtp_common::time::now_ns())
655        {
656            return self.attempt_pending_welcome(pending, None, None);
657        }
658        let wire = xmtp_proto::backend_v1::ServerEnvelope::decode(pending.envelope.as_slice())
659            .map_err(|_| xmtp_db::StorageError::DbDeserialize)?;
660        let welcome = xmtp_api_backend::envelope::decode_welcome_message(wire)
661            .map_err(xmtp_api::ApiError::from)?;
662        let resolved = match ResolvedWelcome::resolve(&welcome, &self.context).await {
663            Ok(resolved) => resolved,
664            // API errors here come from querying the pointee, before any MLS or
665            // identity processing. Failed queries obey the same retention as
666            // missing data, regardless of whether the API error is retryable.
667            Err(error @ (GroupError::WelcomeDataNotFound(_) | GroupError::WrappedApi(_))) => {
668                // Once unreadable retention starts, failed re-fetches must
669                // neither shorten that window nor extend it indefinitely.
670                if pending.unreadable_expires_at_ns.is_some() {
671                    return self.retain_unreadable_welcome(&pending, error, "welcome_blocked");
672                }
673                let deadline = pending
674                    .retry_expires_at_ns
675                    .unwrap_or_else(|| welcome.timestamp().saturating_add(WELCOME_RETENTION_NS));
676                // A legacy blocked row gets a real fetch before its cutoff is
677                // applied. Missing data still obeys the fixed pointer deadline.
678                if deadline <= xmtp_common::time::now_ns() {
679                    self.complete_rejected_pending(&pending, "welcome_pointer_expired")?;
680                    return Ok(WelcomeHeadOutcome::Progress {
681                        cursor,
682                        result: Err(error),
683                    });
684                }
685                // Preserve the legacy classification so its next attempt can
686                // fetch and reclassify the pointee, even past the cutoff.
687                let blocked = pending.error_code.as_deref() == Some("welcome_blocked")
688                    || matches!(&error, GroupError::WrappedApi(api) if !api.is_retryable());
689                let code = if blocked {
690                    "welcome_blocked"
691                } else {
692                    "welcome_pointee"
693                };
694                self.defer_pending(
695                    &pending,
696                    code,
697                    blocked,
698                    Some(deadline),
699                    error.rate_limit_delay(),
700                )?;
701                return Ok(WelcomeHeadOutcome::Waiting {
702                    cursor,
703                    code: code.into(),
704                    blocked,
705                });
706            }
707            Err(error) => return self.classify_pending_result(&pending, Err(error)),
708        };
709        loop {
710            let outcome = self.attempt_pending_welcome(pending.clone(), Some(&resolved), None)?;
711            let WelcomeHeadOutcome::Need {
712                requirement: WelcomeRequirement::Identity(requirements),
713                ..
714            } = outcome
715            else {
716                return Ok(outcome);
717            };
718            match resolve_identity_requirements(&self.context, &requirements).await {
719                Ok(()) => {}
720                Err(IdentityDependencyError::MissingReference(requirement)) => {
721                    return self.attempt_pending_welcome(
722                        pending,
723                        Some(&resolved),
724                        Some(&requirement),
725                    );
726                }
727                Err(error) => {
728                    return self.classify_pending_result(
729                        &pending,
730                        Err(InstallationDiffError::from(error).into()),
731                    );
732                }
733            }
734        }
735    }
736
737    /// Process all Welcomes through one fixed replica-visible target.
738    pub async fn sync_welcomes(&self) -> Result<Vec<MlsGroup<Context>>, GroupError> {
739        let scope = GroupScope::listing(None);
740        let before: HashSet<_> = self
741            .context
742            .db()
743            .stored_group_refs(&scope)?
744            .into_iter()
745            .map(|group| group.id)
746            .collect();
747        crate::subscriptions::barrier::receive_through_current(
748            &self.context,
749            vec![Topic::new_welcome_message(self.context.installation_id())],
750        )
751        .await?;
752        Ok(self
753            .context
754            .db()
755            .stored_group_refs(&scope)?
756            .into_iter()
757            .filter(|group| !before.contains(&group.id))
758            .map(|group| self.mls_group(group))
759            .collect())
760    }
761
762    fn mls_group(&self, group: StoredGroupRef) -> MlsGroup<Context> {
763        MlsGroup::new(
764            self.context.clone(),
765            group.id,
766            group.dm_id,
767            group.conversation_type,
768            group.created_at_ns,
769        )
770    }
771
772    /// Publish, process fixed targets, and finish required work under one deadline.
773    /// Every selected group gets an outcome, even when another group fails.
774    pub async fn sync_all_groups(
775        &self,
776        groups: Vec<MlsGroup<Context>>,
777    ) -> Result<GroupSyncSummary, GroupError> {
778        use crate::subscriptions::incoming::{IncomingCoordinator, IncomingScope};
779        use xmtp_common::time::Instant;
780
781        let deadline = Instant::now() + self.context.incoming_runtime().policy().barrier_timeout;
782        let mut selected = HashSet::new();
783        let selected: Vec<_> = groups
784            .into_iter()
785            .filter(|group| selected.insert(group.group_id))
786            .collect();
787        let db = self.context.db();
788        let num_eligible = selected.len();
789        let mut groups = Vec::new();
790        let mut restored_count = 0;
791        for group in selected {
792            if db
793                .find_group(&group.group_id)?
794                .is_some_and(|stored| stored.membership_state == GroupMembershipState::Restored)
795            {
796                restored_count += 1;
797            } else {
798                groups.push(group);
799            }
800        }
801        if groups.is_empty() && restored_count > 0 {
802            return Err(GroupError::GroupInactive);
803        }
804        let topics = group_sync_topics(&groups);
805        // Receipt can proceed while an independent outgoing request is pending.
806        let _receipt = IncomingCoordinator::for_context(&self.context)
807            .acquire(IncomingScope::Topics(topics.clone()));
808        let concurrency = self
809            .context
810            .incoming_runtime()
811            .policy()
812            .max_dependency_requests;
813        let mut summary = super::summary::SyncSummary::default();
814        if restored_count > 0 {
815            add_group_sync_error(&mut summary, GroupError::GroupInactive);
816        }
817        let publish =
818            run_group_sync_work(&groups, GroupSyncWork::Publish, concurrency, deadline).await;
819        for error in publish.into_iter().filter_map(Result::err) {
820            summary.add_publish_err(error);
821        }
822        let received = crate::subscriptions::barrier::receive_through_current_until(
823            &self.context,
824            topics,
825            deadline,
826        )
827        .await;
828        let unfinished = unfinished_sync_topics(received.as_ref().err());
829        if let Err(error) = received {
830            add_group_sync_error(&mut summary, error.into());
831        }
832        let post_commit = run_group_sync_work(
833            &groups,
834            GroupSyncWork::PostCommit(&unfinished),
835            concurrency,
836            deadline,
837        )
838        .await;
839        finish_group_sync(summary, num_eligible, post_commit)
840    }
841
842    /// Sweep every paused group and clear the pause flag for any
843    /// whose `paused_for_version` is now satisfied by the client's
844    /// `pkg_version`. Pure local-state operation — no network calls.
845    ///
846    /// Returns the count of groups unstuck. Safe to call on any
847    /// installation regardless of whether any groups are paused
848    /// (a no-op on installations with none).
849    ///
850    /// This is the recovery path for the "user upgrades but didn't
851    /// touch a paused group" scenario: without this sweep a paused
852    /// group could stay paused indefinitely after the upgrade, since
853    /// `handle_group_paused` (which is the per-group re-evaluator)
854    /// only fires when the group is actively synced — and
855    /// `sync_all_welcomes_and_groups` filters out groups with no
856    /// new messages on the server.
857    pub async fn unstick_paused_groups(&self) -> Result<usize, GroupError> {
858        use xmtp_mls_group_state::libxmtp_version::LibXMTPVersion;
859
860        let paused = self.context.db().get_paused_groups_with_versions()?;
861        if paused.is_empty() {
862            return Ok(0);
863        }
864        // The client's own version is parsed once at `VersionInfo`
865        // construction; reuse it across every paused group.
866        let own_version_str = self.context.version_info().pkg_version().to_string();
867        let own_v = self.context.version_info().pkg_semver();
868
869        let mut unstuck = 0usize;
870        for (group_id, required_str) in paused {
871            // Lenient on malformed stored bytes — log and skip rather
872            // than fail the whole sweep (one corrupted row shouldn't
873            // brick recovery for all the others).
874            let Ok(required_v) = LibXMTPVersion::parse(&required_str) else {
875                tracing::warn!(
876                    group_id = hex::encode(group_id.as_ref()),
877                    required = %required_str,
878                    "skipping unparseable paused_for_version while sweeping"
879                );
880                continue;
881            };
882            if required_v <= *own_v {
883                // Same leniency as the parse-error branch above: a
884                // transient DB failure on one row shouldn't abort the
885                // sweep for the others. The next sync sweep will pick
886                // this row up again.
887                if let Err(err) = self.context.db().unpause_group(&group_id) {
888                    tracing::warn!(
889                        group_id = hex::encode(group_id.as_ref()),
890                        required = %required_str,
891                        error = %err,
892                        "failed to unpause group during sweep; will retry on next sync"
893                    );
894                    continue;
895                }
896                tracing::debug!(
897                    group_id = hex::encode(group_id.as_ref()),
898                    required = %required_str,
899                    own = %own_version_str,
900                    "unstuck previously paused group: client version now satisfies floor"
901                );
902                unstuck += 1;
903            }
904        }
905        Ok(unstuck)
906    }
907
908    /// Sync the initial groups and fixed-target Welcome discoveries under one deadline.
909    /// Later local groups and above-target Welcomes do not expand this call's scope.
910    pub async fn sync_all_welcomes_and_groups(
911        &self,
912        consent_states: Option<Vec<ConsentState>>,
913    ) -> Result<GroupSyncSummary, GroupError> {
914        use crate::subscriptions::incoming::{IncomingCoordinator, IncomingScope};
915        use xmtp_common::time::Instant;
916
917        let deadline = Instant::now() + self.context.incoming_runtime().policy().barrier_timeout;
918        // Fix the caller's initial group set before any asynchronous work.
919        let groups: Vec<_> = self
920            .context
921            .db()
922            .stored_group_refs(&GroupScope::listing(consent_states.clone()).without_restored())?
923            .into_iter()
924            .map(|group| self.mls_group(group))
925            .collect();
926        let initial_ids = groups.iter().map(|group| group.group_id).collect();
927        let mut topics = group_sync_topics(&groups);
928        topics.push(Topic::new_welcome_message(self.context.installation_id()));
929        let _receipt =
930            IncomingCoordinator::for_context(&self.context).acquire(IncomingScope::Topics(topics));
931        let concurrency = self
932            .context
933            .incoming_runtime()
934            .policy()
935            .max_dependency_requests;
936        let mut summary = super::summary::SyncSummary::default();
937        if let Err(error) = self.unstick_paused_groups().await {
938            add_group_sync_error(&mut summary, error);
939        }
940        let publish =
941            run_group_sync_work(&groups, GroupSyncWork::Publish, concurrency, deadline).await;
942        for error in publish.into_iter().filter_map(Result::err) {
943            summary.add_publish_err(error);
944        }
945        let received = crate::subscriptions::barrier::receive_with_welcomes_until(
946            &self.context,
947            initial_ids,
948            consent_states,
949            deadline,
950        )
951        .await;
952        let unfinished = unfinished_sync_topics(received.result.as_ref().err());
953        if let Err(error) = received.result {
954            add_group_sync_error(&mut summary, error.into());
955        }
956        let mut enrolled: std::collections::HashMap<_, _> = groups
957            .into_iter()
958            .map(|group| (group.group_id, group))
959            .collect();
960        let mut enrolled_ids: HashSet<_> = enrolled.keys().copied().collect();
961        for group_id in received.group_ids {
962            if !enrolled_ids.insert(group_id) {
963                continue;
964            }
965            match MlsGroup::new_cached(self.context.clone(), &group_id) {
966                Ok((group, _)) => {
967                    enrolled.insert(group_id, group);
968                }
969                Err(error) => add_group_sync_error(&mut summary, error.into()),
970            }
971        }
972        let num_eligible = enrolled_ids.len();
973        let groups: Vec<_> = enrolled.into_values().collect();
974        let post_commit = run_group_sync_work(
975            &groups,
976            GroupSyncWork::PostCommit(&unfinished),
977            concurrency,
978            deadline,
979        )
980        .await;
981        finish_group_sync(summary, num_eligible, post_commit)
982    }
983}
984
985#[derive(Clone, Copy)]
986enum GroupSyncWork<'a> {
987    Publish,
988    PostCommit(&'a HashSet<Topic>),
989}
990
991fn unfinished_sync_topics(
992    error: Option<&crate::subscriptions::barrier::BarrierError>,
993) -> HashSet<Topic> {
994    use crate::subscriptions::barrier::BarrierError;
995    let Some(BarrierError::Incomplete { unfinished, .. }) = error else {
996        return HashSet::new();
997    };
998    unfinished
999        .iter()
1000        .map(|status| status.topic.clone())
1001        .collect()
1002}
1003
1004fn group_sync_topics<Context: XmtpSharedContext>(groups: &[MlsGroup<Context>]) -> Vec<Topic> {
1005    groups
1006        .iter()
1007        .map(|group| Topic::new_group_message(group.group_id))
1008        .collect()
1009}
1010
1011/// Every group gets an outcome, including work still queued when the deadline expires.
1012async fn run_group_sync_work<Context: XmtpSharedContext>(
1013    groups: &[MlsGroup<Context>],
1014    work: GroupSyncWork<'_>,
1015    concurrency: usize,
1016    deadline: xmtp_common::time::Instant,
1017) -> Vec<Result<bool, GroupError>> {
1018    use xmtp_common::time::{Instant, timeout};
1019
1020    stream::iter(groups.iter().cloned())
1021        .map(|group| async move {
1022            let remaining = deadline.saturating_duration_since(Instant::now());
1023            let timed_out = || GroupError::SyncFailedToWait(Box::default());
1024            if remaining.is_zero() {
1025                return Err(timed_out());
1026            }
1027            // A rate-limit wait past the deadline returns the rejection, so
1028            // its delay is reported instead of being lost to the timeout.
1029            let work = xmtp_common::retry::with_retry_deadline(deadline, async {
1030                match work {
1031                    GroupSyncWork::Publish => {
1032                        let active = group.is_active()?;
1033                        if active {
1034                            group.publish_intents().await?;
1035                        }
1036                        Ok(active)
1037                    }
1038                    GroupSyncWork::PostCommit(unfinished) => {
1039                        group.post_commit().await?;
1040                        if group.is_active()?
1041                            && !unfinished.contains(&Topic::new_group_message(group.group_id))
1042                        {
1043                            // Maintenance adds outgoing work, not a replacement fixed target.
1044                            // The outer timeout keeps this work within the same deadline.
1045                            group.maybe_update_installations(None).await?;
1046                        }
1047                        group.is_active()
1048                    }
1049                }
1050            });
1051            match timeout(remaining, work).await {
1052                Ok(result) => result,
1053                Err(_) => Err(timed_out()),
1054            }
1055        })
1056        .buffer_unordered(concurrency)
1057        .collect()
1058        .await
1059}
1060
1061fn add_group_sync_error(summary: &mut super::summary::SyncSummary, error: GroupError) {
1062    let error = match summary.other.take() {
1063        Some(first) => combine_sync_errors(*first, error),
1064        None => error,
1065    };
1066    summary.add_other(error);
1067}
1068
1069fn finish_group_sync(
1070    mut summary: super::summary::SyncSummary,
1071    num_eligible: usize,
1072    post_commit: Vec<Result<bool, GroupError>>,
1073) -> Result<GroupSyncSummary, GroupError> {
1074    let mut num_synced = 0;
1075    for result in post_commit {
1076        match result {
1077            Ok(active) => num_synced += usize::from(active),
1078            Err(error) => summary.add_post_commit_err(error),
1079        }
1080    }
1081    if summary.is_errored() {
1082        Err(summary.into())
1083    } else {
1084        Ok(GroupSyncSummary::new(num_eligible, num_synced))
1085    }
1086}
1087
1088fn combine_sync_errors(first: GroupError, second: GroupError) -> GroupError {
1089    use crate::subscriptions::barrier::{BarrierError, BarrierFailure};
1090    match (first, second) {
1091        (
1092            GroupError::StreamBarrier(BarrierError::Incomplete {
1093                reason: a,
1094                mut unfinished,
1095            }),
1096            GroupError::StreamBarrier(BarrierError::Incomplete {
1097                reason: b,
1098                unfinished: next,
1099            }),
1100        ) => {
1101            unfinished.extend(next);
1102            let reason = if a == BarrierFailure::Cancelled || b == BarrierFailure::Cancelled {
1103                BarrierFailure::Cancelled
1104            } else if a == BarrierFailure::Deadline || b == BarrierFailure::Deadline {
1105                BarrierFailure::Deadline
1106            } else {
1107                BarrierFailure::Blocked
1108            };
1109            BarrierError::Incomplete { reason, unfinished }.into()
1110        }
1111        (first, second) => {
1112            let mut summary = super::summary::SyncSummary::other(first);
1113            summary.add_publish_err(second);
1114            GroupError::from(summary)
1115        }
1116    }
1117}
1118
1119#[cfg(test)]
1120pub(crate) async fn pending_welcome_for_test(
1121    context: &impl XmtpSharedContext,
1122    welcome: &xmtp_proto::types::WelcomeMessage,
1123) -> Result<StoredIncomingEnvelope, GroupError> {
1124    let topic = StreamTopic {
1125        entity_id: context.installation_id().to_vec(),
1126        kind: NetworkEntityKind::Welcome,
1127    };
1128    let store = MlsStore::new(context.clone());
1129    loop {
1130        if let Some(pending) = context.db().pending_envelope(&topic, welcome.cursor)? {
1131            return Ok(pending);
1132        }
1133        let page = store
1134            .receive_topics_once(
1135                &[Topic::new_welcome_message(context.installation_id())],
1136                context
1137                    .incoming_runtime()
1138                    .policy()
1139                    .incoming_limits(NetworkEntityKind::Welcome),
1140            )
1141            .await?;
1142        if !page.has_more {
1143            return context
1144                .db()
1145                .pending_envelope(&topic, welcome.cursor)?
1146                .ok_or_else(|| ProcessIntentError::WelcomeAlreadyProcessed(welcome.cursor).into());
1147        }
1148    }
1149}
1150
1151/// Alix's group, from which Alix removed and then re-added Bo. Bo still holds
1152/// the group as active: it received the group topic only through its removal,
1153/// because the secrets of its old membership cannot process the re-add commit,
1154/// and it processed none of it. The re-add Welcome is pending.
1155#[cfg(test)]
1156pub(crate) struct HeldReadd<C> {
1157    pub(crate) alix_group: MlsGroup<C>,
1158    pub(crate) bo_group: MlsGroup<C>,
1159    pub(crate) welcome: xmtp_proto::types::WelcomeMessage,
1160    /// Bo's receipt of the group topic through its removal.
1161    pub(crate) removal: xmtp_db::incoming_envelope::AdmissionResult,
1162}
1163
1164#[cfg(test)]
1165pub(crate) async fn held_readd<P: crate::groups::test_support::EnginePeer>(
1166    alix: &P,
1167    bo: &P,
1168) -> HeldReadd<P::Context> {
1169    use crate::context::CoreContext;
1170    let alix_group = alix.create_group(None, None).unwrap();
1171    alix_group
1172        .add_members(&[bo.context().inbox_id()])
1173        .await
1174        .unwrap();
1175    let bo_group = bo.sync_welcomes().await.unwrap().pop().unwrap();
1176    alix_group
1177        .remove_members(&[bo.context().inbox_id()])
1178        .await
1179        .unwrap();
1180    let page = MlsStore::new(bo.context().clone())
1181        .receive_topics_once(
1182            &[Topic::new_group_message(bo_group.group_id)],
1183            bo.context()
1184                .incoming_runtime()
1185                .policy()
1186                .incoming_limits(NetworkEntityKind::Group),
1187        )
1188        .await
1189        .unwrap();
1190    assert!(!page.has_more);
1191    let removal = page.admissions[0].1;
1192    assert!(removal.inserted > 0);
1193    alix_group
1194        .add_members(&[bo.context().inbox_id()])
1195        .await
1196        .unwrap();
1197    let welcome = bo
1198        .context()
1199        .api()
1200        .query_welcome_messages(bo.context().installation_id())
1201        .await
1202        .unwrap()
1203        .into_iter()
1204        .max_by_key(|welcome| welcome.cursor)
1205        .unwrap();
1206    pending_welcome_for_test(bo.context(), &welcome)
1207        .await
1208        .unwrap();
1209    HeldReadd {
1210        alix_group,
1211        bo_group,
1212        welcome,
1213        removal,
1214    }
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219    use super::*;
1220    use crate::groups::WelcomeMembership;
1221    use crate::prelude::*;
1222    use crate::tester;
1223    use crate::utils::test::MlsGroupExt;
1224    use rstest::*;
1225
1226    struct RejectMembership {
1227        retryable: bool,
1228    }
1229
1230    #[derive(Clone, Copy)]
1231    enum UnreadableWelcome {
1232        Wrapper,
1233        MlsVersion,
1234        MissingKey,
1235    }
1236
1237    /// Unreadable input must expire without extending its deadline, including
1238    /// legacy blocked rows. A held Welcome does not stop admission of the next
1239    /// one; expiry releases the Welcome barrier and key retirement.
1240    // verifies: JOIN-087, JOIN-084, JOIN-063
1241    #[rstest]
1242    #[case::wrapper(UnreadableWelcome::Wrapper)]
1243    #[case::mls_version(UnreadableWelcome::MlsVersion)]
1244    #[case::missing_key(UnreadableWelcome::MissingKey)]
1245    #[xmtp_common::test(unwrap_try = true)]
1246    async fn an_unsupported_welcome_expires_after_a_final_attempt(
1247        #[case] failure: UnreadableWelcome,
1248        #[values(false, true)] previously_blocked: bool,
1249    ) {
1250        use crate::worker::key_package_maintenance::sweep_expired;
1251        use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
1252        use openmls_traits::storage::StorageProvider;
1253        use xmtp_db::ConnectionExt;
1254        use xmtp_db::incoming_envelope::{IncomingLimits, PendingBudget};
1255        use xmtp_db::schema::{incoming_envelopes, key_package_history};
1256        use xmtp_proto::backend_v1::{client_envelope::Payload, welcome_message::Version};
1257
1258        tester!(alix, disable_workers);
1259        tester!(bo, disable_workers);
1260        let group = alix.create_group(None, None).unwrap();
1261        group.invite(&bo).await.unwrap();
1262        let welcome = bo
1263            .context
1264            .api()
1265            .query_welcome_messages(bo.context.installation_id())
1266            .await
1267            .unwrap()
1268            .pop()
1269            .unwrap();
1270        let cursor = welcome.cursor;
1271        pending_welcome_for_test(&bo.context, &welcome)
1272            .await
1273            .unwrap();
1274        let service = WelcomeService::new(bo.context.clone());
1275        let db = bo.context.db();
1276
1277        let pending = db
1278            .pending_envelope(&service.topic(), cursor)
1279            .unwrap()
1280            .unwrap();
1281        let mut wire =
1282            xmtp_proto::backend_v1::ServerEnvelope::decode(pending.envelope.as_slice()).unwrap();
1283        let Payload::WelcomeMessage(message) =
1284            wire.envelope.as_mut().unwrap().payload.as_mut().unwrap()
1285        else {
1286            panic!("expected Welcome payload");
1287        };
1288        let Version::V1(message) = message.version.as_mut().unwrap() else {
1289            panic!("expected inline Welcome");
1290        };
1291        match failure {
1292            UnreadableWelcome::Wrapper => message.wrapper_algorithm = i32::MAX,
1293            UnreadableWelcome::MissingKey => message.hpke_public_key = vec![0; 32],
1294            UnreadableWelcome::MlsVersion => {
1295                (message.data, message.welcome_metadata) =
1296                    xmtp_mls_group_keys::mls_ext::payload_encryption::wrap_payload_hpke(
1297                        &[0, 2],
1298                        &[],
1299                        &message.hpke_public_key,
1300                        xmtp_id::key_package::WrapperAlgorithm::try_from(message.wrapper_algorithm)
1301                            .unwrap(),
1302                        xmtp_configuration::WELCOME_HPKE_LABEL,
1303                    )
1304                    .unwrap();
1305            }
1306        }
1307        let row = || {
1308            incoming_envelopes::table.find((
1309                bo.context.installation_id().to_vec(),
1310                EntityKind::Welcome,
1311                cursor.0 as i64,
1312            ))
1313        };
1314        let envelope = wire.encode_to_vec();
1315        db.raw_query(|conn| {
1316            diesel::update(row())
1317                .set((
1318                    incoming_envelopes::envelope_bytes.eq(envelope.len() as i64),
1319                    incoming_envelopes::envelope.eq(&envelope),
1320                    incoming_envelopes::blocked.eq(previously_blocked),
1321                    incoming_envelopes::error_code
1322                        .eq(previously_blocked.then_some("welcome_blocked")),
1323                ))
1324                .execute(conn)
1325        })
1326        .unwrap();
1327
1328        // First pass blocks the row and records a retention deadline.
1329        let before = xmtp_common::time::now_ns();
1330        if previously_blocked {
1331            service.retry_blocked_welcomes_after(Cursor(0)).unwrap();
1332        } else {
1333            service.process_pending_welcomes_once(&[]).unwrap();
1334        }
1335        let blocked = db
1336            .pending_envelope(&service.topic(), cursor)
1337            .unwrap()
1338            .unwrap();
1339        assert!(blocked.blocked);
1340        let deadline = blocked.unreadable_expires_at_ns.unwrap();
1341        assert!(deadline >= before + xmtp_common::NS_IN_DAY);
1342        assert!(deadline <= xmtp_common::time::now_ns() + xmtp_common::NS_IN_DAY);
1343        assert!(db.has_pending_welcomes().unwrap());
1344
1345        // Before the deadline the row is retained, not removed.
1346        service.retry_blocked_welcomes_after(Cursor(0)).unwrap();
1347        let retained = db.pending_envelope(&service.topic(), cursor).unwrap();
1348        assert_eq!(
1349            retained.expect("retained").unreadable_expires_at_ns,
1350            Some(deadline),
1351            "a retry must not extend the first deadline"
1352        );
1353
1354        let history = db
1355            .find_key_package_history_entries_before_id(i32::MAX)
1356            .unwrap()
1357            .pop()
1358            .unwrap();
1359        let hash = crate::identity::deserialize_key_package_hash_ref(&history.key_package_hash_ref)
1360            .unwrap();
1361        db.raw_query(|conn| {
1362            diesel::update(key_package_history::table.find(history.id))
1363                .set(key_package_history::delete_at_ns.eq(0))
1364                .execute(conn)
1365        })
1366        .unwrap();
1367        sweep_expired(&bo.context).unwrap();
1368        let retained: Option<openmls::prelude::KeyPackageBundle> =
1369            bo.context.mls_storage().key_package(&hash).unwrap();
1370        assert!(retained.is_some());
1371
1372        // A held Welcome uses the held allowance, so one row of ready
1373        // capacity still admits the next Welcome.
1374        let next_group = alix.create_group(None, None).unwrap();
1375        next_group.invite(&bo).await.unwrap();
1376        let budget = PendingBudget {
1377            rows: 1,
1378            bytes: u64::MAX,
1379        };
1380        let limits = IncomingLimits {
1381            batch: budget,
1382            topic: budget,
1383            kind: budget,
1384        };
1385        let store = MlsStore::new(bo.context.clone());
1386        let topics = [Topic::new_welcome_message(bo.context.installation_id())];
1387        store.receive_topics_once(&topics, limits).await.unwrap();
1388        assert!(db.topic_progress(&service.topic()).unwrap().received > cursor);
1389
1390        // At the deadline the final attempt runs and the row is removed, so the
1391        // Welcome barrier and key retirement are released.
1392        db.raw_query(|conn| {
1393            diesel::update(row())
1394                .set(incoming_envelopes::unreadable_expires_at_ns.eq(Some(1i64)))
1395                .execute(conn)
1396        })
1397        .unwrap();
1398        let outcomes = service.retry_blocked_welcomes_after(Cursor(0)).unwrap();
1399        let WelcomeHeadOutcome::Progress {
1400            result: Err(error), ..
1401        } = outcomes.into_iter().next().unwrap()
1402        else {
1403            panic!("expected terminal rejection");
1404        };
1405        match failure {
1406            UnreadableWelcome::Wrapper => {
1407                assert!(matches!(error, GroupError::UnsupportedWelcomeVersion(_)))
1408            }
1409            UnreadableWelcome::MlsVersion => assert!(matches!(
1410                error,
1411                GroupError::WelcomeError(openmls::prelude::WelcomeError::UnsupportedMlsVersion)
1412            )),
1413            UnreadableWelcome::MissingKey => assert!(matches!(
1414                error,
1415                GroupError::NotFound(xmtp_db::NotFound::KeyPackageReference(_))
1416            )),
1417        }
1418        assert_eq!(
1419            db.read_last_rejection(&service.topic())
1420                .unwrap()
1421                .unwrap()
1422                .code,
1423            "unsupported_welcome_expired"
1424        );
1425        assert!(
1426            db.pending_envelope(&service.topic(), cursor)
1427                .unwrap()
1428                .is_none()
1429        );
1430        assert!(db.topic_progress(&service.topic()).unwrap().processed >= cursor);
1431        assert!(
1432            db.welcome_barrier_complete(&service.topic(), cursor)
1433                .unwrap()
1434        );
1435
1436        let next = db
1437            .first_pending_envelope(&service.topic())
1438            .unwrap()
1439            .unwrap();
1440        assert!(matches!(
1441            service
1442                .resolve_pending_welcome(Cursor(next.sequence_id as u64))
1443                .await
1444                .unwrap(),
1445            WelcomeHeadOutcome::Progress {
1446                result: Ok(Some(_)),
1447                ..
1448            }
1449        ));
1450        assert!(db.find_group(&next_group.group_id).unwrap().is_some());
1451        sweep_expired(&bo.context).unwrap();
1452        let removed: Option<openmls::prelude::KeyPackageBundle> =
1453            bo.context.mls_storage().key_package(&hash).unwrap();
1454        assert!(removed.is_none());
1455    }
1456
1457    /// A group-prefix hold gets a deadline 3 days after the Welcome's backend
1458    /// timestamp and keeps key material until the hold ends. Classifying it
1459    /// past the deadline still holds it. Expiry needs the hold's row, a passed
1460    /// deadline, and a processed position at the target and below the anchor;
1461    /// it releases the Welcome barrier and the key package and leaves the
1462    /// held group unchanged.
1463    // verifies: JOIN-092
1464    #[xmtp_common::test(unwrap_try = true)]
1465    async fn group_prefix_wait_expires_after_three_days() {
1466        use crate::worker::key_package_maintenance::sweep_expired;
1467        use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
1468        use openmls_traits::storage::StorageProvider;
1469        use xmtp_db::ConnectionExt;
1470        use xmtp_db::schema::{incoming_envelopes, key_package_history};
1471
1472        tester!(alix, disable_workers);
1473        tester!(bo, disable_workers);
1474        let HeldReadd {
1475            bo_group, welcome, ..
1476        } = held_readd(&alix, &bo).await;
1477        let service = WelcomeService::new(bo.context.clone());
1478        let db = bo.context.db();
1479        let held = || -> Result<bool, GroupError> {
1480            Ok(db
1481                .pending_envelope(&service.topic(), welcome.cursor)?
1482                .is_some())
1483        };
1484
1485        let WelcomeHeadOutcome::Need {
1486            requirement:
1487                WelcomeRequirement::GroupPrefix {
1488                    group_id,
1489                    anchor,
1490                    deadline_ns,
1491                },
1492            ..
1493        } = service.retry_pending_welcome(welcome.cursor, None)?
1494        else {
1495            panic!("expected a group-prefix hold");
1496        };
1497        assert_eq!(group_id, bo_group.group_id);
1498        assert_eq!(deadline_ns, welcome.timestamp() + WELCOME_RETENTION_NS);
1499        let row = db
1500            .pending_envelope(&service.topic(), welcome.cursor)?
1501            .unwrap();
1502        assert_eq!(row.error_code.as_deref(), Some("group_prefix"));
1503        assert!(!row.blocked);
1504        assert_eq!(row.retry_expires_at_ns, Some(deadline_ns));
1505        assert!(!db.welcome_barrier_complete(&service.topic(), welcome.cursor)?);
1506        let processed = db.topic_progress(&StreamTopic::group(group_id))?.processed;
1507        assert!(processed < anchor);
1508        let expire = |anchor, target| {
1509            service.expire_group_prefix_welcome(welcome.cursor, group_id, anchor, target)
1510        };
1511
1512        let history = db
1513            .find_key_package_history_entries_before_id(i32::MAX)?
1514            .pop()
1515            .unwrap();
1516        let hash =
1517            crate::identity::deserialize_key_package_hash_ref(&history.key_package_hash_ref)?;
1518        db.raw_query(|conn| {
1519            diesel::update(
1520                key_package_history::table.filter(key_package_history::id.eq(history.id)),
1521            )
1522            .set(key_package_history::delete_at_ns.eq(0))
1523            .execute(conn)
1524        })?;
1525        sweep_expired(&bo.context)?;
1526        let retained: Option<openmls::prelude::KeyPackageBundle> =
1527            bo.context.mls_storage().key_package(&hash)?;
1528        assert!(
1529            retained.is_some(),
1530            "a prefix hold keeps expired private keys"
1531        );
1532
1533        // Before the deadline the hold cannot expire.
1534        assert!(matches!(
1535            expire(anchor, processed)?,
1536            WelcomeHeadOutcome::Waiting { .. }
1537        ));
1538        assert!(held()?);
1539
1540        // Past the deadline, a row that is no longer a group-prefix hold
1541        // cannot expire.
1542        db.raw_query(|conn| {
1543            diesel::update(incoming_envelopes::table.find((
1544                bo.context.installation_id().to_vec(),
1545                EntityKind::Welcome,
1546                welcome.cursor.0 as i64,
1547            )))
1548            .set((
1549                incoming_envelopes::retry_expires_at_ns.eq(Some(1i64)),
1550                incoming_envelopes::error_code.eq("identity_dependency"),
1551            ))
1552            .execute(conn)
1553        })?;
1554        assert!(matches!(
1555            expire(anchor, processed)?,
1556            WelcomeHeadOutcome::Waiting { .. }
1557        ));
1558        assert!(held()?);
1559
1560        // Classification keeps the stored deadline and still holds the
1561        // Welcome until the coordinator decides.
1562        assert!(matches!(
1563            service.retry_pending_welcome(welcome.cursor, None)?,
1564            WelcomeHeadOutcome::Need {
1565                requirement: WelcomeRequirement::GroupPrefix { deadline_ns: 1, .. },
1566                ..
1567            }
1568        ));
1569        assert_eq!(
1570            db.pending_envelope(&service.topic(), welcome.cursor)?
1571                .unwrap()
1572                .retry_expires_at_ns,
1573            Some(1)
1574        );
1575
1576        // Processing has not reached the target, or is not below the anchor.
1577        for (anchor, target) in [(anchor, Cursor(processed.0 + 1)), (processed, processed)] {
1578            assert!(matches!(
1579                expire(anchor, target)?,
1580                WelcomeHeadOutcome::Waiting { .. }
1581            ));
1582            assert!(held()?);
1583        }
1584
1585        let epoch = bo_group.epoch().await?;
1586        assert!(matches!(
1587            expire(anchor, processed)?,
1588            WelcomeHeadOutcome::Progress {
1589                result: Err(GroupError::WelcomeGroupPrefixPending { .. }),
1590                ..
1591            }
1592        ));
1593        assert!(db.welcome_barrier_complete(&service.topic(), welcome.cursor)?);
1594        assert_eq!(
1595            db.read_last_rejection(&service.topic())?.unwrap().code,
1596            "group_prefix_expired"
1597        );
1598        assert!(!held()?);
1599        assert!(bo_group.is_active()?);
1600        assert_eq!(bo_group.epoch().await?, epoch);
1601        sweep_expired(&bo.context)?;
1602        let retired: Option<openmls::prelude::KeyPackageBundle> =
1603            bo.context.mls_storage().key_package(&hash)?;
1604        assert!(retired.is_none(), "key is retired once the hold ends");
1605    }
1606
1607    enum WelcomeRetention {
1608        Unreadable,
1609        Held,
1610        Terminal,
1611    }
1612
1613    /// Only unreadable MLS formats and missing Welcome key material get this
1614    /// deadline; local failures and required group versions keep their policy.
1615    // verifies: JOIN-087, JOIN-084, JOIN-085, JOIN-048, JOIN-027, JOIN-043
1616    #[rstest]
1617    #[case::mls_version(|| openmls::prelude::WelcomeError::UnsupportedMlsVersion.into(), WelcomeRetention::Unreadable)]
1618    #[case::extensions(|| openmls::prelude::WelcomeError::UnsupportedExtensions.into(), WelcomeRetention::Unreadable)]
1619    #[case::capability(|| openmls::prelude::WelcomeError::UnsupportedCapability.into(), WelcomeRetention::Unreadable)]
1620    #[case::ciphersuite(|| openmls::prelude::WelcomeError::UnsupportedCiphersuite(openmls::prelude::Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519).into(), WelcomeRetention::Unreadable)]
1621    #[case::key_reference(|| xmtp_db::NotFound::KeyPackageReference(vec![]).into(), WelcomeRetention::Unreadable)]
1622    #[case::key_package(|| xmtp_db::NotFound::KeyPackage(vec![]).into(), WelcomeRetention::Unreadable)]
1623    #[case::post_quantum_key(|| xmtp_db::NotFound::PostQuantumPrivateKey.into(), WelcomeRetention::Unreadable)]
1624    #[case::group_version(|| GroupError::UnsupportedWelcomeVersion("999.0.0".into()), WelcomeRetention::Held)]
1625    #[case::local_failure(|| GroupError::LockUnavailable, WelcomeRetention::Held)]
1626    #[case::unrelated_missing_state(|| xmtp_db::NotFound::MlsGroup(GroupId::from([1; 16])).into(), WelcomeRetention::Held)]
1627    #[case::psk(|| openmls::prelude::WelcomeError::Psk(openmls::schedule::errors::PskError::KeyNotFound).into(), WelcomeRetention::Terminal)]
1628    #[case::stale_join_anchor(|| xmtp_db::StorageError::Stream(xmtp_db::stream_storage::StreamStorageError::StaleJoinAnchor).into(), WelcomeRetention::Terminal)]
1629    #[xmtp_common::test(unwrap_try = true)]
1630    async fn welcome_retention_is_limited_to_unreadable_input(
1631        #[case] error: fn() -> GroupError,
1632        #[case] expectation: WelcomeRetention,
1633        #[values(false, true)] prior_pointer_hold: bool,
1634    ) {
1635        use xmtp_db::ConnectionExt;
1636        use xmtp_db::incoming_envelope::NewIncomingEnvelope;
1637
1638        tester!(bo, disable_workers);
1639        let service = WelcomeService::new(bo.context.clone());
1640        let db = bo.context.db();
1641        db.admit_ordered_batch(
1642            &service.topic(),
1643            Cursor(0),
1644            &[NewIncomingEnvelope {
1645                sequence_id: Cursor(1),
1646                envelope: vec![1],
1647            }],
1648            bo.context
1649                .incoming_runtime()
1650                .policy()
1651                .incoming_limits(NetworkEntityKind::Welcome),
1652        )
1653        .unwrap();
1654        if prior_pointer_hold {
1655            db.set_incoming_retry(
1656                &service.topic(),
1657                Cursor(1),
1658                &IncomingRetry {
1659                    retry_at_ns: 0,
1660                    blocked: false,
1661                    error_code: Some("welcome_pointee".into()),
1662                    retry_expires_at_ns: Some(1),
1663                },
1664            )
1665            .unwrap();
1666        }
1667        let pending = db
1668            .pending_envelope(&service.topic(), Cursor(1))
1669            .unwrap()
1670            .unwrap();
1671        let outcome = service
1672            .classify_pending_result(&pending, Err(error()))
1673            .unwrap();
1674        if matches!(expectation, WelcomeRetention::Terminal) {
1675            assert!(matches!(
1676                outcome,
1677                WelcomeHeadOutcome::Progress { result: Err(_), .. }
1678            ));
1679            let rejection = db.read_last_rejection(&service.topic()).unwrap();
1680            assert_eq!(rejection.unwrap().code, "invalid_welcome");
1681            assert!(
1682                db.pending_envelope(&service.topic(), Cursor(1))
1683                    .unwrap()
1684                    .is_none()
1685            );
1686            assert!(!db.has_pending_welcomes().unwrap());
1687            return;
1688        }
1689        assert!(matches!(outcome, WelcomeHeadOutcome::Waiting { .. }));
1690        let pending = db
1691            .pending_envelope(&service.topic(), Cursor(1))
1692            .unwrap()
1693            .unwrap();
1694        let bounded = matches!(expectation, WelcomeRetention::Unreadable);
1695        assert_eq!(pending.unreadable_expires_at_ns.is_some(), bounded);
1696        if bounded {
1697            assert!(pending.blocked);
1698            assert!(pending.unreadable_expires_at_ns.unwrap() > xmtp_common::time::now_ns());
1699            // Present the final attempt with a persisted elapsed deadline.
1700            db.raw_query(|conn| {
1701                use diesel::{ExpressionMethods, RunQueryDsl};
1702                use xmtp_db::schema::incoming_envelopes;
1703                diesel::update(incoming_envelopes::table)
1704                    .set(incoming_envelopes::unreadable_expires_at_ns.eq(Some(1i64)))
1705                    .execute(conn)
1706            })
1707            .unwrap();
1708            let pending = db
1709                .pending_envelope(&service.topic(), Cursor(1))
1710                .unwrap()
1711                .unwrap();
1712            assert!(matches!(
1713                service
1714                    .classify_pending_result(&pending, Err(error()))
1715                    .unwrap(),
1716                WelcomeHeadOutcome::Progress { result: Err(_), .. }
1717            ));
1718            assert!(!db.has_pending_welcomes().unwrap());
1719        }
1720    }
1721
1722    /// A full held allowance gives up the oldest held Welcome, so new holds
1723    /// cannot grow held storage without bound.
1724    // verifies: PROC-020, JOIN-057
1725    #[xmtp_common::test(unwrap_try = true)]
1726    async fn a_full_held_allowance_rejects_the_oldest_held_welcome() {
1727        use xmtp_db::incoming_envelope::NewIncomingEnvelope;
1728
1729        tester!(bo, disable_workers);
1730        let mut settings = bo.context.incoming_runtime().policy().clone();
1731        settings.welcome_pending.rows = 2;
1732        let client = crate::builder::ClientBuilder::from_client(bo.client.clone())
1733            .stream_policy(settings)
1734            .with_disable_workers(true)
1735            .with_allow_offline(Some(true))
1736            .build()
1737            .await?;
1738        let service = WelcomeService::new(client.context.clone());
1739        let db = client.context.db();
1740        let limits = client
1741            .context
1742            .incoming_runtime()
1743            .policy()
1744            .incoming_limits(NetworkEntityKind::Welcome);
1745        // Three Welcomes for groups that require a later client version.
1746        for sequence in 1..=3 {
1747            let envelope = NewIncomingEnvelope {
1748                sequence_id: Cursor(sequence),
1749                envelope: vec![1],
1750            };
1751            db.admit_ordered_batch(&service.topic(), Cursor(sequence - 1), &[envelope], limits)?;
1752            let pending = db
1753                .pending_envelope(&service.topic(), Cursor(sequence))?
1754                .unwrap();
1755            let floor = GroupError::UnsupportedWelcomeVersion("999.0.0".into());
1756            service.classify_pending_result(&pending, Err(floor))?;
1757        }
1758        assert!(db.pending_envelope(&service.topic(), Cursor(1))?.is_none());
1759        let rejection = db.read_last_rejection(&service.topic())?.unwrap();
1760        assert_eq!(
1761            (rejection.sequence_id, rejection.code.as_str()),
1762            (Cursor(1), "held_capacity")
1763        );
1764        for kept in [2, 3] {
1765            assert!(
1766                db.pending_envelope(&service.topic(), Cursor(kept))?
1767                    .unwrap()
1768                    .blocked
1769            );
1770        }
1771    }
1772
1773    /// A Welcome refused by a rate limit is not retried before the delay.
1774    // verifies: RATE-041
1775    #[xmtp_common::test(unwrap_try = true)]
1776    async fn a_rate_limited_welcome_retries_after_the_delay() {
1777        use xmtp_db::incoming_envelope::NewIncomingEnvelope;
1778        tester!(bo, disable_workers);
1779        let service = WelcomeService::new(bo.context.clone());
1780        let db = bo.context.db();
1781        db.admit_ordered_batch(
1782            &service.topic(),
1783            Cursor(0),
1784            &[NewIncomingEnvelope {
1785                sequence_id: Cursor(1),
1786                envelope: vec![1],
1787            }],
1788            bo.context
1789                .incoming_runtime()
1790                .policy()
1791                .incoming_limits(NetworkEntityKind::Welcome),
1792        )?;
1793        let pending = db.pending_envelope(&service.topic(), Cursor(1))?.unwrap();
1794        let delay = xmtp_common::time::Duration::from_secs(300);
1795        let refused = GroupError::WrappedApi(xmtp_api::ApiError::Api(
1796            xmtp_proto::api::NetworkError::new(xmtp_api_grpc::error::GrpcError::Status(
1797                xmtp_api_grpc::error::rate_limited_status("request", delay),
1798            )),
1799        ));
1800        let before = xmtp_common::time::now_ns();
1801        assert!(matches!(
1802            service.classify_pending_result(&pending, Err(refused))?,
1803            WelcomeHeadOutcome::Waiting { blocked: false, .. }
1804        ));
1805        let pending = db.pending_envelope(&service.topic(), Cursor(1))?.unwrap();
1806        assert!(pending.retry_at_ns >= before + delay.as_nanos() as i64);
1807    }
1808
1809    /// A rate limit longer than a pointer's retention retries at the deadline,
1810    /// where the pointer expires without a fetch, instead of never.
1811    // verifies: RATE-041
1812    #[xmtp_common::test(unwrap_try = true)]
1813    async fn a_pointee_rate_limit_never_outlasts_its_retention() {
1814        use xmtp_db::incoming_envelope::NewIncomingEnvelope;
1815        tester!(bo, disable_workers);
1816        let service = WelcomeService::new(bo.context.clone());
1817        let db = bo.context.db();
1818        db.admit_ordered_batch(
1819            &service.topic(),
1820            Cursor(0),
1821            &[NewIncomingEnvelope {
1822                sequence_id: Cursor(1),
1823                envelope: vec![1],
1824            }],
1825            bo.context
1826                .incoming_runtime()
1827                .policy()
1828                .incoming_limits(NetworkEntityKind::Welcome),
1829        )?;
1830        let pending = db.pending_envelope(&service.topic(), Cursor(1))?.unwrap();
1831        let deadline = xmtp_common::time::now_ns() + xmtp_common::NS_IN_SEC;
1832        service.defer_pending(
1833            &pending,
1834            "welcome_pointee",
1835            false,
1836            Some(deadline),
1837            Some(xmtp_common::time::Duration::MAX),
1838        )?;
1839        let pending = db.pending_envelope(&service.topic(), Cursor(1))?.unwrap();
1840        assert!(pending.retry_at_ns <= deadline);
1841    }
1842
1843    /// Gaining format support or restoring a missing key package must permit
1844    /// the final attempt to join, even after the unreadable deadline expires.
1845    // verifies: JOIN-087, JOIN-084
1846    #[rstest]
1847    #[case::wrapper("unsupported_welcome")]
1848    #[case::missing_key("welcome_blocked")]
1849    #[xmtp_common::test(unwrap_try = true)]
1850    async fn an_expired_unsupported_welcome_still_installs_once_it_is_supported(
1851        #[case] code: &'static str,
1852        #[values(false, true)] pointer: bool,
1853    ) {
1854        use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
1855        use openmls_traits::storage::StorageProvider;
1856        use xmtp_db::ConnectionExt;
1857        use xmtp_db::schema::incoming_envelopes;
1858
1859        tester!(alix, disable_workers);
1860        tester!(bo, disable_workers);
1861        let _other_installations = if pointer {
1862            tester!(bo2, from: bo, disable_workers);
1863            tester!(bo3, from: bo, disable_workers);
1864            Some((bo2, bo3))
1865        } else {
1866            None
1867        };
1868        let group = alix.create_group(None, None).unwrap();
1869        group.invite(&bo).await.unwrap();
1870        let welcome = bo
1871            .context
1872            .api()
1873            .query_welcome_messages(bo.context.installation_id())
1874            .await
1875            .unwrap()
1876            .pop()
1877            .unwrap();
1878        assert_eq!(
1879            matches!(
1880                welcome.variant,
1881                xmtp_proto::types::WelcomeMessageType::WelcomePointer(_)
1882            ),
1883            pointer
1884        );
1885        let cursor = welcome.cursor;
1886        pending_welcome_for_test(&bo.context, &welcome)
1887            .await
1888            .unwrap();
1889        let service = WelcomeService::new(bo.context.clone());
1890        let db = bo.context.db();
1891
1892        let missing_key = if code == "welcome_blocked" {
1893            let history = db
1894                .find_key_package_history_entries_before_id(i32::MAX)
1895                .unwrap()
1896                .pop()
1897                .unwrap();
1898            let hash_ref =
1899                crate::identity::deserialize_key_package_hash_ref(&history.key_package_hash_ref)
1900                    .unwrap();
1901            let storage = bo.context.mls_storage();
1902            let bundle: openmls::prelude::KeyPackageBundle =
1903                storage.key_package(&hash_ref).unwrap().unwrap();
1904            storage.delete_key_package(&hash_ref).unwrap();
1905            assert!(matches!(
1906                service.resolve_pending_welcome(cursor).await.unwrap(),
1907                WelcomeHeadOutcome::Waiting { blocked: true, .. }
1908            ));
1909            assert!(
1910                db.pending_envelope(&service.topic(), cursor)
1911                    .unwrap()
1912                    .unwrap()
1913                    .unreadable_expires_at_ns
1914                    .unwrap()
1915                    > xmtp_common::time::now_ns()
1916            );
1917            Some((hash_ref, bundle))
1918        } else {
1919            None
1920        };
1921
1922        // Simulate the first retry after the unreadable deadline, then restore
1923        // the key package when it was missing. The wrapper case models a
1924        // client upgrade that now supports the stored Welcome.
1925        db.raw_query(|conn| {
1926            diesel::update(incoming_envelopes::table.find((
1927                bo.context.installation_id().to_vec(),
1928                EntityKind::Welcome,
1929                cursor.0 as i64,
1930            )))
1931            .set((
1932                incoming_envelopes::blocked.eq(true),
1933                incoming_envelopes::error_code.eq(Some(code)),
1934                incoming_envelopes::unreadable_expires_at_ns.eq(Some(1i64)),
1935            ))
1936            .execute(conn)
1937        })
1938        .unwrap();
1939
1940        if let Some((hash_ref, bundle)) = missing_key {
1941            bo.context
1942                .mls_storage()
1943                .write_key_package(&hash_ref, &bundle)
1944                .unwrap();
1945        }
1946
1947        assert!(matches!(
1948            service
1949                .retry_blocked_welcomes_after(Cursor(0))
1950                .unwrap()
1951                .as_slice(),
1952            [WelcomeHeadOutcome::Need { .. }]
1953        ));
1954
1955        // The row must survive: an elapsed unsupported deadline must not delete
1956        // a Welcome this build can now read. It rejoins ordinary processing,
1957        // which may still need an async dependency before it installs.
1958        assert!(
1959            db.pending_envelope(&service.topic(), cursor)
1960                .unwrap()
1961                .is_some(),
1962            "a supported Welcome must not be deleted by a stale deadline"
1963        );
1964        assert!(db.topic_progress(&service.topic()).unwrap().processed < cursor);
1965        assert!(matches!(
1966            service.resolve_pending_welcome(cursor).await.unwrap(),
1967            WelcomeHeadOutcome::Progress {
1968                result: Ok(Some(_)),
1969                ..
1970            }
1971        ));
1972        assert!(db.find_group(&group.group_id).unwrap().is_some());
1973        assert!(!db.has_pending_welcomes().unwrap());
1974        assert!(db.read_last_rejection(&service.topic()).unwrap().is_none());
1975    }
1976
1977    /// Legacy blocked pointers retain their classification while re-fetches
1978    /// miss, but the fixed pointer deadline still bounds missing data. At the
1979    /// cutoff, a real fetch can recover the join; a miss must release the row.
1980    /// Missing legacy deadlines use the pointer timestamp, never the retry time.
1981    // verifies: JOIN-094, JOIN-063, JOIN-035
1982    #[rstest]
1983    #[case::recovered(false)]
1984    #[case::still_missing(true)]
1985    #[xmtp_common::test(unwrap_try = true)]
1986    async fn legacy_blocked_pointer_has_bounded_refetches(
1987        #[case] remains_missing: bool,
1988        #[values(false, true)] stored_deadline: bool,
1989    ) {
1990        use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
1991        use xmtp_db::{ConnectionExt, schema::incoming_envelopes};
1992
1993        tester!(alix, disable_workers);
1994        tester!(bo, disable_workers);
1995        tester!(_bo2, from: bo, disable_workers);
1996        tester!(_bo3, from: bo, disable_workers);
1997        let group = alix.create_group(None, None).unwrap();
1998        group.invite(&bo).await.unwrap();
1999        let welcome = bo
2000            .context
2001            .api()
2002            .query_welcome_messages(bo.context.installation_id())
2003            .await
2004            .unwrap()
2005            .pop()
2006            .unwrap();
2007        assert!(matches!(
2008            welcome.variant,
2009            xmtp_proto::types::WelcomeMessageType::WelcomePointer(_)
2010        ));
2011        pending_welcome_for_test(&bo.context, &welcome)
2012            .await
2013            .unwrap();
2014        let service = WelcomeService::new(bo.context.clone());
2015        let db = bo.context.db();
2016        let deadline = if stored_deadline {
2017            xmtp_common::time::now_ns() + xmtp_common::NS_IN_HOUR
2018        } else {
2019            welcome.timestamp() + WELCOME_RETENTION_NS
2020        };
2021        db.set_incoming_retry(
2022            &service.topic(),
2023            welcome.cursor,
2024            &IncomingRetry {
2025                retry_at_ns: 0,
2026                blocked: true,
2027                error_code: Some("welcome_blocked".into()),
2028                retry_expires_at_ns: stored_deadline.then_some(deadline),
2029            },
2030        )
2031        .unwrap();
2032
2033        // Preserve the real pointer and keys while simulating a replica that
2034        // temporarily cannot return a pointee the old client had already read.
2035        let mut api = xmtp_api_backend::MockBackendClient::new();
2036        api.expect_query_raw_envelopes().returning(|_| {
2037            Ok(xmtp_proto::backend_v1::QueryResponse {
2038                envelopes: vec![],
2039                continuation: Some(xmtp_proto::backend_v1::Continuation { has_more: false }),
2040            }
2041            .into())
2042        });
2043        let missing_client = crate::builder::ClientBuilder::from_client(bo.client.clone())
2044            .api_client(std::sync::Arc::new(api))
2045            .config_provider(std::sync::Arc::new(
2046                xmtp_configuration::StaticConfigProvider::default(),
2047            ))
2048            .with_allow_offline(Some(true))
2049            .with_disable_workers(true)
2050            .build()
2051            .await
2052            .unwrap();
2053        for _ in 0..2 {
2054            let missing_service = WelcomeService::new(missing_client.context.clone());
2055            assert!(matches!(
2056                missing_service
2057                    .retry_blocked_welcomes_after(Cursor(0))
2058                    .unwrap()
2059                    .as_slice(),
2060                [WelcomeHeadOutcome::Need { .. }]
2061            ));
2062            assert!(matches!(
2063                missing_service.resolve_pending_welcome(welcome.cursor).await.unwrap(),
2064                WelcomeHeadOutcome::Waiting { blocked: true, code, .. }
2065                    if code == "welcome_blocked"
2066            ));
2067            let pending = db
2068                .pending_envelope(&service.topic(), welcome.cursor)
2069                .unwrap()
2070                .unwrap();
2071            assert!(pending.blocked);
2072            assert_eq!(pending.error_code.as_deref(), Some("welcome_blocked"));
2073            assert_eq!(pending.retry_expires_at_ns, Some(deadline));
2074            assert_eq!(pending.unreadable_expires_at_ns, None);
2075            assert!(db.read_last_rejection(&service.topic()).unwrap().is_none());
2076            assert!(db.find_group(&group.group_id).unwrap().is_none());
2077        }
2078
2079        db.raw_query(|conn| {
2080            diesel::update(incoming_envelopes::table.find((
2081                bo.context.installation_id().to_vec(),
2082                EntityKind::Welcome,
2083                welcome.cursor.0 as i64,
2084            )))
2085            .set(incoming_envelopes::retry_expires_at_ns.eq(Some(1i64)))
2086            .execute(conn)
2087        })
2088        .unwrap();
2089        assert!(matches!(
2090            service.retry_pending_welcome(welcome.cursor, None).unwrap(),
2091            WelcomeHeadOutcome::Need { .. }
2092        ));
2093
2094        if remains_missing {
2095            let service = WelcomeService::new(missing_client.context.clone());
2096            assert!(matches!(
2097                service
2098                    .resolve_pending_welcome(welcome.cursor)
2099                    .await
2100                    .unwrap(),
2101                WelcomeHeadOutcome::Progress {
2102                    result: Err(GroupError::WelcomeDataNotFound(_)),
2103                    ..
2104                }
2105            ));
2106            assert!(!db.has_pending_welcomes().unwrap());
2107            assert!(db.find_group(&group.group_id).unwrap().is_none());
2108            assert_eq!(
2109                db.read_last_rejection(&service.topic())
2110                    .unwrap()
2111                    .unwrap()
2112                    .code,
2113                "welcome_pointer_expired"
2114            );
2115            return;
2116        }
2117
2118        // Even past the fetch deadline, try a legacy blocked pointer before
2119        // rejecting it: it may now be readable after an upgrade or recovery.
2120        assert!(matches!(
2121            service
2122                .resolve_pending_welcome(welcome.cursor)
2123                .await
2124                .unwrap(),
2125            WelcomeHeadOutcome::Progress {
2126                result: Ok(Some(_)),
2127                ..
2128            }
2129        ));
2130        assert!(!db.has_pending_welcomes().unwrap());
2131        assert!(db.find_group(&group.group_id).unwrap().is_some());
2132        assert!(db.read_last_rejection(&service.topic()).unwrap().is_none());
2133    }
2134
2135    /// How the backend fails a pointee query.
2136    #[derive(Clone, Copy, Debug)]
2137    enum PointeeQueryFailure {
2138        Unavailable,
2139        Denied,
2140        /// A row whose metadata the ordered read rejects. That is a backend
2141        /// fault, not a property of the pointee, unlike a first row whose
2142        /// payload does not decode.
2143        BadMetadata,
2144    }
2145
2146    /// Query failures, including a response with invalid metadata, obey the
2147    /// same fixed fetch window as absent pointees, including rows persisted as
2148    /// welcome_retry by older clients. An existing unreadable hold keeps its
2149    /// own deadline through transport failures. A metadata fault is held like
2150    /// `Denied` once it is classified, so only a fresh row needs that case.
2151    // verifies: JOIN-094, JOIN-087, JOIN-063, JOIN-035
2152    #[rstest]
2153    #[case::fresh_unavailable(None, false, PointeeQueryFailure::Unavailable)]
2154    #[case::fresh_denied(None, false, PointeeQueryFailure::Denied)]
2155    #[case::fresh_bad_metadata(None, false, PointeeQueryFailure::BadMetadata)]
2156    #[case::legacy_retry_unavailable(
2157        Some("welcome_retry"),
2158        false,
2159        PointeeQueryFailure::Unavailable
2160    )]
2161    #[case::legacy_retry_denied(Some("welcome_retry"), false, PointeeQueryFailure::Denied)]
2162    #[case::unreadable_unavailable(Some("welcome_blocked"), true, PointeeQueryFailure::Unavailable)]
2163    #[case::unreadable_denied(Some("welcome_blocked"), true, PointeeQueryFailure::Denied)]
2164    #[xmtp_common::test(unwrap_try = true)]
2165    async fn pointer_query_errors_have_bounded_retention(
2166        #[case] prior_code: Option<&'static str>,
2167        #[case] unreadable: bool,
2168        #[case] failure: PointeeQueryFailure,
2169    ) {
2170        use xmtp_proto::backend_v1 as wire;
2171        let retryable = matches!(failure, PointeeQueryFailure::Unavailable);
2172        use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
2173        use xmtp_db::{ConnectionExt, schema::incoming_envelopes};
2174
2175        tester!(alix, disable_workers);
2176        tester!(bo, disable_workers);
2177        tester!(_bo2, from: bo, disable_workers);
2178        tester!(_bo3, from: bo, disable_workers);
2179        let group = alix.create_group(None, None).unwrap();
2180        group.invite(&bo).await.unwrap();
2181        let welcome = bo
2182            .context
2183            .api()
2184            .query_welcome_messages(bo.context.installation_id())
2185            .await
2186            .unwrap()
2187            .pop()
2188            .unwrap();
2189        assert!(matches!(
2190            welcome.variant,
2191            xmtp_proto::types::WelcomeMessageType::WelcomePointer(_)
2192        ));
2193        pending_welcome_for_test(&bo.context, &welcome)
2194            .await
2195            .unwrap();
2196        let service = WelcomeService::new(bo.context.clone());
2197        let db = bo.context.db();
2198        let row = || {
2199            incoming_envelopes::table.find((
2200                bo.context.installation_id().to_vec(),
2201                EntityKind::Welcome,
2202                welcome.cursor.0 as i64,
2203            ))
2204        };
2205        let unreadable_deadline =
2206            unreadable.then(|| xmtp_common::time::now_ns() + xmtp_common::NS_IN_DAY);
2207        db.raw_query(|conn| {
2208            diesel::update(row())
2209                .set((
2210                    incoming_envelopes::error_code.eq(prior_code),
2211                    incoming_envelopes::blocked.eq(unreadable),
2212                    incoming_envelopes::retry_expires_at_ns.eq(unreadable.then_some(1i64)),
2213                    incoming_envelopes::unreadable_expires_at_ns.eq(unreadable_deadline),
2214                ))
2215                .execute(conn)
2216        })
2217        .unwrap();
2218
2219        let mut api = xmtp_api_backend::MockBackendClient::new();
2220        api.expect_query_raw_envelopes().returning(move |request| {
2221            let status = match failure {
2222                PointeeQueryFailure::Unavailable => {
2223                    tonic::Status::unavailable("pointee query unavailable")
2224                }
2225                PointeeQueryFailure::Denied => {
2226                    tonic::Status::permission_denied("pointee query denied")
2227                }
2228                PointeeQueryFailure::BadMetadata => {
2229                    // The hash is one byte short of SHA-256.
2230                    return Ok(wire::QueryResponse {
2231                        envelopes: vec![wire::ServerEnvelope {
2232                            meta: Some(wire::EnvelopeMeta {
2233                                topic: request.queries[0].topic.clone(),
2234                                cursor: Some(wire::Cursor { sequence_id: 1 }),
2235                                message_hash: Some(wire::MessageHash {
2236                                    hash: Some(wire::message_hash::Hash::Sha256(vec![7; 31])),
2237                                }),
2238                                ..Default::default()
2239                            }),
2240                            envelope: None,
2241                        }],
2242                        continuation: Some(wire::Continuation { has_more: false }),
2243                    }
2244                    .into());
2245                }
2246            };
2247            Err(xmtp_proto::api::ApiClientError::client(
2248                xmtp_api_grpc::error::GrpcError::Status(status),
2249            ))
2250        });
2251        let failing_client = crate::builder::ClientBuilder::from_client(bo.client.clone())
2252            .api_client(std::sync::Arc::new(api))
2253            .config_provider(std::sync::Arc::new(
2254                xmtp_configuration::StaticConfigProvider::default(),
2255            ))
2256            .with_allow_offline(Some(true))
2257            .with_disable_workers(true)
2258            .build()
2259            .await
2260            .unwrap();
2261        let failing_service = WelcomeService::new(failing_client.context.clone());
2262        let blocked = unreadable || !retryable;
2263        assert!(matches!(
2264            failing_service.resolve_pending_welcome(welcome.cursor).await.unwrap(),
2265            WelcomeHeadOutcome::Waiting { blocked: actual, .. } if actual == blocked
2266        ));
2267        let pending = db
2268            .pending_envelope(&service.topic(), welcome.cursor)
2269            .unwrap()
2270            .unwrap();
2271        assert_eq!(
2272            pending.error_code.as_deref(),
2273            Some(if blocked {
2274                "welcome_blocked"
2275            } else {
2276                "welcome_pointee"
2277            })
2278        );
2279        assert_eq!(
2280            pending.retry_expires_at_ns,
2281            Some(if unreadable {
2282                1
2283            } else {
2284                welcome.timestamp() + WELCOME_RETENTION_NS
2285            })
2286        );
2287        assert_eq!(pending.unreadable_expires_at_ns, unreadable_deadline);
2288        assert!(db.read_last_rejection(&service.topic()).unwrap().is_none());
2289
2290        db.raw_query(|conn| {
2291            diesel::update(row())
2292                .set((
2293                    incoming_envelopes::retry_expires_at_ns.eq(Some(1i64)),
2294                    incoming_envelopes::unreadable_expires_at_ns.eq(unreadable.then_some(1i64)),
2295                ))
2296                .execute(conn)
2297        })
2298        .unwrap();
2299        let failing_service = WelcomeService::new(failing_client.context.clone());
2300        assert!(matches!(
2301            failing_service
2302                .resolve_pending_welcome(welcome.cursor)
2303                .await
2304                .unwrap(),
2305            WelcomeHeadOutcome::Progress { result: Err(_), .. }
2306        ));
2307        assert!(!db.has_pending_welcomes().unwrap());
2308        assert!(db.find_group(&group.group_id).unwrap().is_none());
2309        assert_eq!(
2310            db.read_last_rejection(&service.topic())
2311                .unwrap()
2312                .unwrap()
2313                .code,
2314            if unreadable {
2315                "unsupported_welcome_expired"
2316            } else {
2317                "welcome_pointer_expired"
2318            }
2319        );
2320    }
2321
2322    #[xmtp_common::test(unwrap_try = true)]
2323    async fn blocked_welcome_does_not_hold_an_independent_join() {
2324        use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
2325        use xmtp_db::ConnectionExt;
2326        use xmtp_db::schema::incoming_envelopes;
2327        use xmtp_proto::backend_v1::{client_envelope::Payload, welcome_message::Version};
2328
2329        tester!(alix, disable_workers);
2330        tester!(bo, disable_workers);
2331        let first_group = alix.create_group(None, None)?;
2332        first_group.invite(&bo).await?;
2333        let second_group = alix.create_group(None, None)?;
2334        second_group.invite(&bo).await?;
2335        let welcomes = bo
2336            .context
2337            .api()
2338            .query_welcome_messages(bo.context.installation_id())
2339            .await?;
2340        let first = welcomes.first()?.cursor;
2341        let last = welcomes.last()?.cursor;
2342        pending_welcome_for_test(&bo.context, welcomes.last()?).await?;
2343        let service = WelcomeService::new(bo.context.clone());
2344        let db = bo.context.db();
2345        let pending = db.pending_envelope(&service.topic(), first)?.unwrap();
2346        let mut wire = xmtp_proto::backend_v1::ServerEnvelope::decode(pending.envelope.as_slice())?;
2347        let Payload::WelcomeMessage(welcome) = wire.envelope.as_mut()?.payload.as_mut()? else {
2348            panic!("expected Welcome payload");
2349        };
2350        match welcome.version.as_mut()? {
2351            Version::V1(message) => message.wrapper_algorithm = i32::MAX,
2352            Version::WelcomePointer(message) => message.wrapper_algorithm = i32::MAX,
2353        }
2354        let envelope = wire.encode_to_vec();
2355        db.raw_query(|conn| {
2356            diesel::update(incoming_envelopes::table.find((
2357                bo.context.installation_id().to_vec(),
2358                EntityKind::Welcome,
2359                first.0 as i64,
2360            )))
2361            .set((
2362                incoming_envelopes::envelope_bytes.eq(envelope.len() as i64),
2363                incoming_envelopes::envelope.eq(&envelope),
2364            ))
2365            .execute(conn)
2366        })?;
2367
2368        let outcomes = service.process_pending_welcomes_once(&[])?;
2369        assert!(outcomes.iter().any(|outcome| matches!(outcome,
2370            WelcomeHeadOutcome::Waiting { cursor, blocked: true, .. } if *cursor == first)));
2371        assert!(outcomes.iter().any(|outcome| matches!(outcome,
2372            WelcomeHeadOutcome::Need { cursor, .. } | WelcomeHeadOutcome::Progress { cursor, .. } if *cursor == last)));
2373        if db.find_group(&second_group.group_id)?.is_none() {
2374            assert!(matches!(
2375                service.resolve_pending_welcome(last).await?,
2376                WelcomeHeadOutcome::Progress {
2377                    result: Ok(Some(_)),
2378                    ..
2379                }
2380            ));
2381        }
2382        assert!(db.find_group(&first_group.group_id)?.is_none());
2383        assert!(db.find_group(&second_group.group_id)?.is_some());
2384        let first_pending = db.pending_envelope(&service.topic(), first)?.unwrap();
2385        assert!(first_pending.blocked);
2386        assert_eq!(
2387            first_pending.error_code.as_deref(),
2388            Some("unsupported_welcome")
2389        );
2390        assert!(db.pending_envelope(&service.topic(), last)?.is_none());
2391        let progress = db.topic_progress(&service.topic())?;
2392        assert!(progress.processed < first);
2393        assert_eq!(progress.received, last);
2394    }
2395
2396    impl ValidateGroupMembership for RejectMembership {
2397        async fn check_initial_membership(
2398            &self,
2399            _welcome: &WelcomeMembership,
2400        ) -> Result<(), GroupError> {
2401            if self.retryable {
2402                Err(GroupError::LockUnavailable)
2403            } else {
2404                Err(GroupError::NoPSKSupport)
2405            }
2406        }
2407    }
2408
2409    #[rstest]
2410    #[case::terminal(false)]
2411    #[case::retry(true)]
2412    #[xmtp_common::test(unwrap_try = true)]
2413    async fn welcome_rejection_commits_only_terminal_progress(#[case] retryable: bool) {
2414        tester!(alix, disable_workers);
2415        tester!(bo, disable_workers);
2416        let group = alix.create_group(None, None).unwrap();
2417        group.invite(&bo).await.unwrap();
2418        let welcome = bo
2419            .context
2420            .api()
2421            .query_welcome_messages(bo.context.installation_id())
2422            .await
2423            .unwrap()
2424            .pop()
2425            .unwrap();
2426        pending_welcome_for_test(&bo.context, &welcome)
2427            .await
2428            .unwrap();
2429        let service = WelcomeService::new(bo.context.clone());
2430        let result = service
2431            .process_new_welcome(&welcome, RejectMembership { retryable })
2432            .await;
2433        assert!(result.is_err());
2434        assert!(
2435            bo.context
2436                .db()
2437                .find_group(&group.group_id)
2438                .unwrap()
2439                .is_none()
2440        );
2441        let expected = if !retryable {
2442            welcome.cursor
2443        } else {
2444            Cursor(0)
2445        };
2446        assert_eq!(
2447            bo.context
2448                .db()
2449                .get_last_cursor(bo.context.installation_id(), EntityKind::Welcome)
2450                .unwrap(),
2451            expected
2452        );
2453    }
2454
2455    #[xmtp_common::test(unwrap_try = true)]
2456    async fn later_welcome_completion_keeps_earlier_retry_pending() {
2457        tester!(alix, disable_workers);
2458        tester!(bo, disable_workers);
2459        let first_group = alix.create_group(None, None)?;
2460        first_group.invite(&bo).await?;
2461        let second_group = alix.create_group(None, None)?;
2462        second_group.invite(&bo).await?;
2463        let welcomes = bo
2464            .context
2465            .api()
2466            .query_welcome_messages(bo.context.installation_id())
2467            .await?;
2468        let first = welcomes.first()?.cursor;
2469        let last = welcomes.last()?.cursor;
2470        pending_welcome_for_test(&bo.context, welcomes.last()?).await?;
2471        let service = WelcomeService::new(bo.context.clone());
2472        for welcome in welcomes {
2473            let result = service
2474                .process_new_welcome(
2475                    &welcome,
2476                    RejectMembership {
2477                        retryable: welcome.cursor == first,
2478                    },
2479                )
2480                .await;
2481            assert!(result.is_err());
2482        }
2483        let db = bo.context.db();
2484        assert!(db.pending_envelope(&service.topic(), first)?.is_some());
2485        assert!(db.pending_envelope(&service.topic(), last)?.is_none());
2486        let progress = db.topic_progress(&service.topic())?;
2487        assert!(progress.processed < first);
2488        assert_eq!(progress.received, last);
2489    }
2490}