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] xmtp_mls_common::app_data::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    // the former migration predicate (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 =
465            xmtp_mls_common::app_data::component_source::apply_app_data_update_payload(
466                *component_id,
467                wire_bytes,
468                None,
469                &empty_registry,
470            )
471            .map_err(BootstrapCommitError::DictApply)?;
472        updater.set(ComponentData::from_parts(
473            component_id.as_u16(),
474            dict_bytes.into(),
475        ));
476    }
477    stage.with_app_data_dictionary_updates(updater.changes());
478
479    let bundle = stage
480        .build(provider.rand(), provider.crypto(), signer, |_| true)?
481        .stage_commit(provider)?;
482    Ok(bundle)
483}
484
485#[cfg(test)]
486mod tests {
487    //! Unit coverage for the sender-side bootstrap synthesis pipeline.
488    //! End-to-end commit-staging (with confirmation-tag agreement)
489    //! needs a real `OpenMlsGroup` and lives in integration tests.
490    use super::*;
491    use std::collections::HashMap;
492
493    use openmls::extensions::{Extension, Extensions, Metadata, UnknownExtension};
494    use xmtp_configuration::{
495        GROUP_MEMBERSHIP_EXTENSION_ID, GROUP_PERMISSIONS_EXTENSION_ID,
496        MUTABLE_METADATA_EXTENSION_ID,
497    };
498    use xmtp_mls_common::{
499        app_data::component_id::ComponentId, group_metadata::GroupMetadata,
500        group_mutable_metadata::GroupMutableMetadata,
501    };
502    use xmtp_proto::xmtp::mls::message_contents::{
503        GroupMembership as GroupMembershipProto, GroupMutablePermissionsV1,
504        MembershipPolicy as MembershipPolicyProto,
505        PermissionsUpdatePolicy as PermissionsUpdatePolicyProto, PolicySet as PolicySetProto,
506        membership_policy::{BasePolicy as MembershipBase, Kind as MembershipKind},
507        permissions_update_policy::{Kind as PermissionsKind, PermissionsBasePolicy},
508    };
509
510    use xmtp_db::identity_update::QueryIdentityUpdates;
511
512    use crate::{identity_updates::load_identity_updates, tester};
513
514    /// Unwrap a `GroupMembershipEntry` envelope to its inner `V1`
515    /// payload — the only legal shape today (decode rejects `None`).
516    /// Panics on any other variant; tests fail loudly instead of
517    /// silently skipping assertions.
518    fn unwrap_v1(entry: &GroupMembershipEntry) -> &GroupMembershipEntryV1 {
519        match entry.version.as_ref().expect("entry missing version") {
520            GroupMembershipEntryVersion::V1(v1) => v1,
521        }
522    }
523
524    fn minimal_policy_set() -> PolicySetProto {
525        let allow = MembershipPolicyProto {
526            kind: Some(MembershipKind::Base(MembershipBase::Allow as i32)),
527        };
528        let admin_only = PermissionsUpdatePolicyProto {
529            kind: Some(PermissionsKind::Base(
530                PermissionsBasePolicy::AllowIfAdmin as i32,
531            )),
532        };
533        let super_admin_only = PermissionsUpdatePolicyProto {
534            kind: Some(PermissionsKind::Base(
535                PermissionsBasePolicy::AllowIfSuperAdmin as i32,
536            )),
537        };
538        PolicySetProto {
539            add_member_policy: Some(allow.clone()),
540            remove_member_policy: Some(allow),
541            update_metadata_policy: HashMap::new(),
542            add_admin_policy: Some(admin_only.clone()),
543            remove_admin_policy: Some(admin_only),
544            update_permissions_policy: Some(super_admin_only),
545        }
546    }
547
548    fn build_test_extensions(
549        gmm: GroupMutableMetadata,
550        membership: GroupMembershipProto,
551        metadata: GroupMetadata,
552    ) -> Extensions<openmls::group::GroupContext> {
553        let gmm_bytes: Vec<u8> = gmm.try_into().unwrap();
554        let permissions_bytes = GroupMutablePermissionsV1 {
555            policies: Some(minimal_policy_set()),
556        }
557        .encode_to_vec();
558        let membership_bytes = membership.encode_to_vec();
559        let metadata_bytes: Vec<u8> = metadata.try_into().unwrap();
560        Extensions::from_vec(vec![
561            Extension::Unknown(MUTABLE_METADATA_EXTENSION_ID, UnknownExtension(gmm_bytes)),
562            Extension::Unknown(
563                GROUP_PERMISSIONS_EXTENSION_ID,
564                UnknownExtension(permissions_bytes),
565            ),
566            Extension::Unknown(
567                GROUP_MEMBERSHIP_EXTENSION_ID,
568                UnknownExtension(membership_bytes),
569            ),
570            Extension::ImmutableMetadata(Metadata::new(metadata_bytes)),
571        ])
572        .expect("valid group-context extension set")
573    }
574
575    fn default_gmm() -> GroupMutableMetadata {
576        GroupMutableMetadata::new(HashMap::new(), Vec::new(), Vec::new())
577    }
578
579    fn default_metadata(creator_inbox_id: String) -> GroupMetadata {
580        GroupMetadata::new(
581            xmtp_db::group::ConversationType::Group,
582            creator_inbox_id,
583            None,
584            None,
585        )
586    }
587
588    #[xmtp_common::test(unwrap_try = true)]
589    async fn synthesize_preserves_zero_membership_without_failed_installations() {
590        let context = crate::test::mock::context();
591        let inbox = hex::encode([7; 32]);
592        let extensions = build_test_extensions(
593            default_gmm(),
594            GroupMembershipProto {
595                members: [(inbox.clone(), 0)].into(),
596                failed_installations: Vec::new(),
597            },
598            default_metadata(inbox.clone()),
599        );
600        let values =
601            synthesize_initial_component_values_from_extensions(&context, &extensions).await?;
602        let membership = migration::decode_group_membership_delta(
603            values.get(&ComponentId::GROUP_MEMBERSHIP).unwrap(),
604        )?;
605        assert_eq!(membership.len(), 1);
606        let entry = unwrap_v1(membership.get(&InboxId::from_hex(&inbox)?).unwrap());
607        assert_eq!(entry.sequence_id, 0);
608        assert!(entry.failed_installations.is_empty());
609    }
610
611    #[xmtp_common::test(unwrap_try = true)]
612    async fn synthesize_initial_component_values_is_deterministic() {
613        // Same inputs → bit-identical output bytes. Load-bearing
614        // invariant for cross-peer byte-compare validation: a regression
615        // makes honest receivers reject every bootstrap commit with a
616        // confirmation-tag mismatch.
617        tester!(alix);
618        tester!(bo);
619        load_identity_updates(
620            alix.context.api(),
621            &alix.context.db(),
622            &[alix.inbox_id(), bo.inbox_id()],
623        )
624        .await?;
625        // GROUP_MEMBERSHIP sequence_ids are no longer synthetic: the
626        // synthesizer pins the per-inbox association-state lookup to
627        // the GMM's sequence_id, so the value must match an existing
628        // identity-update record.
629        let alix_seq = alix
630            .context
631            .db()
632            .get_latest_sequence_id_for_inbox(alix.inbox_id())? as u64;
633        let bo_seq = alix
634            .context
635            .db()
636            .get_latest_sequence_id_for_inbox(bo.inbox_id())? as u64;
637
638        let mut members = HashMap::new();
639        members.insert(alix.inbox_id().to_string(), alix_seq);
640        members.insert(bo.inbox_id().to_string(), bo_seq);
641        let exts = build_test_extensions(
642            default_gmm(),
643            GroupMembershipProto {
644                members,
645                failed_installations: vec![],
646            },
647            default_metadata(alix.inbox_id().to_string()),
648        );
649
650        let a = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
651        let b = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
652        assert_eq!(a, b, "bootstrap synthesis must be byte-stable");
653    }
654
655    #[xmtp_common::test(unwrap_try = true)]
656    async fn synthesize_partitions_failed_installations_by_owner() {
657        // Two inboxes each own one installation; the flat
658        // failed_installations list should partition so each
659        // per-inbox `GroupMembershipEntryV1` carries only its own.
660        tester!(alix);
661        tester!(bo);
662        load_identity_updates(
663            alix.context.api(),
664            &alix.context.db(),
665            &[alix.inbox_id(), bo.inbox_id()],
666        )
667        .await?;
668
669        let alix_install = alix.installation_id.to_vec();
670        let bo_install = bo.installation_id.to_vec();
671        let alix_inbox = InboxId::from_hex(alix.inbox_id())?;
672        let bo_inbox = InboxId::from_hex(bo.inbox_id())?;
673        let alix_seq = alix
674            .context
675            .db()
676            .get_latest_sequence_id_for_inbox(alix.inbox_id())? as u64;
677        let bo_seq = alix
678            .context
679            .db()
680            .get_latest_sequence_id_for_inbox(bo.inbox_id())? as u64;
681
682        let mut members = HashMap::new();
683        members.insert(alix.inbox_id().to_string(), alix_seq);
684        members.insert(bo.inbox_id().to_string(), bo_seq);
685        let exts = build_test_extensions(
686            default_gmm(),
687            GroupMembershipProto {
688                members,
689                failed_installations: vec![alix_install.clone(), bo_install.clone()],
690            },
691            default_metadata(alix.inbox_id().to_string()),
692        );
693
694        let out = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
695
696        let bytes = out.get(&ComponentId::GROUP_MEMBERSHIP).unwrap();
697        let decoded =
698            xmtp_mls_common::app_data::migration::decode_group_membership_delta(bytes).unwrap();
699        assert_eq!(decoded.len(), 2);
700        let v1_a = unwrap_v1(decoded.get(&alix_inbox).unwrap());
701        let v1_b = unwrap_v1(decoded.get(&bo_inbox).unwrap());
702        assert_eq!(v1_a.sequence_id, alix_seq);
703        assert_eq!(v1_a.failed_installations, vec![alix_install]);
704        assert_eq!(v1_b.sequence_id, bo_seq);
705        assert_eq!(v1_b.failed_installations, vec![bo_install]);
706    }
707
708    #[xmtp_common::test(unwrap_try = true)]
709    async fn synthesize_drops_unresolvable_failed_installations() {
710        // `failed_installations` is hint-only; an entry whose owning
711        // inbox isn't in the lookup is dropped silently.
712        tester!(alix);
713        load_identity_updates(alix.context.api(), &alix.context.db(), &[alix.inbox_id()]).await?;
714
715        let alix_inbox = InboxId::from_hex(alix.inbox_id())?;
716        let alix_seq = alix
717            .context
718            .db()
719            .get_latest_sequence_id_for_inbox(alix.inbox_id())? as u64;
720        let orphan_install = vec![0xDE; 32]; // not owned by any inbox
721
722        let mut members = HashMap::new();
723        members.insert(alix.inbox_id().to_string(), alix_seq);
724        let exts = build_test_extensions(
725            default_gmm(),
726            GroupMembershipProto {
727                members,
728                failed_installations: vec![orphan_install],
729            },
730            default_metadata(alix.inbox_id().to_string()),
731        );
732
733        let out = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
734        let bytes = out.get(&ComponentId::GROUP_MEMBERSHIP).unwrap();
735        let decoded =
736            xmtp_mls_common::app_data::migration::decode_group_membership_delta(bytes).unwrap();
737        let v1 = unwrap_v1(decoded.get(&alix_inbox).unwrap());
738        assert_eq!(
739            v1.failed_installations,
740            Vec::<Vec<u8>>::new(),
741            "orphan failed_installation must be dropped"
742        );
743    }
744
745    #[xmtp_common::test(unwrap_try = true)]
746    async fn synthesize_emits_expected_component_keys() {
747        // Every well-known non-optional component shows up. DM_MEMBERS
748        // / ONESHOT_MESSAGE are gated on presence — a plain non-DM,
749        // non-oneshot group has neither.
750        tester!(alix);
751        let exts = build_test_extensions(
752            default_gmm(),
753            GroupMembershipProto::default(),
754            default_metadata(alix.inbox_id().to_string()),
755        );
756        let out = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
757        assert!(out.contains_key(&ComponentId::COMPONENT_REGISTRY));
758        assert!(out.contains_key(&ComponentId::GROUP_MEMBERSHIP));
759        assert!(out.contains_key(&ComponentId::ADMIN_LIST));
760        assert!(out.contains_key(&ComponentId::SUPER_ADMIN_LIST));
761        assert!(out.contains_key(&ComponentId::CREATOR_INBOX_ID));
762        assert!(out.contains_key(&ComponentId::CONVERSATION_TYPE));
763        assert!(!out.contains_key(&ComponentId::DM_MEMBERS));
764        assert!(!out.contains_key(&ComponentId::ONESHOT_MESSAGE));
765    }
766
767    #[xmtp_common::test(unwrap_try = true)]
768    async fn synthesize_partitions_against_snapshotted_view_not_latest() {
769        // Two members, each with multiple installations; the group's
770        // recorded sequence_id captures a snapshot of identity history.
771        // After the snapshot, new installations are added to each
772        // member's chain WITHOUT a group-side resync. The bootstrap
773        // synthesizer pins per-inbox association-state lookups to the
774        // group's snapshotted sequence_id, so:
775        //  - installations live at the snapshot are partitioned to their
776        //    owning inbox
777        //  - installations added AFTER the snapshot have no owner at the
778        //    snapshotted state and are dropped from `failed_installations`
779        //  - bogus install ids that never belonged to anyone are dropped
780        //  - cross-inbox leakage doesn't happen
781        //  - re-running synthesis with the same input is byte-stable
782        //
783        // This is the exact "group has not refreshed yet, identity moved
784        // on" scenario every honest receiver will hit during a real
785        // bootstrap rollout.
786        use std::collections::BTreeSet;
787
788        tester!(alix);
789        let alix2 = alix.new_installation().await;
790        tester!(bo);
791        let bo2 = bo.new_installation().await;
792
793        // Refresh once so the synthesizer's local-DB read sees both
794        // inboxes' chains up through the second installation.
795        load_identity_updates(
796            alix.context.api(),
797            &alix.context.db(),
798            &[alix.inbox_id(), bo.inbox_id()],
799        )
800        .await?;
801
802        // Snapshot the group's view of each inbox here. The synthetic
803        // pre-flip GMM below uses these as its sequence_ids, so the
804        // synthesizer will pin its per-inbox lookups to this point in
805        // each chain.
806        let alix_snapshot_seq =
807            alix.context
808                .db()
809                .get_latest_sequence_id_for_inbox(alix.inbox_id())? as u64;
810        let bo_snapshot_seq = alix
811            .context
812            .db()
813            .get_latest_sequence_id_for_inbox(bo.inbox_id())? as u64;
814
815        let alix1_install = alix.installation_id.to_vec();
816        let alix2_install = alix2.installation_id.to_vec();
817        let bo1_install = bo.installation_id.to_vec();
818        let bo2_install = bo2.installation_id.to_vec();
819
820        // After the snapshot: each chain extends. The group has NOT
821        // resynced, so its recorded sequence_id stays put.
822        let alix3 = alix.new_installation().await;
823        let bo3 = bo.new_installation().await;
824        let alix3_install = alix3.installation_id.to_vec();
825        let bo3_install = bo3.installation_id.to_vec();
826
827        // Refresh local DB so post-snapshot updates are visible — the
828        // point of this test is that the synthesizer DOESN'T use them
829        // because it queries at the snapshotted seq_id, not latest.
830        load_identity_updates(
831            alix.context.api(),
832            &alix.context.db(),
833            &[alix.inbox_id(), bo.inbox_id()],
834        )
835        .await?;
836
837        let alix_inbox = InboxId::from_hex(alix.inbox_id())?;
838        let bo_inbox = InboxId::from_hex(bo.inbox_id())?;
839
840        // Bogus install id that has never belonged to any inbox — the
841        // legacy `failed_installations` proto is permissive enough that
842        // a malicious or buggy prior commit could have stuffed garbage
843        // in here.
844        let bogus_install = vec![0xCA; 32];
845
846        let mut members = HashMap::new();
847        members.insert(alix.inbox_id().to_string(), alix_snapshot_seq);
848        members.insert(bo.inbox_id().to_string(), bo_snapshot_seq);
849        let exts = build_test_extensions(
850            default_gmm(),
851            GroupMembershipProto {
852                members,
853                failed_installations: vec![
854                    alix1_install.clone(),
855                    alix2_install.clone(),
856                    alix3_install.clone(),
857                    bo1_install.clone(),
858                    bo2_install.clone(),
859                    bo3_install.clone(),
860                    bogus_install.clone(),
861                ],
862            },
863            default_metadata(alix.inbox_id().to_string()),
864        );
865
866        let out = synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
867        let bytes = out.get(&ComponentId::GROUP_MEMBERSHIP).unwrap();
868        let decoded =
869            xmtp_mls_common::app_data::migration::decode_group_membership_delta(bytes).unwrap();
870
871        let alix_v1 = unwrap_v1(decoded.get(&alix_inbox).unwrap());
872        let bo_v1 = unwrap_v1(decoded.get(&bo_inbox).unwrap());
873
874        assert_eq!(alix_v1.sequence_id, alix_snapshot_seq);
875        assert_eq!(bo_v1.sequence_id, bo_snapshot_seq);
876
877        let alix_failed: BTreeSet<Vec<u8>> = alix_v1.failed_installations.iter().cloned().collect();
878        let bo_failed: BTreeSet<Vec<u8>> = bo_v1.failed_installations.iter().cloned().collect();
879
880        // Snapshot-visible installations partition to their owner.
881        assert!(alix_failed.contains(&alix1_install));
882        assert!(alix_failed.contains(&alix2_install));
883        assert!(bo_failed.contains(&bo1_install));
884        assert!(bo_failed.contains(&bo2_install));
885
886        // Post-snapshot installations have no owner at the snapshotted
887        // sequence_id and are dropped.
888        assert!(!alix_failed.contains(&alix3_install));
889        assert!(!bo_failed.contains(&alix3_install));
890        assert!(!alix_failed.contains(&bo3_install));
891        assert!(!bo_failed.contains(&bo3_install));
892
893        // Bogus install id is dropped — never belonged to any inbox.
894        assert!(!alix_failed.contains(&bogus_install));
895        assert!(!bo_failed.contains(&bogus_install));
896
897        // No cross-inbox leakage.
898        assert!(!alix_failed.contains(&bo1_install));
899        assert!(!alix_failed.contains(&bo2_install));
900        assert!(!bo_failed.contains(&alix1_install));
901        assert!(!bo_failed.contains(&alix2_install));
902
903        // Determinism: byte-identical output across calls. Validation
904        // peers byte-compare against this, so any non-determinism here
905        // would make every honest receiver reject the bootstrap commit.
906        let again =
907            synthesize_initial_component_values_from_extensions(&alix.context, &exts).await?;
908        assert_eq!(out, again, "bootstrap synthesis must be byte-stable");
909    }
910}