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}