Skip to main content

xmtp_mls/groups/
lifecycle.rs

1//! Construction, loading, proposal capability, and insertion.
2
3use super::*;
4
5/// Represents a group, which can contain anywhere from 1 to MAX_GROUP_SIZE inboxes.
6///
7/// This is a wrapper around OpenMLS's `MlsGroup` that handles our application-level configuration
8/// and validations.
9impl<Context> MlsGroup<Context>
10where
11    Context: XmtpSharedContext,
12{
13    // Creates a new group instance. Does not validate that the group exists in the DB
14    pub fn new(
15        context: Context,
16        group_id: GroupId,
17        dm_id: Option<String>,
18        conversation_type: ConversationType,
19        created_at_ns: i64,
20    ) -> Self {
21        Self::new_from_arc(
22            context.clone(),
23            group_id,
24            dm_id,
25            conversation_type,
26            created_at_ns,
27        )
28    }
29
30    /// Creates a new group instance from the database. Validate that the group exists in the DB before constructing
31    /// the group.
32    ///
33    /// # Returns
34    ///
35    /// Returns the Group and the stored group information as a tuple.
36    pub fn new_cached(
37        context: Context,
38        group_id: &GroupId,
39    ) -> Result<(Self, StoredGroup), StorageError> {
40        let conn = context.db();
41        if let Some(group) = conn.find_group(group_id)? {
42            Ok((
43                Self::new_from_arc(
44                    context,
45                    *group_id,
46                    group.dm_id.clone(),
47                    group.conversation_type,
48                    group.created_at_ns,
49                ),
50                group,
51            ))
52        } else {
53            tracing::error!("group {} does not exist", hex::encode(group_id));
54            Err(NotFound::GroupById(*group_id).into())
55        }
56    }
57
58    pub(crate) fn new_from_arc(
59        context: Context,
60        group_id: GroupId,
61        dm_id: Option<String>,
62        conversation_type: ConversationType,
63        created_at_ns: i64,
64    ) -> Self {
65        let mut mutexes = context.mutexes().clone();
66        Self {
67            group_id,
68            dm_id,
69            conversation_type,
70            created_at_ns,
71            mutex: mutexes.get_mutex(group_id),
72            context: context.clone(),
73            #[cfg(test)]
74            mls_commit_lock: Arc::clone(context.mls_commit_lock()),
75        }
76    }
77
78    /// Read a consistent MLS snapshot. Only immutable results leave the transaction.
79    pub(crate) fn with_group_snapshot<R>(
80        &self,
81        operation: impl FnOnce(&OpenMlsGroup) -> Result<R, GroupError>,
82    ) -> Result<R, GroupError> {
83        state_write(self.context.mls_storage(), |tx| {
84            tx.with_group(self.group_id, |group, _| operation(group))
85                .map(Continue)
86        })
87        .map(TransactionOutcome::into_continued)
88    }
89
90    // Test fixtures can deliberately retain an MLS object to construct stale state.
91    #[cfg(test)]
92    #[tracing::instrument(level = "trace", skip_all)]
93    pub(crate) fn load_mls_group_with_lock<F, R>(
94        &self,
95        storage: &impl XmtpMlsStorageProvider,
96        operation: F,
97    ) -> Result<R, GroupError>
98    where
99        F: Fn(OpenMlsGroup) -> Result<R, GroupError>,
100    {
101        // Get the group ID for locking
102        let group_id = self.group_id;
103
104        // Acquire the lock synchronously using blocking_lock
105        let _lock = self.mls_commit_lock.get_lock_sync(group_id);
106        // Load the MLS group
107        let mls_group = OpenMlsGroup::load(storage, &self.group_id.to_openmls())
108            .inspect_err(|e| tracing::error!("openmls error while loading group {e}"))
109            .map_err(|_| NotFound::MlsGroup(self.group_id))?
110            .ok_or(NotFound::MlsGroup(self.group_id))?;
111
112        // Perform the operation with the MLS group
113        operation(mls_group)
114    }
115
116    // Test fixtures can deliberately retain an MLS object across a network wait.
117    #[cfg(test)]
118    #[tracing::instrument(level = "trace", skip(operation))]
119    pub(crate) async fn load_mls_group_with_lock_async<R, E>(
120        &self,
121        operation: impl AsyncFnOnce(OpenMlsGroup) -> Result<R, E>,
122    ) -> Result<R, E>
123    where
124        E: From<crate::StorageError> + From<xmtp_db::sql_key_store::SqlKeyStoreError>,
125    {
126        let mls_storage = self.context.mls_storage();
127        // Get the group ID for locking
128        let group_id = self.group_id;
129
130        // Acquire the lock asynchronously
131        let _lock = self.mls_commit_lock.get_lock_async(group_id).await;
132
133        // Load the MLS group
134        let mls_group = OpenMlsGroup::load(mls_storage, &self.group_id.to_openmls())?
135            .ok_or(StorageError::from(NotFound::GroupById(self.group_id)))?;
136
137        // Perform the operation with the MLS group
138        operation(mls_group).await
139    }
140
141    /// Check if all members in the group support the proposal-by-reference flow.
142    ///
143    /// This checks both:
144    /// 1. Leaf node capabilities in the MLS group (via `check_extension_support`)
145    /// 2. The latest published key packages for all member installations fetched
146    ///    from the network, since leaf nodes may be stale (they aren't updated after
147    ///    the first message is sent).
148    ///
149    /// Returns `true` if all members support proposals, `false` otherwise.
150    pub async fn all_members_support_proposals(
151        &self,
152        mls_group: &OpenMlsGroup,
153    ) -> Result<bool, GroupError> {
154        let (supported, installation_ids) = self.proposal_support_snapshot(mls_group);
155        self.published_members_support_proposals(supported, installation_ids)
156            .await
157    }
158
159    fn proposal_support_snapshot(&self, group: &OpenMlsGroup) -> (bool, Vec<Vec<u8>>) {
160        let supported = group
161            .check_extension_support(&[ExtensionType::AppDataDictionary])
162            .is_ok();
163        let installation_ids = group
164            .members()
165            .map(|member| member.signature_key)
166            .filter(|id| id.as_slice() != self.context.installation_id().as_slice())
167            .collect();
168        (supported, installation_ids)
169    }
170
171    async fn published_members_support_proposals(
172        &self,
173        supported: bool,
174        installation_ids: Vec<Vec<u8>>,
175    ) -> Result<bool, GroupError> {
176        if supported || installation_ids.is_empty() {
177            return Ok(true);
178        }
179
180        let store = crate::mls_store::MlsStore::new(self.context.clone());
181        let key_packages = store
182            .get_key_packages_for_installation_ids(installation_ids)
183            .await?;
184
185        for result in key_packages.values() {
186            match result {
187                Ok(verified_kp) => {
188                    let capabilities = verified_kp.inner.leaf_node().capabilities();
189                    if !capabilities
190                        .extensions()
191                        .contains(&ExtensionType::AppDataDictionary)
192                    {
193                        return Ok(false);
194                    }
195                }
196                Err(_) => {
197                    return Ok(false);
198                }
199            }
200        }
201
202        Ok(true)
203    }
204
205    /// Check published capabilities using immutable member IDs from one snapshot.
206    async fn ensure_members_support_proposals(&self) -> Result<(), GroupError> {
207        let (supported, installation_ids) =
208            self.with_group_snapshot(|group| Ok(self.proposal_support_snapshot(group)))?;
209        if self
210            .published_members_support_proposals(supported, installation_ids)
211            .await?
212        {
213            Ok(())
214        } else {
215            Err(GroupError::ProposalsNotSupported(
216                "Cannot enable proposals: not all members support the proposal extension"
217                    .to_string(),
218            ))
219        }
220    }
221
222    /// Snapshot this group's membership capabilities: the extension types in
223    /// the group context, plus the extension types each member installation
224    /// advertises.
225    ///
226    /// This reports raw capability facts rather than answers — callers filter
227    /// it to whatever question they care about. For the proposal
228    /// (app-data-dictionary) migration specifically, a caller checks for
229    /// [`MlsExtensionType::AppDataDictionary`] in `context_extensions` (already
230    /// migrated?) and in each installation's `supported_extensions` (eligible /
231    /// who is blocking?).
232    ///
233    /// Every installation's capabilities — the local one included — come from
234    /// its *latest published* key package, not its in-group leaf node: a leaf
235    /// is frozen when the installation joins and is never updated, so it would
236    /// understate a client that upgraded afterward (the same reason
237    /// [`Self::all_members_support_proposals`] falls back to key packages). An
238    /// installation whose key package has not been published or fails
239    /// verification is reported with `capabilities_known == false` and an empty
240    /// extension list, so callers can distinguish "unknown" from "advertises
241    /// nothing". Transient failures (network, auth, db) surface as an `Err`
242    /// rather than masquerading as unknown capabilities.
243    pub async fn membership_capabilities(&self) -> Result<GroupMembershipCapabilities, GroupError> {
244        // Read the group context's extension types under lock. The member list
245        // (below) is read in a separate lock acquisition, so this is a
246        // best-effort snapshot rather than a single atomic view — fine for a
247        // debug surface.
248        let context_extensions = self.with_group_snapshot(|mls_group| {
249            Ok::<_, GroupError>(
250                mls_group
251                    .extensions()
252                    .iter()
253                    .map(|ext| MlsExtensionType::from(ext.extension_type()))
254                    .collect::<Vec<_>>(),
255            )
256        })?;
257
258        let members = self.members().await?;
259        let own_installation_id = self.context.installation_id();
260
261        // Capabilities for every installation come from its latest published
262        // key package. We intentionally include our own installation rather
263        // than reading its in-group leaf, which is frozen at join and would
264        // understate an upgraded local client.
265        let query_ids: Vec<Vec<u8>> = members
266            .iter()
267            .flat_map(|member| member.installation_ids.iter().cloned())
268            .collect();
269
270        let extensions_by_installation = self.installation_extensions(query_ids).await?;
271
272        let installation_capabilities = |installation_id: Vec<u8>| -> InstallationCapabilities {
273            let is_own = installation_id.as_slice() == own_installation_id.as_slice();
274            match extensions_by_installation.get(installation_id.as_slice()) {
275                Some(extensions) => InstallationCapabilities {
276                    installation_id,
277                    is_own,
278                    supported_extensions: extensions.clone(),
279                    capabilities_known: true,
280                },
281                None => InstallationCapabilities {
282                    installation_id,
283                    is_own,
284                    supported_extensions: Vec::new(),
285                    capabilities_known: false,
286                },
287            }
288        };
289
290        let member_caps: Vec<InboxCapabilities> = members
291            .into_iter()
292            .map(|member| InboxCapabilities {
293                inbox_id: member.inbox_id,
294                installations: member
295                    .installation_ids
296                    .into_iter()
297                    .map(installation_capabilities)
298                    .collect(),
299            })
300            .collect();
301
302        Ok(GroupMembershipCapabilities {
303            context_extensions,
304            members: member_caps,
305        })
306    }
307
308    /// Fetch the latest published key package for each given installation and
309    /// return the MLS extension types it advertises, keyed by installation id.
310    ///
311    /// An installation with no published key package (e.g. an old client — what
312    /// this surface exists to flag) or whose key package fails verification is
313    /// simply absent from the returned map; callers treat absence as
314    /// "capabilities unknown". Transient/infrastructure failures (network,
315    /// auth, db) are NOT swallowed — they propagate as `Err`, so a snapshot can
316    /// tell "this installation has nothing published" apart from "we couldn't
317    /// reach the server".
318    ///
319    async fn installation_extensions(
320        &self,
321        query_ids: Vec<Vec<u8>>,
322    ) -> Result<HashMap<Vec<u8>, Vec<MlsExtensionType>>, GroupError> {
323        if query_ids.is_empty() {
324            return Ok(HashMap::new());
325        }
326        let store = crate::mls_store::MlsStore::new(self.context.clone());
327
328        let verified = store
329            .get_key_packages_for_installation_ids(query_ids)
330            .await?;
331
332        Ok(verified
333            .into_iter()
334            .filter_map(|(id, result)| {
335                let extensions = result
336                    .ok()?
337                    .inner
338                    .leaf_node()
339                    .capabilities()
340                    .extensions()
341                    .iter()
342                    .copied()
343                    .map(MlsExtensionType::from)
344                    .collect();
345                Some((id, extensions))
346            })
347            .collect())
348    }
349
350    /// Check if the group has proposals enabled (proposal-by-reference flow).
351    ///
352    /// Delegates to `check_proposals_enabled` which detects the
353    /// standard MLS `ExtensionType::AppDataDictionary` group-context
354    /// extension. A migrated group carries the dict via that extension
355    /// type, making it both the wire-format carrier AND the signal
356    /// that the proposal flow is in effect.
357    ///
358    /// When proposals are enabled on a group:
359    /// - Add/remove member operations MUST use proposals
360    /// - All members being added MUST advertise `AppDataDictionary`
361    ///   support in their key-package capabilities
362    /// - Direct commits for membership changes are not allowed
363    pub fn proposals_enabled(&self, mls_group: &OpenMlsGroup) -> bool {
364        check_proposals_enabled(mls_group.extensions())
365    }
366
367    /// Like [`Self::proposals_enabled`], but loads the group from storage
368    /// instead of taking a caller-held `OpenMlsGroup`. The convenience
369    /// shape bindings need for a plain "is this group migrated?" read.
370    pub fn is_proposals_enabled(&self) -> Result<bool, GroupError> {
371        self.with_group_snapshot(|mls_group| Ok(self.proposals_enabled(mls_group)))
372    }
373
374    /// Enable proposals on this group (proposal-by-reference flow).
375    ///
376    /// Runs the bootstrap commit that migrates the group's state out
377    /// of legacy GMM-style extensions and into the standard MLS
378    /// `AppDataDictionary` extension. After bootstrap completes the
379    /// dictionary is the sole source of truth for the metadata
380    /// attributes that previously lived in the legacy extensions.
381    /// Once enabled:
382    /// - All add/remove member operations will use proposals
383    /// - All members being added must advertise `AppDataDictionary`
384    ///   support in their key-package capabilities
385    /// - This cannot be disabled once set
386    ///
387    /// # Options
388    ///
389    /// See [`EnableProposalsOptions`] for the two knobs:
390    /// - `force`: skip the pre-flight key-package capability check. Use
391    ///   when the version floor guarantees proposal support.
392    /// - `min_version`: override the `MIN_SUPPORTED_PROTOCOL_VERSION`
393    ///   floor written into the migrated group. Defaults to
394    ///   [`xmtp_configuration::PROPOSALS_MIN_PROTOCOL_VERSION`] — the
395    ///   release where proposals support first ships.
396    ///
397    /// # Prerequisites
398    ///
399    /// Before calling this method with `force = false`, ensure all
400    /// existing members support proposals by calling
401    /// `all_members_support_proposals()`.
402    ///
403    /// # Errors
404    ///
405    /// Returns an error if:
406    /// - `force = false` and not all existing members support proposals
407    /// - `min_version` is set to an invalid semver string
408    /// - `min_version` is outside the allowed bounds: above the
409    ///   caller's own `pkg_version`, or — in non-test builds — below
410    ///   [`xmtp_configuration::PROPOSALS_MIN_PROTOCOL_VERSION`]
411    /// - The group context extension update fails
412    pub async fn enable_proposals(
413        &self,
414        options: EnableProposalsOptions,
415    ) -> Result<(), GroupError> {
416        // Race-loss recovery: a concurrent migrator may win the
417        // race, advancing the group's epoch and causing our own
418        // intent's commit to fail when it tries to apply locally.
419        // The user-facing semantic of `enable_proposals` is "after
420        // this returns Ok, the group is migrated" — so if we error
421        // out but the group ended up migrated anyway, that's a
422        // benign race loss, not a failure.
423        //
424        // Implementation: delegate to an inner function and, on
425        // error, check `proposals_enabled` one final time. Only
426        // genuine failures (where the group is NOT migrated) are
427        // surfaced to the caller.
428        let result = self.enable_proposals_inner(options).await;
429        if let Err(ref err) = result {
430            let migrated_anyway = self
431                .with_group_snapshot(|mls_group| {
432                    Ok::<bool, GroupError>(self.proposals_enabled(mls_group))
433                })
434                .unwrap_or_else(|check_err| {
435                    tracing::warn!(
436                        inbox_id = self.context.inbox_id(),
437                        group_id = hex::encode(self.group_id.as_ref()),
438                        error = %check_err,
439                        "enable_proposals: recovery-path migration check failed; \
440                         falling back to surfacing original error"
441                    );
442                    false
443                });
444            if migrated_anyway {
445                tracing::info!(
446                    inbox_id = self.context.inbox_id(),
447                    group_id = hex::encode(self.group_id.as_ref()),
448                    error = %err,
449                    "enable_proposals: error surfaced but group is migrated — \
450                     treating as success (concurrent migrator won the race)"
451                );
452                return Ok(());
453            }
454        }
455        result
456    }
457
458    async fn enable_proposals_inner(
459        &self,
460        options: EnableProposalsOptions,
461    ) -> Result<(), GroupError> {
462        // Two-step migration so old clients (any peer running a
463        // libxmtp release predating this code's `pkg_version`) get a
464        // pause hint they can read BEFORE the legacy
465        // GroupMutableMetadata extension that carries it is stripped
466        // by the bootstrap commit. XIP §3.2.
467        //
468        // Step A: bump MIN_SUPPORTED_PROTOCOL_VERSION in legacy GMM to
469        // `pkg_version()`. Pre-bootstrap groups have no AppData
470        // dictionary registry, so the `MetadataUpdate` handler's
471        // migrated branch is false and the write goes through the
472        // legacy GCE path — exactly the extension old clients know how
473        // to read for `paused_for_version`. Any peer below the version
474        // floor processes this commit, sees the version mismatch in
475        // `validate_one_commit`, and lands in `paused_for_version`. It
476        // never observes step B.
477        //
478        // Step B: bootstrap commit. Strips legacy extensions, seeds
479        // the dict, adds `AppDataDictionary` to RequiredCapabilities.
480        // Fires only for the still-active (above-floor) members.
481        //
482        // The safety primitive here is **server-side commit ordering**:
483        // peers see step A's commit before step B's because the server
484        // linearizes them. `sync_until_intent_resolved` confirms only
485        // the migrator's local Processed state — it does NOT wait for
486        // peer pickup. Server linearization is what guarantees
487        // below-floor peers pause before they could ever see the
488        // bootstrap commit.
489        //
490        // Pre-flight: read all three gating signals under one lock so
491        // a concurrent migrator's bootstrap commit can't land between
492        // our reads. Without the single-lock pass:
493        //   * member-support check passes, then a concurrent migrator's
494        //     bootstrap commit lands locally, then the legacy-GMM read
495        //     returns `None` (extension stripped), then `needs_bump`
496        //     becomes `true`, then step A publishes a legacy GCE bump
497        //     against a group whose legacy extensions are gone — fails
498        //     mid-flight with a confusing error.
499        // Reading all three together collapses that race window: if
500        // `proposals_enabled` flipped to true under us, we early-return
501        // and never publish step A.
502        //
503        // (1) `all_members_support_proposals` ensures every peer can
504        // process the bootstrap commit so we don't ship step A — the
505        // legacy GMM bump — for a migration that's about to fail at
506        // step B and leave below-floor peers permanently paused. Gated
507        // by `options.force`: when the version floor guarantees support, callers can
508        // explicitly opt out of the per-member scan.
509        // (2) `proposals_enabled` early-exits if the group is already
510        // migrated; calling `enable_proposals()` twice is a user error
511        // and we don't want to publish a redundant legacy GMM bump on
512        // a group that no longer has a legacy GMM.
513        // (3) `needs_min_version_bump` decides whether step A is
514        // necessary. Semver comparison, NOT string equality. A
515        // concurrent migrator at a higher version (or this very
516        // migrator on retry) may have already set a floor >= ours; in
517        // that case re-bumping with a lower string value would
518        // downgrade the floor and silently unpause peers between the
519        // lower and the higher version. Skip step A when the current
520        // floor already covers the target.
521        let min_version = options
522            .min_version
523            .unwrap_or_else(|| xmtp_configuration::PROPOSALS_MIN_PROTOCOL_VERSION.to_string());
524        // Validate the floor string up-front so we fail with a clear
525        // error before publishing the legacy GMM bump rather than
526        // discovering it mid-flight when the validator parses it.
527        let target_v =
528            LibXMTPVersion::parse(&min_version).map_err(|e| GroupError::InvalidMinVersion {
529                value: min_version.clone(),
530                reason: e.to_string(),
531            })?;
532        let own_version_str = self.context.version_info().pkg_version().to_string();
533        let own_v =
534            LibXMTPVersion::parse(&own_version_str).map_err(|e| GroupError::InvalidMinVersion {
535                value: own_version_str.clone(),
536                reason: format!("own pkg_version: {e}"),
537            })?;
538        let force = options.force;
539
540        log_event!(
541            Event::EnableProposalsStart,
542            self.context.installation_id(),
543            group_id = self.group_id,
544            min_version = min_version.as_str(),
545            force
546        );
547        if !force {
548            self.ensure_members_support_proposals().await?;
549        }
550        let (already_migrated, needs_min_version_bump) = self
551            .with_group_snapshot(|mls_group| {
552                // Idempotency: re-calling enable_proposals on an
553                // already-migrated group is a no-op success.
554                // The footgun clamp below MUST run after this
555                // early-return — otherwise a caller pinning a
556                // forward-looking constant in idempotent retry code
557                // would error post-migration even when the floor was
558                // already set by the original call.
559                if self.proposals_enabled(mls_group) {
560                    return Ok::<(bool, bool), GroupError>((true, false));
561                }
562                // Footgun guard: a caller setting min_version above
563                // their own pkg_version would pause themselves (and
564                // every peer at or below their version) the moment the
565                // bump landed — bricking the group from the inside.
566                // Refuse. Honest mistakes only; a malicious client can
567                // patch this out, but honest mistakes are what we're
568                // protecting against.
569                if target_v > own_v {
570                    return Err(GroupError::MinVersionExceedsOwnVersion {
571                        requested: min_version.clone(),
572                        own: own_version_str.clone(),
573                    });
574                }
575                // Encoder-freeze clamp (lower bound): the bootstrap
576                // encoder is byte-frozen, and receive-side validators
577                // accept its output via strict byte-compare only down
578                // to `PROPOSALS_MIN_PROTOCOL_VERSION`. Seeding a floor
579                // below that constant would drop below-floor receivers
580                // — whose frozen decoder may disagree on the bytes —
581                // back into the byte-compare instead of the pause
582                // path, reopening the fork the floor exists to close.
583                // Refuse, so the effective invariant is
584                // `PROPOSALS_MIN_PROTOCOL_VERSION <= min_version <=
585                // own pkg_version`. Test builds are exempt so
586                // `EnableProposalsOptions::test_default()` can seed a
587                // synthetic below-floor value (`"0.0.0"`) that never
588                // pauses workspace-version peers.
589                #[cfg(not(any(test, feature = "test-utils")))]
590                {
591                    let floor_str = xmtp_configuration::PROPOSALS_MIN_PROTOCOL_VERSION;
592                    let floor_v = LibXMTPVersion::parse(floor_str).map_err(|e| {
593                        GroupError::InvalidMinVersion {
594                            value: floor_str.to_string(),
595                            reason: format!("PROPOSALS_MIN_PROTOCOL_VERSION: {e}"),
596                        }
597                    })?;
598                    if target_v < floor_v {
599                        return Err(GroupError::MinVersionDowngrade {
600                            requested: min_version.clone(),
601                            current: floor_str.to_string(),
602                        });
603                    }
604                }
605                let metadata =
606                    xmtp_mls_common::group_mutable_metadata::extract_legacy_group_mutable_metadata(
607                        mls_group,
608                    )
609                    .ok();
610                let current = metadata.and_then(|m| Self::min_protocol_version_from_extensions(&m));
611                let needs_bump = match current.as_deref() {
612                    None => true,
613                    Some(current_str) => match LibXMTPVersion::parse(current_str) {
614                        Ok(current_v) => current_v < target_v,
615                        Err(e) => {
616                            // Lenient on a malformed legacy GMM floor (mirrors the
617                            // receive-side leniency in
618                            // [`enforce_min_version_monotonicity`]). Step A
619                            // overwrites the value entirely with a known-good
620                            // semver string, so a malformed prior can't poison the
621                            // bump. Log a warning so operators can detect
622                            // corrupted state.
623                            tracing::warn!(
624                                current = %current_str,
625                                error = %e,
626                                "enable_proposals: legacy GMM MinimumSupportedProtocolVersion is unparseable; \
627                                 proceeding with step-A bump to overwrite"
628                            );
629                            true
630                        }
631                    },
632                };
633                Ok::<(bool, bool), GroupError>((false, needs_bump))
634            })?;
635
636        if already_migrated {
637            log_event!(
638                Event::EnableProposalsCompleted,
639                self.context.installation_id(),
640                group_id = self.group_id,
641                already_migrated = true,
642                min_version = min_version.as_str()
643            );
644            return Ok(());
645        }
646
647        if needs_min_version_bump {
648            let min_version_intent_data: Vec<u8> =
649                intents::UpdateMetadataIntentData::new_update_group_min_version_to_match_self(
650                    min_version.clone(),
651                )
652                .into();
653            let min_version_intent = intents::QueueIntent::metadata_update()
654                .data(min_version_intent_data)
655                .queue(self)?;
656            self.sync_until_intent_resolved(min_version_intent.id)
657                .await?;
658        }
659
660        // Build the bootstrap-target extensions in a single lock
661        // acquisition to avoid races. The bootstrap commit produced by
662        // `IntentKind::BootstrapMigration` will:
663        // - REMOVE the four legacy XMTP extensions (mutable metadata,
664        //   group permissions, group membership, ImmutableMetadata)
665        // - update RequiredCapabilities to add `AppDataDictionary` and
666        //   drop the four legacy extension types
667        // - emit one `AppDataUpdate(component_id, Update(bytes))`
668        //   proposal per well-known component, seeding the dict — the
669        //   AppDataDictionary GCE itself is populated by openmls when
670        //   the bundled AppDataUpdate proposals apply during commit
671        //   processing
672        // All in a single commit, so the migration is atomic on-the-
673        // wire: receivers either see the migrated state (bootstrap
674        // commit accepted) or the legacy state (commit rejected).
675        //
676        // The `all_members_support_proposals` re-check on this read
677        // pass is a defense-in-depth: pre-flight ran above before step
678        // A, but a peer could join between then and now. The intent
679        // dispatch reads live group state at publish time, so step B
680        // must observe the same predicate it gated step A with. Same
681        // `force` gate as the pre-flight — if the caller explicitly
682        // disabled the capability check there, honor that here too so
683        // a freshly-joined member can't re-block the migration mid-
684        // flight.
685        if !force {
686            self.ensure_members_support_proposals().await?;
687        }
688        let new_extensions = self.with_group_snapshot(|mls_group| {
689            // Re-check `proposals_enabled` inside the lock: a
690            // concurrent migrator may have completed the migration
691            // between the first idempotency check and this second
692            // lock acquisition. Returning `None` here lets the
693            // outer code skip queuing a redundant bootstrap intent
694            // — preserves the idempotency contract documented at
695            // the top of `enable_proposals_inner`.
696            if self.proposals_enabled(mls_group) {
697                return Ok::<Option<Extensions<GroupContext>>, GroupError>(None);
698            }
699            let mut extensions: Extensions<GroupContext> = mls_group.extensions().clone();
700
701            // 1. Remove the four legacy XMTP extensions. The
702            //    bootstrap commit's job is to eliminate them so
703            //    the dict becomes the sole source of truth.
704            //    The bundled `AppDataUpdate(COMPONENT_REGISTRY)`
705            //    proposal triggers openmls to add the standard
706            //    `AppDataDictionary` group-context extension when
707            //    the commit applies — that extension's presence
708            //    (plus the registry entry) IS the migrated marker.
709            //    No separate XMTP-flavored marker is needed.
710            extensions.remove(ExtensionType::Unknown(MUTABLE_METADATA_EXTENSION_ID));
711            extensions.remove(ExtensionType::Unknown(GROUP_PERMISSIONS_EXTENSION_ID));
712            extensions.remove(ExtensionType::Unknown(GROUP_MEMBERSHIP_EXTENSION_ID));
713            extensions.remove(ExtensionType::ImmutableMetadata);
714
715            // 2. Update RequiredCapabilities: require
716            //    `AppDataDictionary` (the standard extension that
717            //    carries the dict) and drop the four legacy
718            //    extension types so receivers don't reject the
719            //    commit for missing required extensions.
720            update_required_capabilities_for_bootstrap(&mut extensions)?;
721
722            Ok(Some(extensions))
723        })?;
724
725        // Concurrent migrator won the race between the two lock
726        // acquisitions — group is already migrated, no bootstrap
727        // intent to queue.
728        let Some(new_extensions) = new_extensions else {
729            return Ok(());
730        };
731
732        use openmls::prelude::tls_codec::Serialize;
733        let extensions_bytes = new_extensions.tls_serialize_detached()?;
734
735        // Queue the bootstrap intent. The handler synthesizes
736        // per-component dict seeds and bundles the GCE proposal +
737        // every AppDataUpdate proposal into one self-contained
738        // commit. No follow-up CommitPendingProposals needed.
739        let intent_data = intents::ProposeGroupContextExtensionsIntentData::new(extensions_bytes);
740        let bootstrap_intent = intents::QueueIntent::bootstrap_migration()
741            .data(intent_data)
742            .queue(self)?;
743
744        self.sync_until_intent_resolved(bootstrap_intent.id).await?;
745
746        let enabled = self.with_group_snapshot(|mls_group| {
747            Ok::<bool, GroupError>(self.proposals_enabled(mls_group))
748        })?;
749
750        if !enabled {
751            return Err(GroupError::ProposalsNotSupported(
752                "Failed to enable proposals: extension not applied".to_string(),
753            ));
754        }
755
756        log_event!(
757            Event::EnableProposalsCompleted,
758            self.context.installation_id(),
759            group_id = self.group_id,
760            already_migrated = false,
761            min_version = min_version.as_str()
762        );
763        Ok(())
764    }
765
766    /// Validate that key packages support the AppData dictionary
767    /// group-context extension. A leaf that advertises
768    /// `ExtensionType::AppDataDictionary` in its standard MLS
769    /// `Capabilities` can join a migrated group and receive standalone
770    /// `AppDataUpdate` proposals.
771    pub fn validate_key_packages_support_proposals(
772        &self,
773        key_packages: &[openmls::key_packages::KeyPackage],
774    ) -> Result<(), GroupError> {
775        let extension_type = ExtensionType::AppDataDictionary;
776
777        for kp in key_packages {
778            let leaf_node = kp.leaf_node();
779            let capabilities = leaf_node.capabilities();
780
781            if !capabilities.extensions().contains(&extension_type) {
782                return Err(GroupError::ProposalsNotSupported(
783                    "Member does not support AppData dictionary: installation cannot receive standalone proposal messages".to_string(),
784                ));
785            }
786        }
787
788        Ok(())
789    }
790
791    // Create a new group and save it to the DB
792    pub(crate) fn create_and_insert(
793        context: Context,
794        conversation_type: ConversationType,
795        permissions_policy_set: PolicySet,
796        opts: GroupMetadataOptions,
797        oneshot_message: Option<OneshotMessage>,
798    ) -> Result<Self, GroupError> {
799        assert!(conversation_type != ConversationType::Dm);
800        let stored_group = Self::insert(
801            &context,
802            None,
803            GroupMembershipState::Allowed,
804            conversation_type,
805            permissions_policy_set,
806            opts,
807            oneshot_message,
808        )?;
809        let new_group = Self::new_from_arc(
810            context.clone(),
811            stored_group.id,
812            stored_group.dm_id,
813            conversation_type,
814            stored_group.created_at_ns,
815        );
816
817        // Consent state defaults to allowed when the user creates the group
818        if !conversation_type.is_virtual() {
819            new_group.update_consent_state(ConsentState::Allowed)?;
820        }
821
822        context.task_channels().wake_notifications();
823        Ok(new_group)
824    }
825
826    pub(crate) fn insert(
827        context: &Context,
828        existing_group_id: Option<&[u8]>,
829        membership_state: GroupMembershipState,
830        conversation_type: ConversationType,
831        permissions_policy_set: PolicySet,
832        opts: GroupMetadataOptions,
833        oneshot_message: Option<OneshotMessage>,
834    ) -> Result<StoredGroup, GroupError> {
835        assert!(conversation_type != ConversationType::Dm);
836
837        let creator_inbox_id = context.inbox_id();
838        let protected_metadata = build_protected_metadata_extension(
839            creator_inbox_id,
840            conversation_type,
841            oneshot_message,
842        )?;
843        let commit_log_enabled = context.server_configuration().commit_log_enabled();
844        let mutable_metadata = build_mutable_metadata_extension_default(
845            creator_inbox_id,
846            opts.clone(),
847            commit_log_enabled,
848        )?;
849        let group_membership = build_starting_group_membership_extension(creator_inbox_id, 0);
850        let mutable_permissions = build_mutable_permissions_extension(permissions_policy_set)?;
851        let group_config = build_group_config(
852            protected_metadata,
853            mutable_metadata,
854            group_membership,
855            mutable_permissions,
856        )?;
857
858        state_write(context.mls_storage(), |tx| {
859            let storage = tx.storage();
860            let db = storage.db();
861            if let Some(existing_group_id) = existing_group_id {
862                let group_id = GroupId::try_from(existing_group_id)?;
863                if let Some(existing) = db.find_group(&group_id)? {
864                    return Ok(Continue(existing));
865                }
866            }
867            let provider = XmtpOpenMlsProviderRef::new(&storage);
868            let mls_group = if let Some(existing_group_id) = existing_group_id {
869                // TODO: For groups restored from backup, in order to support queries on metadata such as
870                // the group title and description, a stubbed OpenMLS group is created, and later overwritten
871                // when a welcome is received.
872                OpenMlsGroup::from_backup_stub_logged(
873                    &provider,
874                    context.identity(),
875                    &group_config,
876                    GroupId::try_from(existing_group_id)?,
877                    commit_log_enabled,
878                )?
879            } else {
880                OpenMlsGroup::from_creation_logged(
881                    &provider,
882                    context.identity(),
883                    &group_config,
884                    commit_log_enabled,
885                )?
886            };
887
888            let group_id: GroupId = mls_group.group_id().try_into()?;
889            // If not an existing group, the creator is a super admin and should publish the commit log
890            // Otherwise, for existing groups, we'll never publish the commit log until we receive a welcome message
891            let should_publish_commit_log = existing_group_id.is_none();
892
893            let stored_group = StoredGroup::builder()
894                .id(group_id)
895                .created_at_ns(now_ns())
896                .membership_state(membership_state)
897                .conversation_type(conversation_type)
898                .added_by_inbox_id(context.inbox_id().to_string())
899                .message_disappear_from_ns(
900                    opts.message_disappearing_settings
901                        .as_ref()
902                        .map(|m| m.from_ns),
903                )
904                .message_disappear_in_ns(
905                    opts.message_disappearing_settings.as_ref().map(|m| m.in_ns),
906                )
907                .should_publish_commit_log(should_publish_commit_log)
908                .build()?;
909
910            stored_group.store_or_ignore(&db)?;
911            Ok::<_, GroupError>(Continue(stored_group))
912        })
913        .map(TransactionOutcome::into_continued)
914    }
915
916    // Create a new DM and save it to the DB
917    pub(crate) fn create_dm_and_insert(
918        context: &Context,
919        membership_state: GroupMembershipState,
920        dm_target_inbox_id: InboxId,
921        opts: DMMetadataOptions,
922        existing_group_id: Option<&[u8]>,
923    ) -> Result<Self, GroupError> {
924        let protected_metadata =
925            build_dm_protected_metadata_extension(context.inbox_id(), dm_target_inbox_id.clone())?;
926        let commit_log_enabled = context.server_configuration().commit_log_enabled();
927        let mutable_metadata = build_dm_mutable_metadata_extension_default(
928            context.inbox_id(),
929            &dm_target_inbox_id,
930            opts.clone(),
931            commit_log_enabled,
932        )?;
933        let group_membership = build_starting_group_membership_extension(context.inbox_id(), 0);
934        let mutable_permissions = PolicySet::new_dm();
935        let mutable_permission_extension =
936            build_mutable_permissions_extension(mutable_permissions)?;
937        let group_config = build_group_config(
938            protected_metadata,
939            mutable_metadata,
940            group_membership,
941            mutable_permission_extension,
942        )?;
943
944        let (stored_group, created) = state_write(context.mls_storage(), |tx| {
945            let storage = tx.storage();
946            let db = storage.db();
947            if let Some(group_id) = existing_group_id {
948                let group_id = GroupId::try_from(group_id)?;
949                if let Some(existing) = db.find_group(&group_id)? {
950                    return Ok(Continue((existing, false)));
951                }
952            }
953            let provider = XmtpOpenMlsProviderRef::new(&storage);
954            let mls_group = if let Some(group_id) = existing_group_id {
955                OpenMlsGroup::from_backup_stub_logged(
956                    &provider,
957                    context.identity(),
958                    &group_config,
959                    GroupId::try_from(group_id)?,
960                    commit_log_enabled,
961                )?
962            } else {
963                OpenMlsGroup::from_creation_logged(
964                    &provider,
965                    context.identity(),
966                    &group_config,
967                    commit_log_enabled,
968                )?
969            };
970
971            let group_id: GroupId = mls_group.group_id().try_into()?;
972            let stored_group = StoredGroup::builder()
973                .id(group_id)
974                .created_at_ns(now_ns())
975                .membership_state(membership_state)
976                .added_by_inbox_id(context.inbox_id().to_string())
977                .message_disappear_from_ns(
978                    opts.message_disappearing_settings
979                        .as_ref()
980                        .map(|m| m.from_ns),
981                )
982                .message_disappear_in_ns(
983                    opts.message_disappearing_settings.as_ref().map(|m| m.in_ns),
984                )
985                .dm_id(Some(
986                    DmMembers {
987                        member_one_inbox_id: dm_target_inbox_id,
988                        member_two_inbox_id: context.identity().inbox_id().to_string(),
989                    }
990                    .to_string(),
991                ))
992                .build()?;
993
994            stored_group.store(&db)?;
995            Ok::<_, GroupError>(Continue((stored_group, true)))
996        })?
997        .into_continued();
998        let new_group = Self::new_from_arc(
999            context.clone(),
1000            stored_group.id,
1001            stored_group.dm_id,
1002            ConversationType::Dm,
1003            stored_group.created_at_ns,
1004        );
1005        // Consent state defaults to allowed when the user creates the group
1006        if created {
1007            new_group.update_consent_state(ConsentState::Allowed)?;
1008        }
1009        Ok(new_group)
1010    }
1011
1012    // Super admin status is only criteria for whether to publish the commit log for now
1013    pub(in crate::groups) fn check_should_publish_commit_log(
1014        inbox_id: String,
1015        mutable_metadata: Option<GroupMutableMetadata>,
1016    ) -> bool {
1017        mutable_metadata
1018            .as_ref()
1019            .map(|metadata| metadata.is_super_admin(&inbox_id))
1020            .unwrap_or(false) // Default to false if no mutable metadata
1021    }
1022}