Skip to main content

xmtp_mls/groups/app_data/
migration.rs

1//! Sender-side bootstrap synthesis that needs async access to identity
2//! updates (for the `failed_installations` per-inbox partition).
3//!
4//! Mirrors the wire-format contract enforced by the receiver-side
5//! `synthesize_canonical_subset_for_validation` in
6//! `xmtp_mls_common::app_data::migration`. The split is:
7//!
8//! - sync pure-local synthesis (registry, admin/super-admin lists,
9//!   Bytes attrs, sequence-id map, immutable seeds) → `xmtp_mls_common`
10//! - async identity-update-dependent synthesis
11//!   (`failed_installations` partition) → this module
12//!
13//! The output is the full `BTreeMap<ComponentId, Vec<u8>>` the
14//! bootstrap intent handler fans out as individual `AppDataUpdate`
15//! proposals, sorted by ComponentId ascending.
16
17use std::collections::BTreeMap;
18
19use openmls::{
20    extensions::Extensions,
21    group::{GroupContext, MlsGroup as OpenMlsGroup},
22    messages::proposals::{AppDataUpdateProposal, Proposal},
23    prelude::CommitMessageBundle,
24    storage::OpenMlsProvider,
25};
26use prost::Message as _;
27use tls_codec::{Serialize as _, VLBytes};
28use xmtp_mls_common::{
29    app_data::{
30        component_id::ComponentId,
31        component_registry::{ComponentRegistry, ComponentRegistryError},
32        migration::{self, CanonicalBootstrapExpectation, MigrationError as CommonMigrationError},
33    },
34    inbox_id::InboxId,
35    tls_map::TlsMapDelta,
36};
37use xmtp_proto::xmtp::mls::message_contents::{
38    GroupMembership as GroupMembershipProto, GroupMembershipEntry,
39    group_membership_entry::{
40        V1 as GroupMembershipEntryV1, Version as GroupMembershipEntryVersion,
41    },
42};
43
44use crate::{context::XmtpSharedContext, identity_updates::IdentityUpdates};
45
46/// Sender-side bootstrap synthesis errors.
47#[derive(Debug, thiserror::Error)]
48pub enum BootstrapSynthesisError {
49    #[error(transparent)]
50    Common(#[from] CommonMigrationError),
51
52    /// Bootstrap can't fall back silently: a missing installation list
53    /// means we can't partition `failed_installations`, so we surface
54    /// the lookup error rather than emit incorrect bytes.
55    #[error("identity-update lookup failed for inbox {inbox_id}: {source}")]
56    IdentityUpdateLookup {
57        inbox_id: String,
58        #[source]
59        source: crate::client::ClientError,
60    },
61
62    #[error("legacy GroupMembership decode: {0}")]
63    LegacyMembershipDecode(#[from] prost::DecodeError),
64
65    /// Re-encoding the canonical-subset's `expected_registry` into the
66    /// `COMPONENT_REGISTRY` wire bytes failed. Surfaces a
67    /// [`ComponentRegistryError`] from the per-entry `set` round-trip
68    /// (rejected `ComponentMetadata`, reserved id, etc.). The
69    /// canonical-subset synthesizer already validated these values, so
70    /// hitting this path indicates a logic divergence between the two
71    /// crates rather than user-visible state.
72    #[error("registry re-encode: {0}")]
73    RegistryReEncode(#[from] ComponentRegistryError),
74
75    /// A `GROUP_MEMBERSHIP` `sequence_id` exceeded `i64::MAX` and so
76    /// can't be passed through to the identity-update API (whose query
77    /// surface is signed). Practically unreachable today (sequence ids
78    /// don't get within thirteen orders of magnitude of `i64::MAX`),
79    /// but a `as i64` cast would silently wrap to a negative value and
80    /// quietly query the wrong association state — surface it as a
81    /// terminal error rather than risk a deceptive partition of
82    /// `failed_installations`.
83    #[error(
84        "sequence_id {sequence_id} for inbox {inbox_id} exceeds i64::MAX; cannot query identity-update history"
85    )]
86    SequenceIdOverflow { inbox_id: String, sequence_id: u64 },
87
88    /// TLS-encoding the bootstrap wire delta for COMPONENT_REGISTRY
89    /// (or any other inline `TlsMapDelta`) failed. Surfaces a
90    /// `tls_codec::Error` from the underlying serializer. Practically
91    /// unreachable (we just constructed the delta in memory), but the
92    /// `?` operator needs a conversion.
93    #[error("wire delta encode: {0}")]
94    TlsCodec(#[from] tls_codec::Error),
95}
96
97impl xmtp_common::retry::RetryableError for BootstrapSynthesisError {
98    /// Most synthesis failures are deterministic over the inputs and
99    /// not retryable (decode errors, registry-shape mismatches, common
100    /// migration validation). The exception is `IdentityUpdateLookup`,
101    /// which wraps a [`crate::client::ClientError`] that can carry a
102    /// transient API failure (network blip, server 5xx). Delegate
103    /// retryability to the wrapped client error so a momentary blip
104    /// during bootstrap doesn't permanently fail the intent.
105    fn is_retryable(&self) -> bool {
106        match self {
107            Self::IdentityUpdateLookup { source, .. } => source.is_retryable(),
108            Self::Common(_)
109            | Self::LegacyMembershipDecode(_)
110            | Self::RegistryReEncode(_)
111            | Self::SequenceIdOverflow { .. }
112            | Self::TlsCodec(_) => false,
113        }
114    }
115}
116
117/// Synthesize the full `AppDataUpdate` payload set the bootstrap
118/// commit ships, keyed by `ComponentId`.
119///
120/// Sync parts delegate to [`xmtp_mls_common::app_data::migration::synthesize_canonical_subset_for_validation`];
121/// the async part calls `IdentityUpdates::get_association_state` to
122/// partition `failed_installations` by owning inbox. Installations
123/// whose owner can't be resolved are dropped — `failed_installations`
124/// is a retry-suppression hint, so each costs at most one extra retry.
125pub async fn synthesize_initial_component_values<C: XmtpSharedContext>(
126    context: &C,
127    mls_group: &OpenMlsGroup,
128) -> Result<BTreeMap<ComponentId, Vec<u8>>, BootstrapSynthesisError> {
129    synthesize_initial_component_values_from_extensions(context, mls_group.extensions()).await
130}
131
132/// Extensions-only variant of [`synthesize_initial_component_values`].
133/// Lets tests exercise synthesis against synthetic extensions without
134/// standing up a real MLS group.
135pub async fn synthesize_initial_component_values_from_extensions<C: XmtpSharedContext>(
136    context: &C,
137    extensions: &Extensions<GroupContext>,
138) -> Result<BTreeMap<ComponentId, Vec<u8>>, BootstrapSynthesisError> {
139    use xmtp_mls_common::app_data::migration::synthesize_canonical_subset_from_extensions;
140
141    // Sync synthesis produces everything except GROUP_MEMBERSHIP.
142    let canonical: CanonicalBootstrapExpectation =
143        synthesize_canonical_subset_from_extensions(extensions)?;
144
145    let mut out: BTreeMap<ComponentId, Vec<u8>> = BTreeMap::new();
146    for (id, (_op_type, bytes)) in canonical.strict.into_iter() {
147        out.insert(id, bytes);
148    }
149
150    // COMPONENT_REGISTRY wire payload: a `TlsMapDelta<ComponentId,
151    // VLBytes>` of all-`Insert` mutations (bootstrap = delta-from-
152    // empty). Built inline from `canonical.expected_registry` —
153    // there's no whole-registry wire encoder on `ComponentRegistry`
154    // because every steady-state caller emits only the few entries
155    // it touches, so a "to_wire_bytes" method would only ever be
156    // used here. Keeping it inline avoids adding a one-call-site
157    // helper. The receiver decodes via `decode_component_registry_delta`
158    // inside the bootstrap validator and via `apply_wire_bytes` for
159    // the dict write — both produce the same materialized state.
160    //
161    // Round-trip-validate each metadata entry through `ComponentRegistry::set`
162    // first so we surface a `ComponentRegistryError` here (rather than
163    // shipping bytes that fail per-entry validation on the receiver).
164    let mut registry = ComponentRegistry::new();
165    for (id, meta) in canonical.expected_registry.iter() {
166        registry.set(*id, meta.clone())?;
167    }
168    let mut registry_delta: TlsMapDelta<ComponentId, VLBytes> = TlsMapDelta::new();
169    for (id, meta) in canonical.expected_registry.into_iter() {
170        registry_delta = registry_delta.insert(id, VLBytes::new(meta.encode_to_vec()));
171    }
172    out.insert(
173        ComponentId::COMPONENT_REGISTRY,
174        registry_delta.tls_serialize_detached()?,
175    );
176
177    // Async GROUP_MEMBERSHIP: partition failed_installations by
178    // owning inbox id via identity-update history.
179    let group_membership_bytes =
180        build_partitioned_group_membership(context, extensions, &canonical.membership_sequence_ids)
181            .await?;
182    out.insert(ComponentId::GROUP_MEMBERSHIP, group_membership_bytes);
183
184    Ok(out)
185}
186
187/// Walk the legacy `GroupMembership.failed_installations` flat list,
188/// query each member's installations from the identity-update store,
189/// and bucket the failed installations into a `TlsMap<InboxId, VLBytes>`
190/// of per-inbox `GroupMembershipEntryV1`.
191async fn build_partitioned_group_membership<C: XmtpSharedContext>(
192    context: &C,
193    extensions: &Extensions<GroupContext>,
194    sequence_ids: &BTreeMap<InboxId, u64>,
195) -> Result<Vec<u8>, BootstrapSynthesisError> {
196    use openmls::extensions::{Extension, UnknownExtension};
197    let legacy_bytes = extensions
198        .iter()
199        .find_map(|extension| match extension {
200            Extension::Unknown(
201                xmtp_configuration::GROUP_MEMBERSHIP_EXTENSION_ID,
202                UnknownExtension(data),
203            ) => Some(data),
204            _ => None,
205        })
206        .ok_or_else(|| {
207            BootstrapSynthesisError::Common(CommonMigrationError::MissingGroupMembershipExtension)
208        })?;
209    let legacy_proto = GroupMembershipProto::decode(legacy_bytes.as_slice())?;
210    // No ownership lookup is needed when there are no failed installations.
211    // Preserve the sequence-zero sentinel of a creator-only group.
212    if legacy_proto.failed_installations.is_empty() {
213        return encode_partitioned_group_membership(sequence_ids, BTreeMap::new());
214    }
215
216    let identity_updates = IdentityUpdates::new(context);
217    let db = context.db();
218
219    // installation_id -> owning inbox_id. HashMap is fine: we only
220    // `.get()` it (no iteration), so its undefined order doesn't leak
221    // into the serialized output.
222    let mut install_owner: std::collections::HashMap<Vec<u8>, InboxId> =
223        std::collections::HashMap::new();
224    for (inbox_id, seq) in sequence_ids.iter() {
225        let inbox_id_hex = inbox_id.to_hex();
226        let signed_seq =
227            i64::try_from(*seq).map_err(|_| BootstrapSynthesisError::SequenceIdOverflow {
228                inbox_id: inbox_id_hex.clone(),
229                sequence_id: *seq,
230            })?;
231        let state = identity_updates
232            .get_association_state(&db, &inbox_id_hex, Some(signed_seq))
233            .await
234            .map_err(|source| BootstrapSynthesisError::IdentityUpdateLookup {
235                inbox_id: inbox_id_hex.clone(),
236                source,
237            })?;
238        for install_id in state.installation_ids() {
239            install_owner.insert(install_id, *inbox_id);
240        }
241    }
242
243    // Bucket the flat failed_installations list by owner; drop entries
244    // whose owner can't be resolved. The bootstrap commit is produced
245    // by one sender and byte-compared by receivers (no re-synthesis),
246    // so a missed installation costs at most one retry — documented
247    // on the proto as a hint-only field.
248    let mut per_inbox_failed: BTreeMap<InboxId, Vec<Vec<u8>>> = BTreeMap::new();
249    let mut dropped = 0usize;
250    for fi in &legacy_proto.failed_installations {
251        if let Some(owner) = install_owner.get(fi) {
252            per_inbox_failed.entry(*owner).or_default().push(fi.clone());
253        } else {
254            dropped += 1;
255        }
256    }
257    if dropped > 0 {
258        let total = legacy_proto.failed_installations.len();
259        // Drops above half the input usually indicate a systemic
260        // identity-update lookup gap (e.g., the inbox of an installation
261        // never made it into `sequence_ids`), not the rare "stray entry"
262        // case the hint-only contract was designed for. Bump severity so
263        // operators see it in dashboards even though it isn't
264        // correctness-critical.
265        if dropped * 2 > total {
266            tracing::warn!(
267                dropped,
268                total,
269                "bootstrap synthesis dropped a majority of failed_installation entries (hint only, but unusual)"
270            );
271        } else {
272            tracing::info!(
273                dropped,
274                total,
275                "bootstrap synthesis dropped unresolvable failed_installation entries (hint only, not correctness-critical)"
276            );
277        }
278    }
279
280    encode_partitioned_group_membership(sequence_ids, per_inbox_failed)
281}
282
283fn encode_partitioned_group_membership(
284    sequence_ids: &BTreeMap<InboxId, u64>,
285    mut per_inbox_failed: BTreeMap<InboxId, Vec<Vec<u8>>>,
286) -> Result<Vec<u8>, BootstrapSynthesisError> {
287    // Build the final per-inbox entries. Wraps each `V1` payload in
288    // the `GroupMembershipEntry` envelope so the on-the-wire shape
289    // matches what `decode_group_membership_delta` reads back —
290    // forward-compat with future `Version` variants comes for free.
291    let mut entries: BTreeMap<InboxId, GroupMembershipEntry> = BTreeMap::new();
292    for (inbox_id, seq) in sequence_ids.iter() {
293        let failed = per_inbox_failed.remove(inbox_id).unwrap_or_default();
294        entries.insert(
295            *inbox_id,
296            GroupMembershipEntry {
297                version: Some(GroupMembershipEntryVersion::V1(GroupMembershipEntryV1 {
298                    sequence_id: *seq,
299                    failed_installations: failed,
300                })),
301            },
302        );
303    }
304
305    Ok(migration::encode_group_membership_delta(&entries)?)
306}
307
308/// Errors surfaced by [`stage_bootstrap_commit`].
309#[derive(Debug, thiserror::Error)]
310pub enum BootstrapCommitError<StorageError: std::error::Error> {
311    #[error("commit create error: {0}")]
312    CreateCommit(#[from] openmls::group::CreateCommitError),
313    #[error("commit stage error: {0}")]
314    StageCommit(#[from] openmls::group::CommitBuilderStageError<StorageError>),
315    #[error("TLS codec error: {0}")]
316    TlsCodec(#[from] tls_codec::Error),
317    /// Caller invariant violated: `new_extensions` still carries one of
318    /// the four legacy XMTP extensions that bootstrap is supposed to
319    /// strip. Failing fast here keeps a malformed sender from publishing
320    /// a commit that every honest receiver rejects.
321    #[error("bootstrap precondition: new_extensions still carries legacy XMTP extension {0:#06x}")]
322    LegacyExtensionPresent(u16),
323    /// Caller invariant violated: `new_extensions`'s
324    /// `RequiredCapabilities` doesn't list
325    /// `ExtensionType::AppDataDictionary`. Every bootstrap commit
326    /// MUST require AppDataDictionary so post-flip members can't skip
327    /// the support check.
328    #[error("bootstrap precondition: RequiredCapabilities doesn't list AppDataDictionary")]
329    MissingAppDataDictionaryRequirement,
330    /// Sender-side `apply_app_data_update_payload` rejected one of
331    /// the synthesized component values when deriving dict bytes from
332    /// wire bytes. Indicates a bug in synthesis (the wire bytes don't
333    /// decode under the component's own apply rules) — fail loud here
334    /// rather than ship a commit with sender/receiver dict divergence.
335    #[error("bootstrap precondition: dict apply failed: {0}")]
336    DictApply(#[from] super::component_source::ComponentSourceError),
337}
338
339/// Build and stage the bootstrap migration commit.
340///
341/// The commit bundles one `AppDataUpdate(component_id, Update(bytes))`
342/// proposal per entry in `component_values` (sorted by ComponentId
343/// ascending, enforced by `BTreeMap`) plus the GCE proposal carrying
344/// `new_extensions`. `with_app_data_dictionary_updates` is populated
345/// with the full set of dict writes so OpenMLS's
346/// `apply_app_data_update_proposals` sees a matching bag.
347///
348/// The caller is responsible for computing `component_values` via the
349/// async [`synthesize_initial_component_values`] and for building
350/// `new_extensions` with:
351/// - `MUTABLE_METADATA_EXTENSION_ID`, `GROUP_PERMISSIONS_EXTENSION_ID`,
352///   `GROUP_MEMBERSHIP_EXTENSION_ID`, and the immutable metadata
353///   extension (`ExtensionType::ImmutableMetadata`) removed from the
354///   group context extensions AND from `RequiredCapabilities`.
355/// - `ExtensionType::AppDataDictionary` added to `RequiredCapabilities`
356///   so receivers must advertise support for the dict-carrying
357///   standard MLS extension. The `AppDataDictionary` GCE itself is
358///   populated by OpenMLS when the bundled `AppDataUpdate` proposals
359///   apply during commit processing.
360pub fn stage_bootstrap_commit<Provider: OpenMlsProvider>(
361    mls_group: &mut OpenMlsGroup,
362    provider: &Provider,
363    signer: &impl openmls_traits::signatures::Signer,
364    component_values: &BTreeMap<ComponentId, Vec<u8>>,
365    new_extensions: Extensions<GroupContext>,
366) -> Result<CommitMessageBundle, BootstrapCommitError<Provider::StorageError>> {
367    use openmls::component::ComponentData;
368    use openmls::extensions::Extension;
369    use xmtp_configuration::{
370        GROUP_MEMBERSHIP_EXTENSION_ID, GROUP_PERMISSIONS_EXTENSION_ID,
371        MUTABLE_METADATA_EXTENSION_ID,
372    };
373
374    // Precondition guard for the contract documented above. A malformed
375    // `new_extensions` would build a commit that every honest receiver
376    // rejects, so fail loud at the sender — a clear precondition error
377    // beats an opaque downstream confirmation-tag mismatch.
378    for ext in new_extensions.iter() {
379        match ext {
380            Extension::Unknown(MUTABLE_METADATA_EXTENSION_ID, _) => {
381                return Err(BootstrapCommitError::LegacyExtensionPresent(
382                    MUTABLE_METADATA_EXTENSION_ID,
383                ));
384            }
385            Extension::Unknown(GROUP_PERMISSIONS_EXTENSION_ID, _) => {
386                return Err(BootstrapCommitError::LegacyExtensionPresent(
387                    GROUP_PERMISSIONS_EXTENSION_ID,
388                ));
389            }
390            Extension::Unknown(GROUP_MEMBERSHIP_EXTENSION_ID, _) => {
391                return Err(BootstrapCommitError::LegacyExtensionPresent(
392                    GROUP_MEMBERSHIP_EXTENSION_ID,
393                ));
394            }
395            Extension::ImmutableMetadata(_) => {
396                // OpenMLS-assigned IANA value for ExtensionType::ImmutableMetadata.
397                return Err(BootstrapCommitError::LegacyExtensionPresent(0xf000));
398            }
399            _ => {}
400        }
401    }
402    // RequiredCapabilities MUST list AppDataDictionary so post-flip
403    // members can't add themselves without supporting the dict. Using
404    // `check_proposals_enabled` (which detects the AppDataDictionary
405    // GCE itself) wouldn't work here — openmls only adds the dict GCE
406    // when the AppDataUpdate proposals apply during commit processing.
407    use openmls::extensions::ExtensionType;
408    let requires_app_data_dictionary = new_extensions
409        .required_capabilities()
410        .map(|rc| {
411            rc.extension_types()
412                .contains(&ExtensionType::AppDataDictionary)
413        })
414        .unwrap_or(false);
415    if !requires_app_data_dictionary {
416        return Err(BootstrapCommitError::MissingAppDataDictionaryRequirement);
417    }
418
419    // OpenMLS commit-ordering rule (draft-ietf-mls-extensions §4.7-7):
420    // the GCE proposal MUST come before any AppDataUpdate proposals.
421    let mut builder = mls_group
422        .commit_builder()
423        .propose_group_context_extensions(new_extensions)
424        .map_err(BootstrapCommitError::CreateCommit)?;
425    // Each component is encoded twice — once into the proposal payload
426    // (`AppDataUpdateProposal::update` takes ownership), and once into
427    // the dict updater below (`ComponentData::from_parts` also takes
428    // ownership). Both consumers want owned bytes, and the caller passes
429    // `&BTreeMap` (the closure in `mls_sync.rs` only sees a borrow), so
430    // we clone here and consume on the second pass to keep the
431    // sender/receiver byte-bag aligned for confirmation-tag agreement.
432    // Component values are bounded (well-known set; largest is
433    // GROUP_MEMBERSHIP scaling with member count) so the clones are not
434    // a hot-path concern today.
435    for (component_id, bytes) in component_values.iter() {
436        builder = builder.add_proposal(Proposal::AppDataUpdate(Box::new(
437            AppDataUpdateProposal::update(component_id.as_u16(), bytes.clone()),
438        )));
439    }
440
441    let mut stage = builder.load_psks(provider.storage())?;
442
443    // The wire payload (above) is a delta; the dict stores the
444    // materialized state (a snapshot). Sender and receiver must
445    // converge on byte-identical dict bytes — the OpenMLS path-
446    // encryption AAD covers the post-commit GroupContext, which
447    // embeds the serialized AppDataDictionary, so any sender/receiver
448    // byte divergence here surfaces on the receiver as
449    // `UnableToDecrypt` and the bootstrap commit is rejected.
450    //
451    // Single source of truth: route each component's wire bytes
452    // through `apply_app_data_update_payload(id, wire, None, &reg)` to
453    // derive the dict bytes. The receiver runs the same function over
454    // the same wire bytes (with prior=None at bootstrap) and gets the
455    // same output, so dict byte-equality is guaranteed by construction.
456    //
457    // The empty `ComponentRegistry` here is intentional: bootstrap
458    // only writes well-known components, each of which has a per-id
459    // `Component` impl, so the type-aware fallback branch in
460    // `apply_app_data_update_payload` is never consulted.
461    let empty_registry = xmtp_mls_common::app_data::component_registry::ComponentRegistry::new();
462    let mut updater = stage.app_data_dictionary_updater();
463    for (component_id, wire_bytes) in component_values.iter() {
464        let dict_bytes = super::component_source::apply_app_data_update_payload(
465            *component_id,
466            wire_bytes,
467            None,
468            &empty_registry,
469        )
470        .map_err(BootstrapCommitError::DictApply)?;
471        updater.set(ComponentData::from_parts(
472            component_id.as_u16(),
473            dict_bytes.into(),
474        ));
475    }
476    stage.with_app_data_dictionary_updates(updater.changes());
477
478    let bundle = stage
479        .build(provider.rand(), provider.crypto(), signer, |_| true)?
480        .stage_commit(provider)?;
481    Ok(bundle)
482}
483
484#[cfg(test)]
485mod tests {
486    //! Unit coverage for the sender-side bootstrap synthesis pipeline.
487    //! End-to-end commit-staging (with confirmation-tag agreement)
488    //! needs a real `OpenMlsGroup` and lives in integration tests.
489    use super::*;
490    use std::collections::HashMap;
491
492    use openmls::extensions::{Extension, Extensions, Metadata, UnknownExtension};
493    use xmtp_configuration::{
494        GROUP_MEMBERSHIP_EXTENSION_ID, GROUP_PERMISSIONS_EXTENSION_ID,
495        MUTABLE_METADATA_EXTENSION_ID,
496    };
497    use xmtp_mls_common::{
498        app_data::component_id::ComponentId, group_metadata::GroupMetadata,
499        group_mutable_metadata::GroupMutableMetadata,
500    };
501    use xmtp_proto::xmtp::mls::message_contents::{
502        GroupMembership as GroupMembershipProto, GroupMutablePermissionsV1,
503        MembershipPolicy as MembershipPolicyProto,
504        PermissionsUpdatePolicy as PermissionsUpdatePolicyProto, PolicySet as PolicySetProto,
505        membership_policy::{BasePolicy as MembershipBase, Kind as MembershipKind},
506        permissions_update_policy::{Kind as PermissionsKind, PermissionsBasePolicy},
507    };
508
509    use xmtp_db::identity_update::QueryIdentityUpdates;
510
511    use crate::{identity_updates::load_identity_updates, tester};
512
513    /// Unwrap a `GroupMembershipEntry` envelope to its inner `V1`
514    /// payload — the only legal shape today (decode rejects `None`).
515    /// Panics on any other variant; tests fail loudly instead of
516    /// silently skipping assertions.
517    fn unwrap_v1(entry: &GroupMembershipEntry) -> &GroupMembershipEntryV1 {
518        match entry.version.as_ref().expect("entry missing version") {
519            GroupMembershipEntryVersion::V1(v1) => v1,
520        }
521    }
522
523    fn minimal_policy_set() -> PolicySetProto {
524        let allow = MembershipPolicyProto {
525            kind: Some(MembershipKind::Base(MembershipBase::Allow as i32)),
526        };
527        let admin_only = PermissionsUpdatePolicyProto {
528            kind: Some(PermissionsKind::Base(
529                PermissionsBasePolicy::AllowIfAdmin as i32,
530            )),
531        };
532        let super_admin_only = PermissionsUpdatePolicyProto {
533            kind: Some(PermissionsKind::Base(
534                PermissionsBasePolicy::AllowIfSuperAdmin as i32,
535            )),
536        };
537        PolicySetProto {
538            add_member_policy: Some(allow.clone()),
539            remove_member_policy: Some(allow),
540            update_metadata_policy: HashMap::new(),
541            add_admin_policy: Some(admin_only.clone()),
542            remove_admin_policy: Some(admin_only),
543            update_permissions_policy: Some(super_admin_only),
544        }
545    }
546
547    fn build_test_extensions(
548        gmm: GroupMutableMetadata,
549        membership: GroupMembershipProto,
550        metadata: GroupMetadata,
551    ) -> Extensions<openmls::group::GroupContext> {
552        let gmm_bytes: Vec<u8> = gmm.try_into().unwrap();
553        let permissions_bytes = GroupMutablePermissionsV1 {
554            policies: Some(minimal_policy_set()),
555        }
556        .encode_to_vec();
557        let membership_bytes = membership.encode_to_vec();
558        let metadata_bytes: Vec<u8> = metadata.try_into().unwrap();
559        Extensions::from_vec(vec![
560            Extension::Unknown(MUTABLE_METADATA_EXTENSION_ID, UnknownExtension(gmm_bytes)),
561            Extension::Unknown(
562                GROUP_PERMISSIONS_EXTENSION_ID,
563                UnknownExtension(permissions_bytes),
564            ),
565            Extension::Unknown(
566                GROUP_MEMBERSHIP_EXTENSION_ID,
567                UnknownExtension(membership_bytes),
568            ),
569            Extension::ImmutableMetadata(Metadata::new(metadata_bytes)),
570        ])
571        .expect("valid group-context extension set")
572    }
573
574    fn default_gmm() -> GroupMutableMetadata {
575        GroupMutableMetadata::new(HashMap::new(), Vec::new(), Vec::new())
576    }
577
578    fn default_metadata(creator_inbox_id: String) -> GroupMetadata {
579        GroupMetadata::new(
580            xmtp_db::group::ConversationType::Group,
581            creator_inbox_id,
582            None,
583            None,
584        )
585    }
586
587    #[xmtp_common::test(unwrap_try = true)]
588    async fn synthesize_preserves_zero_membership_without_failed_installations() {
589        let context = crate::test::mock::context();
590        let inbox = hex::encode([7; 32]);
591        let extensions = build_test_extensions(
592            default_gmm(),
593            GroupMembershipProto {
594                members: [(inbox.clone(), 0)].into(),
595                failed_installations: Vec::new(),
596            },
597            default_metadata(inbox.clone()),
598        );
599        let values =
600            synthesize_initial_component_values_from_extensions(&context, &extensions).await?;
601        let membership = migration::decode_group_membership_delta(
602            values.get(&ComponentId::GROUP_MEMBERSHIP).unwrap(),
603        )?;
604        assert_eq!(membership.len(), 1);
605        let entry = unwrap_v1(membership.get(&InboxId::from_hex(&inbox)?).unwrap());
606        assert_eq!(entry.sequence_id, 0);
607        assert!(entry.failed_installations.is_empty());
608    }
609
610    #[xmtp_common::test(unwrap_try = true)]
611    async fn synthesize_initial_component_values_is_deterministic() {
612        // Same inputs → bit-identical output bytes. Load-bearing
613        // invariant for cross-peer byte-compare validation: a regression
614        // makes honest receivers reject every bootstrap commit with a
615        // confirmation-tag mismatch.
616        tester!(alix);
617        tester!(bo);
618        load_identity_updates(
619            alix.context.api(),
620            &alix.context.db(),
621            &[alix.inbox_id(), bo.inbox_id()],
622        )
623        .await?;
624        // GROUP_MEMBERSHIP sequence_ids are no longer synthetic: the
625        // synthesizer pins the per-inbox association-state lookup to
626        // the GMM's sequence_id, so the value must match an existing
627        // identity-update record.
628        let alix_seq = alix
629            .context
630            .db()
631            .get_latest_sequence_id_for_inbox(alix.inbox_id())? as u64;
632        let bo_seq = alix
633            .context
634            .db()
635            .get_latest_sequence_id_for_inbox(bo.inbox_id())? as u64;
636
637        let mut members = HashMap::new();
638        members.insert(alix.inbox_id().to_string(), alix_seq);
639        members.insert(bo.inbox_id().to_string(), bo_seq);
640        let exts = build_test_extensions(
641            default_gmm(),
642            GroupMembershipProto {
643                members,
644                failed_installations: vec![],
645            },
646            default_metadata(alix.inbox_id().to_string()),
647        );
648
649        let a = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
650        let b = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
651        assert_eq!(a, b, "bootstrap synthesis must be byte-stable");
652    }
653
654    #[xmtp_common::test(unwrap_try = true)]
655    async fn synthesize_partitions_failed_installations_by_owner() {
656        // Two inboxes each own one installation; the flat
657        // failed_installations list should partition so each
658        // per-inbox `GroupMembershipEntryV1` carries only its own.
659        tester!(alix);
660        tester!(bo);
661        load_identity_updates(
662            alix.context.api(),
663            &alix.context.db(),
664            &[alix.inbox_id(), bo.inbox_id()],
665        )
666        .await?;
667
668        let alix_install = alix.installation_id.to_vec();
669        let bo_install = bo.installation_id.to_vec();
670        let alix_inbox = InboxId::from_hex(alix.inbox_id())?;
671        let bo_inbox = InboxId::from_hex(bo.inbox_id())?;
672        let alix_seq = alix
673            .context
674            .db()
675            .get_latest_sequence_id_for_inbox(alix.inbox_id())? as u64;
676        let bo_seq = alix
677            .context
678            .db()
679            .get_latest_sequence_id_for_inbox(bo.inbox_id())? as u64;
680
681        let mut members = HashMap::new();
682        members.insert(alix.inbox_id().to_string(), alix_seq);
683        members.insert(bo.inbox_id().to_string(), bo_seq);
684        let exts = build_test_extensions(
685            default_gmm(),
686            GroupMembershipProto {
687                members,
688                failed_installations: vec![alix_install.clone(), bo_install.clone()],
689            },
690            default_metadata(alix.inbox_id().to_string()),
691        );
692
693        let out = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
694
695        let bytes = out.get(&ComponentId::GROUP_MEMBERSHIP).unwrap();
696        let decoded =
697            xmtp_mls_common::app_data::migration::decode_group_membership_delta(bytes).unwrap();
698        assert_eq!(decoded.len(), 2);
699        let v1_a = unwrap_v1(decoded.get(&alix_inbox).unwrap());
700        let v1_b = unwrap_v1(decoded.get(&bo_inbox).unwrap());
701        assert_eq!(v1_a.sequence_id, alix_seq);
702        assert_eq!(v1_a.failed_installations, vec![alix_install]);
703        assert_eq!(v1_b.sequence_id, bo_seq);
704        assert_eq!(v1_b.failed_installations, vec![bo_install]);
705    }
706
707    #[xmtp_common::test(unwrap_try = true)]
708    async fn synthesize_drops_unresolvable_failed_installations() {
709        // `failed_installations` is hint-only; an entry whose owning
710        // inbox isn't in the lookup is dropped silently.
711        tester!(alix);
712        load_identity_updates(alix.context.api(), &alix.context.db(), &[alix.inbox_id()]).await?;
713
714        let alix_inbox = InboxId::from_hex(alix.inbox_id())?;
715        let alix_seq = alix
716            .context
717            .db()
718            .get_latest_sequence_id_for_inbox(alix.inbox_id())? as u64;
719        let orphan_install = vec![0xDE; 32]; // not owned by any inbox
720
721        let mut members = HashMap::new();
722        members.insert(alix.inbox_id().to_string(), alix_seq);
723        let exts = build_test_extensions(
724            default_gmm(),
725            GroupMembershipProto {
726                members,
727                failed_installations: vec![orphan_install],
728            },
729            default_metadata(alix.inbox_id().to_string()),
730        );
731
732        let out = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
733        let bytes = out.get(&ComponentId::GROUP_MEMBERSHIP).unwrap();
734        let decoded =
735            xmtp_mls_common::app_data::migration::decode_group_membership_delta(bytes).unwrap();
736        let v1 = unwrap_v1(decoded.get(&alix_inbox).unwrap());
737        assert_eq!(
738            v1.failed_installations,
739            Vec::<Vec<u8>>::new(),
740            "orphan failed_installation must be dropped"
741        );
742    }
743
744    #[xmtp_common::test(unwrap_try = true)]
745    async fn synthesize_emits_expected_component_keys() {
746        // Every well-known non-optional component shows up. DM_MEMBERS
747        // / ONESHOT_MESSAGE are gated on presence — a plain non-DM,
748        // non-oneshot group has neither.
749        tester!(alix);
750        let exts = build_test_extensions(
751            default_gmm(),
752            GroupMembershipProto::default(),
753            default_metadata(alix.inbox_id().to_string()),
754        );
755        let out = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
756        assert!(out.contains_key(&ComponentId::COMPONENT_REGISTRY));
757        assert!(out.contains_key(&ComponentId::GROUP_MEMBERSHIP));
758        assert!(out.contains_key(&ComponentId::ADMIN_LIST));
759        assert!(out.contains_key(&ComponentId::SUPER_ADMIN_LIST));
760        assert!(out.contains_key(&ComponentId::CREATOR_INBOX_ID));
761        assert!(out.contains_key(&ComponentId::CONVERSATION_TYPE));
762        assert!(!out.contains_key(&ComponentId::DM_MEMBERS));
763        assert!(!out.contains_key(&ComponentId::ONESHOT_MESSAGE));
764    }
765
766    #[xmtp_common::test(unwrap_try = true)]
767    async fn synthesize_partitions_against_snapshotted_view_not_latest() {
768        // Two members, each with multiple installations; the group's
769        // recorded sequence_id captures a snapshot of identity history.
770        // After the snapshot, new installations are added to each
771        // member's chain WITHOUT a group-side resync. The bootstrap
772        // synthesizer pins per-inbox association-state lookups to the
773        // group's snapshotted sequence_id, so:
774        //  - installations live at the snapshot are partitioned to their
775        //    owning inbox
776        //  - installations added AFTER the snapshot have no owner at the
777        //    snapshotted state and are dropped from `failed_installations`
778        //  - bogus install ids that never belonged to anyone are dropped
779        //  - cross-inbox leakage doesn't happen
780        //  - re-running synthesis with the same input is byte-stable
781        //
782        // This is the exact "group has not refreshed yet, identity moved
783        // on" scenario every honest receiver will hit during a real
784        // bootstrap rollout.
785        use std::collections::BTreeSet;
786
787        tester!(alix);
788        let alix2 = alix.new_installation().await;
789        tester!(bo);
790        let bo2 = bo.new_installation().await;
791
792        // Refresh once so the synthesizer's local-DB read sees both
793        // inboxes' chains up through the second installation.
794        load_identity_updates(
795            alix.context.api(),
796            &alix.context.db(),
797            &[alix.inbox_id(), bo.inbox_id()],
798        )
799        .await?;
800
801        // Snapshot the group's view of each inbox here. The synthetic
802        // pre-flip GMM below uses these as its sequence_ids, so the
803        // synthesizer will pin its per-inbox lookups to this point in
804        // each chain.
805        let alix_snapshot_seq =
806            alix.context
807                .db()
808                .get_latest_sequence_id_for_inbox(alix.inbox_id())? as u64;
809        let bo_snapshot_seq = alix
810            .context
811            .db()
812            .get_latest_sequence_id_for_inbox(bo.inbox_id())? as u64;
813
814        let alix1_install = alix.installation_id.to_vec();
815        let alix2_install = alix2.installation_id.to_vec();
816        let bo1_install = bo.installation_id.to_vec();
817        let bo2_install = bo2.installation_id.to_vec();
818
819        // After the snapshot: each chain extends. The group has NOT
820        // resynced, so its recorded sequence_id stays put.
821        let alix3 = alix.new_installation().await;
822        let bo3 = bo.new_installation().await;
823        let alix3_install = alix3.installation_id.to_vec();
824        let bo3_install = bo3.installation_id.to_vec();
825
826        // Refresh local DB so post-snapshot updates are visible — the
827        // point of this test is that the synthesizer DOESN'T use them
828        // because it queries at the snapshotted seq_id, not latest.
829        load_identity_updates(
830            alix.context.api(),
831            &alix.context.db(),
832            &[alix.inbox_id(), bo.inbox_id()],
833        )
834        .await?;
835
836        let alix_inbox = InboxId::from_hex(alix.inbox_id())?;
837        let bo_inbox = InboxId::from_hex(bo.inbox_id())?;
838
839        // Bogus install id that has never belonged to any inbox — the
840        // legacy `failed_installations` proto is permissive enough that
841        // a malicious or buggy prior commit could have stuffed garbage
842        // in here.
843        let bogus_install = vec![0xCA; 32];
844
845        let mut members = HashMap::new();
846        members.insert(alix.inbox_id().to_string(), alix_snapshot_seq);
847        members.insert(bo.inbox_id().to_string(), bo_snapshot_seq);
848        let exts = build_test_extensions(
849            default_gmm(),
850            GroupMembershipProto {
851                members,
852                failed_installations: vec![
853                    alix1_install.clone(),
854                    alix2_install.clone(),
855                    alix3_install.clone(),
856                    bo1_install.clone(),
857                    bo2_install.clone(),
858                    bo3_install.clone(),
859                    bogus_install.clone(),
860                ],
861            },
862            default_metadata(alix.inbox_id().to_string()),
863        );
864
865        let out = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
866        let bytes = out.get(&ComponentId::GROUP_MEMBERSHIP).unwrap();
867        let decoded =
868            xmtp_mls_common::app_data::migration::decode_group_membership_delta(bytes).unwrap();
869
870        let alix_v1 = unwrap_v1(decoded.get(&alix_inbox).unwrap());
871        let bo_v1 = unwrap_v1(decoded.get(&bo_inbox).unwrap());
872
873        assert_eq!(alix_v1.sequence_id, alix_snapshot_seq);
874        assert_eq!(bo_v1.sequence_id, bo_snapshot_seq);
875
876        let alix_failed: BTreeSet<Vec<u8>> = alix_v1.failed_installations.iter().cloned().collect();
877        let bo_failed: BTreeSet<Vec<u8>> = bo_v1.failed_installations.iter().cloned().collect();
878
879        // Snapshot-visible installations partition to their owner.
880        assert!(alix_failed.contains(&alix1_install));
881        assert!(alix_failed.contains(&alix2_install));
882        assert!(bo_failed.contains(&bo1_install));
883        assert!(bo_failed.contains(&bo2_install));
884
885        // Post-snapshot installations have no owner at the snapshotted
886        // sequence_id and are dropped.
887        assert!(!alix_failed.contains(&alix3_install));
888        assert!(!bo_failed.contains(&alix3_install));
889        assert!(!alix_failed.contains(&bo3_install));
890        assert!(!bo_failed.contains(&bo3_install));
891
892        // Bogus install id is dropped — never belonged to any inbox.
893        assert!(!alix_failed.contains(&bogus_install));
894        assert!(!bo_failed.contains(&bogus_install));
895
896        // No cross-inbox leakage.
897        assert!(!alix_failed.contains(&bo1_install));
898        assert!(!alix_failed.contains(&bo2_install));
899        assert!(!bo_failed.contains(&alix1_install));
900        assert!(!bo_failed.contains(&alix2_install));
901
902        // Determinism: byte-identical output across calls. Validation
903        // peers byte-compare against this, so any non-determinism here
904        // would make every honest receiver reject the bootstrap commit.
905        let again =
906            synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
907        assert_eq!(out, again, "bootstrap synthesis must be byte-stable");
908    }
909}