Skip to main content

xmtp_mls_common/app_data/
creation.rs

1//! App-data dictionary values that are synthesized when a conversation is created.
2//!
3//! [`initial_dictionary`] builds the epoch-0 dictionary, including the
4//! registry entries a new conversation takes from the backend catalogue.
5//! [`catalogue_registry_entries`] selects those entries; a later commit
6//! reuses it to register the ones a group is missing.
7
8use tls_codec::Serialize;
9use xmtp_configuration::ApplicationComponentDefinition;
10use xmtp_proto::types::ConversationType;
11use xmtp_proto::xmtp::mls::message_contents::{
12    ComponentMetadata, ComponentPermissions, ComponentType,
13    MembershipPolicy as MembershipPolicyProto, MetadataPolicy as MetadataPolicyProto,
14    PermissionsUpdatePolicy as PermissionsUpdatePolicyProto, PolicySet as PolicySetProto,
15    membership_policy::{BasePolicy as MembershipBasePolicy, Kind as MembershipPolicyKind},
16    metadata_policy::{
17        AndCondition as MetadataAndCondition, AnyCondition as MetadataAnyCondition,
18        Kind as MetadataPolicyKind, MetadataBasePolicy,
19    },
20    permissions_update_policy::{Kind as PermissionsPolicyKind, PermissionsBasePolicy},
21};
22
23use crate::{
24    app_data::{
25        component_id::ComponentId,
26        component_registry::{ComponentRegistry, ComponentRegistryError, new_component_metadata},
27        migration::MigrationError,
28    },
29    group_mutable_metadata::MetadataField,
30    inbox_id::InboxId,
31    tls_set::TlsSetDelta,
32};
33
34/// Produce a populated [`ComponentRegistry`] from the legacy
35/// [`PolicySetProto`]. Deterministic: every honest peer synthesizes
36/// bit-identical output from the same input.
37///
38/// Mapping summary:
39///
40/// - Mutable scalar components pull insert/update from
41///   `update_metadata_policy[field_name]`; a missing key defaults to
42///   admin-only (super-admin for the 0x800A protocol-version floor),
43///   mirroring the legacy enforcer. Delete is hardcoded super-admin-only.
44///   Per-field `ComponentType` comes from
45///   [`metadata_field_registry_mapping`] — disappearing-message
46///   timestamps are bytes (BE-u64), the rest are utf-8 strings.
47/// - `ADMIN_LIST` is constrained: insert/update from `add_admin_policy`,
48///   delete from `remove_admin_policy`. All must be deny-, admin-, or
49///   super-admin-gated (synthesis rejects otherwise).
50/// - `SUPER_ADMIN_LIST` and `COMPONENT_REGISTRY` are hardcoded
51///   super-admin-only and not written to the registry.
52/// - `GROUP_MEMBERSHIP` mirrors `add_member_policy`/`remove_member_policy`
53///   for insert/delete; update is `Allow` (anyone can advance
54///   installations).
55/// - Immutable components: super-admin insert, deny update + delete.
56pub fn synthesize_registry_from_policy_set(
57    policy_set: &PolicySetProto,
58) -> Result<ComponentRegistry, MigrationError> {
59    // Public entry point: maximal registry (every well-known component).
60    // The internal `build_registry` gates `DM_MEMBERS` / `ONESHOT_MESSAGE`
61    // for receiver-side synthesis where the group may not have them —
62    // those live in the immutable range and can't be removed once written.
63    build_registry(policy_set, true, true)
64}
65
66pub(crate) fn build_registry(
67    policy_set: &PolicySetProto,
68    include_dm_members: bool,
69    include_oneshot_message: bool,
70) -> Result<ComponentRegistry, MigrationError> {
71    let mut registry = ComponentRegistry::new();
72
73    // Defensive: every top-level policy must be present.
74    let add_member = policy_set
75        .add_member_policy
76        .as_ref()
77        .ok_or(MigrationError::MissingPolicyField("add_member_policy"))?;
78    let remove_member = policy_set
79        .remove_member_policy
80        .as_ref()
81        .ok_or(MigrationError::MissingPolicyField("remove_member_policy"))?;
82    let add_admin = policy_set
83        .add_admin_policy
84        .as_ref()
85        .ok_or(MigrationError::MissingPolicyField("add_admin_policy"))?;
86    let remove_admin = policy_set
87        .remove_admin_policy
88        .as_ref()
89        .ok_or(MigrationError::MissingPolicyField("remove_admin_policy"))?;
90    policy_set
91        .update_permissions_policy
92        .as_ref()
93        .ok_or(MigrationError::MissingPolicyField(
94            "update_permissions_policy",
95        ))?;
96
97    // Fail fast on unknown `update_metadata_policy` keys before doing
98    // any work — silently dropping them would lose permission
99    // enforcement.
100    let known: std::collections::HashSet<&'static str> = metadata_field_registry_mapping()
101        .iter()
102        .map(|(f, _, _)| f.as_str())
103        .collect();
104    for key in policy_set.update_metadata_policy.keys() {
105        if !known.contains(key.as_str()) {
106            return Err(MigrationError::UnknownMetadataField(key.clone()));
107        }
108    }
109
110    // Mutable scalar components: insert/update from
111    // `update_metadata_policy[field]`; delete is always super-admin-only.
112    // Per-field `ComponentType` comes from the mapping — strings for
113    // free-form text, bytes for the BE-u64 disappearing-message
114    // timestamps.
115    //
116    // A field ABSENT from `update_metadata_policy` must default to what the
117    // legacy enforcer applied to a missing field — admin-only
118    // (`group_permissions.rs`: "default to admin only for fields with
119    // missing policies"), NOT `Allow`. The protocol-version floor defaults
120    // to super-admin instead, matching every preconfigured PolicySet
121    // (`default_map`/`default_policy`/`policy_admin_only`), so a group that
122    // predates the field can't be wedged by a non-super-admin raising the
123    // monotonic 0x800A floor. Present keys are carried verbatim, so a DM's
124    // stored `Allow` (from `dm_map`) is preserved rather than tightened.
125    for (field, component_id, component_type) in metadata_field_registry_mapping() {
126        let default_base = if matches!(field, MetadataField::MinimumSupportedProtocolVersion) {
127            MetadataBasePolicy::AllowIfSuperAdmin
128        } else {
129            MetadataBasePolicy::AllowIfAdmin
130        };
131        let policy = policy_set
132            .update_metadata_policy
133            .get(field.as_str())
134            .cloned()
135            .unwrap_or_else(|| metadata_policy(default_base));
136
137        registry.set(
138            *component_id,
139            new_component_metadata(
140                ComponentPermissions {
141                    insert_policy: Some(policy.clone()),
142                    update_policy: Some(policy),
143                    delete_policy: Some(metadata_policy(MetadataBasePolicy::AllowIfSuperAdmin)),
144                },
145                *component_type,
146            ),
147        )?;
148    }
149
150    // COMMIT_LOG_SIGNER: super-admin-only regardless of PolicySet
151    // shape. The field DOES change post-creation (see
152    // `Group::update_commit_log_signer`), but its enforcement on the
153    // legacy side is implicit: `_commit_log_signer` is never present
154    // in `update_metadata_policy` (neither `default_map` nor `dm_map`
155    // populates it — `supported_fields()` excludes `CommitLogSigner`),
156    // so the policy enforcer at `group_permissions.rs` falls through
157    // to the `_`-prefix super-admin-only path. We encode that
158    // implicit policy explicitly here. A malicious peer that *does*
159    // ship `_commit_log_signer` inside `update_metadata_policy` will
160    // surface as `UnknownMetadataField` above — fail-loud beats
161    // silently downgrading enforcement.
162    let super_admin = metadata_policy(MetadataBasePolicy::AllowIfSuperAdmin);
163    registry.set(
164        ComponentId::COMMIT_LOG_SIGNER,
165        new_component_metadata(
166            ComponentPermissions {
167                insert_policy: Some(super_admin.clone()),
168                update_policy: Some(super_admin.clone()),
169                delete_policy: Some(super_admin.clone()),
170            },
171            ComponentType::Bytes,
172        ),
173    )?;
174
175    // ADMIN_LIST (SetInboxId, constrained).
176    let admin_policy = admin_list_policy_to_metadata_policy(add_admin)?;
177    let remove_admin_policy = admin_list_policy_to_metadata_policy(remove_admin)?;
178    registry.set(
179        ComponentId::ADMIN_LIST,
180        new_component_metadata(
181            ComponentPermissions {
182                insert_policy: Some(admin_policy.clone()),
183                update_policy: Some(admin_policy),
184                delete_policy: Some(remove_admin_policy),
185            },
186            ComponentType::TlsSetInboxId,
187        ),
188    )?;
189
190    // GROUP_MEMBERSHIP (TlsMapInboxIdBytes).
191    registry.set(
192        ComponentId::GROUP_MEMBERSHIP,
193        new_component_metadata(
194            ComponentPermissions {
195                insert_policy: Some(membership_policy_to_metadata_policy(add_member)?),
196                update_policy: Some(metadata_policy(MetadataBasePolicy::Allow)),
197                delete_policy: Some(membership_policy_to_metadata_policy(remove_member)?),
198            },
199            ComponentType::TlsMapInboxIdBytes,
200        ),
201    )?;
202
203    // Immutable seeds: super-admin insert, deny update + delete.
204    // `DM_MEMBERS` and `ONESHOT_MESSAGE` are gated on the `include_*`
205    // flags — registering them for a group that doesn't have them
206    // would pin their absence forever (immutable entries can't be
207    // removed after write). Receiver-side synthesis gates them
208    // symmetrically so byte-compare always lines up.
209    let immutable_permissions = ComponentPermissions {
210        insert_policy: Some(metadata_policy(MetadataBasePolicy::AllowIfSuperAdmin)),
211        update_policy: Some(metadata_policy(MetadataBasePolicy::Deny)),
212        delete_policy: Some(metadata_policy(MetadataBasePolicy::Deny)),
213    };
214    for id in [
215        ComponentId::CONVERSATION_TYPE,
216        ComponentId::CREATOR_INBOX_ID,
217    ] {
218        registry.set(
219            id,
220            new_component_metadata(immutable_permissions.clone(), ComponentType::Bytes),
221        )?;
222    }
223    if include_oneshot_message {
224        registry.set(
225            ComponentId::ONESHOT_MESSAGE,
226            new_component_metadata(immutable_permissions.clone(), ComponentType::Bytes),
227        )?;
228    }
229    if include_dm_members {
230        registry.set(
231            ComponentId::DM_MEMBERS,
232            new_component_metadata(immutable_permissions, ComponentType::TlsSetInboxId),
233        )?;
234    }
235
236    Ok(registry)
237}
238
239/// List of (`MetadataField`, `ComponentId`, `ComponentType`) tuples the
240/// registry knows about. Kept in one place so synthesis and validation
241/// can iterate the same set.
242///
243/// The legacy `GroupMutableMetadata` stored every value as a `String`
244/// (with disappearing-message timestamps stringified via `to_string()`),
245/// but on the new component side the natural representation differs:
246/// the disappearing-message timestamps round-trip as big-endian `u64`
247/// bytes, while everything else is utf-8 text. Tagging the type here
248/// drives `new_component_metadata` to register the correct
249/// `ComponentType`.
250///
251/// The `(ComponentId, ComponentType)` pairs in this table must agree
252/// with the static dispatch table at
253/// [`super::registry_table::WELL_KNOWN`] — pinned by the unit test
254/// `metadata_field_mapping_agrees_with_dispatch_table` below.
255pub(crate) fn metadata_field_registry_mapping()
256-> &'static [(MetadataField, ComponentId, ComponentType)] {
257    &[
258        (
259            MetadataField::GroupName,
260            ComponentId::GROUP_NAME,
261            ComponentType::String,
262        ),
263        (
264            MetadataField::Description,
265            ComponentId::GROUP_DESCRIPTION,
266            ComponentType::String,
267        ),
268        (
269            MetadataField::GroupImageUrlSquare,
270            ComponentId::GROUP_IMAGE_URL,
271            ComponentType::String,
272        ),
273        (
274            MetadataField::MessageDisappearFromNS,
275            ComponentId::MESSAGE_DISAPPEAR_FROM_NS,
276            ComponentType::Bytes,
277        ),
278        (
279            MetadataField::MessageDisappearInNS,
280            ComponentId::MESSAGE_DISAPPEAR_IN_NS,
281            ComponentType::Bytes,
282        ),
283        (
284            MetadataField::AppData,
285            ComponentId::APP_DATA,
286            ComponentType::String,
287        ),
288        (
289            MetadataField::MinimumSupportedProtocolVersion,
290            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
291            ComponentType::String,
292        ),
293    ]
294}
295
296pub(crate) fn metadata_policy(base: MetadataBasePolicy) -> MetadataPolicyProto {
297    MetadataPolicyProto {
298        kind: Some(MetadataPolicyKind::Base(base as i32)),
299    }
300}
301
302/// Convert a legacy `add_admin_policy` / `remove_admin_policy` (typed
303/// as `PermissionsUpdatePolicy` on the wire) into the `MetadataPolicy`
304/// that gates `ADMIN_LIST` insert/update/delete on the new side.
305/// Only deny, admin, or super-admin base policies are allowed: combinators
306/// or any other base value would silently break the constrained-component
307/// check in [`ComponentRegistry::validate_metadata`].
308pub(crate) fn admin_list_policy_to_metadata_policy(
309    p: &PermissionsUpdatePolicyProto,
310) -> Result<MetadataPolicyProto, MigrationError> {
311    match &p.kind {
312        Some(PermissionsPolicyKind::Base(base)) => match PermissionsBasePolicy::try_from(*base) {
313            Ok(PermissionsBasePolicy::AllowIfAdmin) => {
314                Ok(metadata_policy(MetadataBasePolicy::AllowIfAdmin))
315            }
316            Ok(PermissionsBasePolicy::AllowIfSuperAdmin) => {
317                Ok(metadata_policy(MetadataBasePolicy::AllowIfSuperAdmin))
318            }
319            Ok(PermissionsBasePolicy::Deny) => Ok(metadata_policy(MetadataBasePolicy::Deny)),
320            _ => Err(MigrationError::NonConstrainedAdminPolicy(Some(*base))),
321        },
322        Some(PermissionsPolicyKind::AndCondition(_))
323        | Some(PermissionsPolicyKind::AnyCondition(_))
324        | None => Err(MigrationError::NonConstrainedAdminPolicy(None)),
325    }
326}
327
328/// Convert a legacy `MembershipPolicy` to a `MetadataPolicy`.
329/// `AllowIfAdminOrSuperAdmin` collapses to `AllowIfAdmin` because
330/// `MetadataPolicy::AllowIfAdmin` already means "admin or super admin".
331/// Combinators map recursively so the registry preserves their legacy
332/// meaning. Unknown base values fail loud rather than silently collapsing
333/// to Deny.
334pub(crate) fn membership_policy_to_metadata_policy(
335    p: &MembershipPolicyProto,
336) -> Result<MetadataPolicyProto, MigrationError> {
337    match &p.kind {
338        Some(MembershipPolicyKind::Base(base)) => {
339            let mapped = match MembershipBasePolicy::try_from(*base) {
340                Ok(MembershipBasePolicy::Allow) => MetadataBasePolicy::Allow,
341                Ok(MembershipBasePolicy::Deny) => MetadataBasePolicy::Deny,
342                Ok(MembershipBasePolicy::AllowIfAdminOrSuperAdmin) => {
343                    MetadataBasePolicy::AllowIfAdmin
344                }
345                Ok(MembershipBasePolicy::AllowIfSuperAdmin) => {
346                    MetadataBasePolicy::AllowIfSuperAdmin
347                }
348                _ => return Err(MigrationError::UnknownMembershipPolicy(Some(*base))),
349            };
350            Ok(metadata_policy(mapped))
351        }
352        Some(MembershipPolicyKind::AndCondition(condition)) if !condition.policies.is_empty() => {
353            Ok(MetadataPolicyProto {
354                kind: Some(MetadataPolicyKind::AndCondition(MetadataAndCondition {
355                    policies: condition
356                        .policies
357                        .iter()
358                        .map(membership_policy_to_metadata_policy)
359                        .collect::<Result<_, _>>()?,
360                })),
361            })
362        }
363        Some(MembershipPolicyKind::AnyCondition(condition)) if !condition.policies.is_empty() => {
364            Ok(MetadataPolicyProto {
365                kind: Some(MetadataPolicyKind::AnyCondition(MetadataAnyCondition {
366                    policies: condition
367                        .policies
368                        .iter()
369                        .map(membership_policy_to_metadata_policy)
370                        .collect::<Result<_, _>>()?,
371                })),
372            })
373        }
374        _ => Err(MigrationError::UnknownMembershipPolicy(None)),
375    }
376}
377
378/// Translate a legacy `GroupMutableMetadata` attribute string into the
379/// AppData wire bytes for that component.
380///
381/// - `MESSAGE_DISAPPEAR_FROM_NS` / `MESSAGE_DISAPPEAR_IN_NS`: the legacy
382///   attribute is a decimal-stringified `i64`; emit 8 big-endian bytes.
383/// - String-typed attributes (group name/description/url/app data/min
384///   version): emit the raw UTF-8 bytes unchanged.
385pub fn encode_metadata_attribute_value(
386    component_id: ComponentId,
387    legacy_value: &str,
388) -> Result<Vec<u8>, MigrationError> {
389    match component_id {
390        ComponentId::COMMIT_LOG_SIGNER => {
391            let raw = hex::decode(legacy_value).map_err(|error| {
392                MigrationError::InvalidCommitLogSignerHex {
393                    reason: error.to_string(),
394                }
395            })?;
396            let expected = xmtp_cryptography::configuration::ED25519_KEY_LENGTH;
397            if raw.len() != expected {
398                return Err(MigrationError::InvalidCommitLogSignerLength {
399                    expected,
400                    actual: raw.len(),
401                });
402            }
403            Ok(raw)
404        }
405        ComponentId::MESSAGE_DISAPPEAR_FROM_NS => {
406            parse_disappearing_i64("messageDisappearFromNS", legacy_value)
407        }
408        ComponentId::MESSAGE_DISAPPEAR_IN_NS => {
409            parse_disappearing_i64("messageDisappearInNS", legacy_value)
410        }
411        _ => Ok(legacy_value.as_bytes().to_vec()),
412    }
413}
414
415pub(crate) fn parse_disappearing_i64(
416    field: &'static str,
417    legacy_value: &str,
418) -> Result<Vec<u8>, MigrationError> {
419    let n: i64 = legacy_value
420        .parse()
421        .map_err(
422            |err: std::num::ParseIntError| MigrationError::InvalidDisappearingTimestamp {
423                field,
424                value: legacy_value.to_string(),
425                reason: err.to_string(),
426            },
427        )?;
428    Ok(n.to_be_bytes().to_vec())
429}
430
431/// Encode hex inbox ids as a `TlsSet<InboxId>`. Must stay byte-identical
432/// to the bridge's `encode_inbox_id_set` or byte-compare validation
433/// fails.
434/// Encode an inbox-id set as a **bootstrap wire delta**: a
435/// `TlsSetDelta<InboxId>` of all-`Insert` mutations. The wire is
436/// always a delta; bootstrap is the case where the prior set is
437/// empty, so every mutation is an `Insert`. The dict stores the
438/// materialized `TlsSet` snapshot; receivers translate wire → dict
439/// via [`apply_wire_bytes`].
440pub(crate) fn encode_inbox_id_set(inbox_ids_hex: &[String]) -> Result<Vec<u8>, MigrationError> {
441    let ids: Vec<InboxId> = inbox_ids_hex
442        .iter()
443        .map(|s| InboxId::from_hex(s))
444        .collect::<Result<Vec<_>, _>>()?;
445    // Sort so the wire bytes are deterministic across senders. The
446    // canonical-subset validator byte-compares this output against
447    // the actual proposal, so non-determinism here would let an
448    // honest sender's payload mismatch the validator's expectation.
449    let mut sorted_ids: Vec<InboxId> = ids;
450    sorted_ids.sort();
451    sorted_ids.dedup();
452    let mut delta: TlsSetDelta<InboxId> = TlsSetDelta::new();
453    for id in sorted_ids {
454        delta = delta.insert(id);
455    }
456    Ok(delta.tls_serialize_detached()?)
457}
458
459/// Encode the DM's two members as a **bootstrap wire delta**: a
460/// `TlsSetDelta<InboxId>` of all-`Insert` mutations. (DM_MEMBERS is
461/// declared as `ComponentType::TlsSetInboxId`; the dict stores the
462/// materialized `TlsSet` snapshot.)
463pub(crate) fn encode_dm_members(
464    dm: &crate::group_metadata::DmMembers<xmtp_id::InboxId>,
465) -> Result<Vec<u8>, MigrationError> {
466    // Decode first, then compare on InboxId — hex strings can differ
467    // only in case ("ABC..." vs "abc...") and still represent the same
468    // inbox id. Self-DMs would otherwise slip past a string-compare
469    // and `TlsSet` would silently collapse to one element, losing
470    // fidelity.
471    let one_str: &str = dm.member_one_inbox_id.as_ref();
472    let two_str: &str = dm.member_two_inbox_id.as_ref();
473    let one = InboxId::from_hex(one_str)?;
474    let two = InboxId::from_hex(two_str)?;
475    if one == two {
476        // Include both raw inputs so case-divergent self-references
477        // ("ABC..." vs "abc...") are visible in logs without having
478        // to reproduce.
479        return Err(MigrationError::DmMembersSelfReference(format!(
480            "{} (member_one={one_str}, member_two={two_str})",
481            one.to_hex(),
482        )));
483    }
484    // Sort for deterministic wire bytes (validator byte-compare).
485    let (a, b) = if one <= two { (one, two) } else { (two, one) };
486    let delta = TlsSetDelta::<InboxId>::new().insert(a).insert(b);
487    Ok(delta.tls_serialize_detached()?)
488}
489
490/// `CONVERSATION_TYPE` payload codec: 4-byte big-endian `i32` matching
491/// `xmtp_proto::xmtp::mls::message_contents::ConversationType`.
492/// Fixed-width simplifies byte-compare validation.
493pub(crate) fn encode_conversation_type(value: i32) -> Vec<u8> {
494    value.to_be_bytes().to_vec()
495}
496
497/// Inverse of [`encode_conversation_type`]. Test-only by design: the
498/// receiver-side validator byte-compares the sender's CONVERSATION_TYPE
499/// payload against [`encode_conversation_type`]'s output without ever
500/// decoding it — equal bytes are semantically equal because the codec
501/// is fixed-width. Decode is only needed to round-trip-test the codec
502/// itself, so it stays gated behind `#[cfg(test)]` rather than leaking
503/// into production callers that might be tempted to re-decode (and
504/// then have to handle a wrong-length error path that the validator
505/// already rules out via byte-compare).
506#[cfg(test)]
507pub(crate) fn decode_conversation_type(bytes: &[u8]) -> Result<i32, MigrationError> {
508    let arr: [u8; 4] = bytes
509        .try_into()
510        .map_err(|_| MigrationError::ConversationTypePayloadLength(bytes.len()))?;
511    Ok(i32::from_be_bytes(arr))
512}
513
514/// The registry entries a conversation takes from a backend catalogue: each
515/// valid definition selected for `conversation_type` (`in_groups` for a
516/// group, `in_dms` for a DM, none for a sync group) whose type and policy
517/// tags this build knows.
518///
519/// A snapshot an app supplies through a `ConfigProvider` is never validated
520/// as a whole, so only the first definition of each ID is considered, and an
521/// invalid one is skipped. Otherwise a repeated immutable ID would fail
522/// creation, and a well-known ID would replace that component's policies.
523///
524/// A definition with an unknown tag is skipped, not registered: this client
525/// could not write or validate its values, and leaving the ID absent lets a
526/// client that knows the tags register it in a later commit.
527// implements: META-066, META-067
528pub fn catalogue_registry_entries(
529    catalogue: &[ApplicationComponentDefinition],
530    conversation_type: ConversationType,
531) -> impl Iterator<Item = (ComponentId, ComponentMetadata)> + '_ {
532    let mut seen = std::collections::HashSet::new();
533    catalogue
534        .iter()
535        .filter(move |definition| seen.insert(definition.component_id))
536        .filter(|definition| definition.validate().is_ok())
537        .filter(move |definition| match conversation_type {
538            ConversationType::Dm => definition.in_dms,
539            ConversationType::Sync => false,
540            ConversationType::Group | ConversationType::Oneshot => definition.in_groups,
541        })
542        .map(|definition| {
543            (
544                ComponentId::new(definition.component_id),
545                ComponentMetadata {
546                    permissions: Some(definition.permissions.clone().into()),
547                    component_type: definition.component_type,
548                    external_committer_permissions: None,
549                },
550            )
551        })
552        .filter(|(_, metadata)| is_known_metadata(metadata))
553}
554
555/// True when this build knows the entry's component type and every policy
556/// tag in its three policies.
557fn is_known_metadata(metadata: &ComponentMetadata) -> bool {
558    fn is_known(policy: &MetadataPolicyProto) -> bool {
559        match &policy.kind {
560            Some(MetadataPolicyKind::Base(base)) => MetadataBasePolicy::try_from(*base)
561                .is_ok_and(|base| base != MetadataBasePolicy::Unspecified),
562            Some(MetadataPolicyKind::AndCondition(MetadataAndCondition { policies }))
563            | Some(MetadataPolicyKind::AnyCondition(MetadataAnyCondition { policies })) => {
564                !policies.is_empty() && policies.iter().all(is_known)
565            }
566            None => false,
567        }
568    }
569    ComponentType::try_from(metadata.component_type)
570        .is_ok_and(|component_type| component_type != ComponentType::Unspecified)
571        && metadata.permissions.as_ref().is_some_and(|permissions| {
572            [
573                &permissions.insert_policy,
574                &permissions.update_policy,
575                &permissions.delete_policy,
576            ]
577            .into_iter()
578            .all(|policy| policy.as_ref().is_some_and(is_known))
579        })
580}
581
582/// Add the entries every non-sync conversation registers at creation:
583/// `USER_DISPLAY_NAME`, `GROUP_IMAGE` for a group, and the eligible catalogue
584/// definitions. `GROUP_IMAGE` copies the `GROUP_IMAGE_URL` policies that
585/// `build_registry` selected from the policy set.
586// implements: META-066, PERM-029
587fn register_configured_fields(
588    registry: &mut ComponentRegistry,
589    conversation_type: ConversationType,
590    catalogue: &[ApplicationComponentDefinition],
591) -> Result<(), MigrationError> {
592    if conversation_type == ConversationType::Sync {
593        return Ok(());
594    }
595    let self_owned = metadata_policy(MetadataBasePolicy::AllowIfSelfOrNonMember);
596    registry.set(
597        ComponentId::USER_DISPLAY_NAME,
598        new_component_metadata(
599            ComponentPermissions {
600                insert_policy: Some(self_owned.clone()),
601                update_policy: Some(self_owned.clone()),
602                delete_policy: Some(self_owned),
603            },
604            ComponentType::TlsMapInboxIdString,
605        ),
606    )?;
607    if conversation_type != ConversationType::Dm {
608        let image_url = registry.get(&ComponentId::GROUP_IMAGE_URL)?.ok_or(
609            ComponentRegistryError::NotFound(ComponentId::GROUP_IMAGE_URL),
610        )?;
611        registry.set(
612            ComponentId::GROUP_IMAGE,
613            ComponentMetadata {
614                component_type: ComponentType::Bytes as i32,
615                ..image_url
616            },
617        )?;
618    }
619    catalogue_registry_entries(catalogue, conversation_type)
620        .try_for_each(|(id, metadata)| registry.set(id, metadata))?;
621    Ok(())
622}
623
624/// The immutable values for a new conversation.
625pub enum InitialGroupKind<'a> {
626    /// A group, including sync and oneshot groups.
627    Group {
628        conversation_type: xmtp_proto::types::ConversationType,
629        oneshot_message: Option<&'a xmtp_proto::xmtp::mls::message_contents::OneshotMessage>,
630    },
631    /// A DM between the creator and this inbox.
632    Dm { target_inbox_id: &'a str },
633    /// An archive placeholder whose historical pair can exclude this client.
634    RestoredDm {
635        member_one_inbox_id: &'a str,
636        member_two_inbox_id: &'a str,
637    },
638}
639
640/// Build the complete dictionary stored in epoch zero.
641/// Collection values are stored snapshots, not proposal deltas.
642/// `catalogue` is the client's backend snapshot; only its eligible
643/// definitions enter the registry, and no component value is taken from it.
644// implements: META-018, PERM-002
645pub fn initial_dictionary(
646    kind: InitialGroupKind<'_>,
647    policy_set: &PolicySetProto,
648    opts: &crate::group::GroupMetadataOptions,
649    creator_inbox_id: &str,
650    commit_log_signer: Option<&[u8]>,
651    catalogue: &[ApplicationComponentDefinition],
652) -> Result<openmls::extensions::AppDataDictionary, MigrationError> {
653    use crate::tls_set::TlsSet;
654    use openmls::extensions::AppDataDictionary;
655    use prost::Message as _;
656    use std::collections::BTreeMap;
657    use xmtp_proto::xmtp::mls::message_contents::{
658        GroupMembershipEntry,
659        group_membership_entry::{V1, Version},
660    };
661
662    let creator = InboxId::from_hex(creator_inbox_id)?;
663    let (conversation_type, dm_members, oneshot_message) = match kind {
664        InitialGroupKind::Group {
665            conversation_type,
666            oneshot_message,
667        } => (conversation_type, None, oneshot_message),
668        InitialGroupKind::Dm { target_inbox_id } => {
669            let target = InboxId::from_hex(target_inbox_id)?;
670            if target == creator {
671                return Err(MigrationError::DmMembersSelfReference(creator.to_hex()));
672            }
673            (ConversationType::Dm, Some((creator, target)), None)
674        }
675        InitialGroupKind::RestoredDm {
676            member_one_inbox_id,
677            member_two_inbox_id,
678        } => {
679            let first = InboxId::from_hex(member_one_inbox_id)?;
680            let second = InboxId::from_hex(member_two_inbox_id)?;
681            if first == second {
682                return Err(MigrationError::DmMembersSelfReference(first.to_hex()));
683            }
684            (ConversationType::Dm, Some((first, second)), None)
685        }
686    };
687    let mut registry = build_registry(policy_set, dm_members.is_some(), oneshot_message.is_some())?;
688    register_configured_fields(&mut registry, conversation_type, catalogue)?;
689    let mut dictionary = AppDataDictionary::new();
690    dictionary.insert(
691        ComponentId::COMPONENT_REGISTRY.as_u16(),
692        registry.to_bytes()?,
693    );
694    let empty = TlsSet::<InboxId>::new().tls_serialize_detached()?;
695    dictionary.insert(ComponentId::ADMIN_LIST.as_u16(), empty.clone());
696    let super_admins = if dm_members.is_some() {
697        empty
698    } else {
699        [creator]
700            .into_iter()
701            .collect::<TlsSet<_>>()
702            .tls_serialize_detached()?
703    };
704    dictionary.insert(ComponentId::SUPER_ADMIN_LIST.as_u16(), super_admins);
705    let membership = BTreeMap::from([(
706        creator,
707        GroupMembershipEntry {
708            version: Some(Version::V1(V1 {
709                sequence_id: 0,
710                failed_installations: vec![],
711            })),
712        },
713    )]);
714    dictionary.insert(
715        ComponentId::GROUP_MEMBERSHIP.as_u16(),
716        super::migration::encode_group_membership_dict(&membership)?,
717    );
718    for (id, value) in [
719        (ComponentId::GROUP_NAME, opts.name.as_ref()),
720        (ComponentId::GROUP_DESCRIPTION, opts.description.as_ref()),
721        (ComponentId::GROUP_IMAGE_URL, opts.image_url_square.as_ref()),
722        (ComponentId::APP_DATA, opts.app_data.as_ref()),
723    ] {
724        if let Some(value) = value {
725            dictionary.insert(id.as_u16(), encode_metadata_attribute_value(id, value)?);
726        }
727    }
728    if let Some(settings) = &opts.message_disappearing_settings {
729        dictionary.insert(
730            ComponentId::MESSAGE_DISAPPEAR_FROM_NS.as_u16(),
731            settings.from_ns.to_be_bytes().to_vec(),
732        );
733        dictionary.insert(
734            ComponentId::MESSAGE_DISAPPEAR_IN_NS.as_u16(),
735            settings.in_ns.to_be_bytes().to_vec(),
736        );
737    }
738    if let Some(signer) = commit_log_signer {
739        let expected = xmtp_cryptography::configuration::ED25519_KEY_LENGTH;
740        if signer.len() != expected {
741            return Err(MigrationError::InvalidCommitLogSignerLength {
742                expected,
743                actual: signer.len(),
744            });
745        }
746        dictionary.insert(ComponentId::COMMIT_LOG_SIGNER.as_u16(), signer.to_vec());
747    }
748    dictionary.insert(
749        ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16(),
750        xmtp_configuration::PROPOSALS_MIN_PROTOCOL_VERSION
751            .as_bytes()
752            .to_vec(),
753    );
754    let conversation_type: xmtp_proto::xmtp::mls::message_contents::ConversationType =
755        conversation_type.into();
756    dictionary.insert(
757        ComponentId::CONVERSATION_TYPE.as_u16(),
758        encode_conversation_type(conversation_type as i32),
759    );
760    dictionary.insert(
761        ComponentId::CREATOR_INBOX_ID.as_u16(),
762        creator.tls_serialize_detached()?,
763    );
764    if let Some((first, second)) = dm_members {
765        let members: TlsSet<_> = [first, second].into_iter().collect();
766        dictionary.insert(
767            ComponentId::DM_MEMBERS.as_u16(),
768            members.tls_serialize_detached()?,
769        );
770    }
771    if let Some(message) = oneshot_message {
772        dictionary.insert(
773            ComponentId::ONESHOT_MESSAGE.as_u16(),
774            message.encode_to_vec(),
775        );
776    }
777    Ok(dictionary)
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use prost::Message as _;
784    use tls_codec::Serialize;
785    use xmtp_proto::{
786        types::ConversationType,
787        xmtp::mls::message_contents::{
788            MembershipPolicy, PermissionsUpdatePolicy,
789            membership_policy::{BasePolicy as MembershipBasePolicy, Kind as MembershipPolicyKind},
790            permissions_update_policy::{Kind as PermissionsPolicyKind, PermissionsBasePolicy},
791        },
792    };
793
794    fn policy_set() -> PolicySetProto {
795        let membership = MembershipPolicy {
796            kind: Some(MembershipPolicyKind::Base(
797                MembershipBasePolicy::Allow as i32,
798            )),
799        };
800        let admin = PermissionsUpdatePolicy {
801            kind: Some(PermissionsPolicyKind::Base(
802                PermissionsBasePolicy::AllowIfAdmin as i32,
803            )),
804        };
805        let super_admin = PermissionsUpdatePolicy {
806            kind: Some(PermissionsPolicyKind::Base(
807                PermissionsBasePolicy::AllowIfSuperAdmin as i32,
808            )),
809        };
810        PolicySetProto {
811            add_member_policy: Some(membership.clone()),
812            remove_member_policy: Some(membership),
813            update_metadata_policy: Default::default(),
814            add_admin_policy: Some(admin.clone()),
815            remove_admin_policy: Some(admin),
816            update_permissions_policy: Some(super_admin),
817        }
818    }
819
820    fn inbox(byte: u8) -> String {
821        hex::encode([byte; 32])
822    }
823
824    #[xmtp_common::test(unwrap_try = true)]
825    // verifies: META-018, PERM-002
826    fn initial_group_dictionary_seeds_required_and_optional_values() {
827        let creator = inbox(1);
828        let options = crate::group::GroupMetadataOptions {
829            name: Some("name".into()),
830            description: Some("description".into()),
831            image_url_square: Some("image".into()),
832            app_data: Some("app data".into()),
833            message_disappearing_settings: Some(
834                crate::group_mutable_metadata::MessageDisappearingSettings::new(7, 9),
835            ),
836        };
837        let oneshot = xmtp_proto::xmtp::mls::message_contents::OneshotMessage::default();
838        let signer = [3; xmtp_cryptography::configuration::ED25519_KEY_LENGTH];
839        let dictionary = initial_dictionary(
840            InitialGroupKind::Group {
841                conversation_type: ConversationType::Group,
842                oneshot_message: Some(&oneshot),
843            },
844            &policy_set(),
845            &options,
846            &creator,
847            Some(&signer),
848            &[],
849        )?;
850
851        let creator_id = InboxId::from_hex(&creator)?;
852        let empty_admins = crate::tls_set::TlsSet::<InboxId>::new().tls_serialize_detached()?;
853        assert_eq!(
854            dictionary.get(&ComponentId::ADMIN_LIST.as_u16()),
855            Some(empty_admins.as_slice())
856        );
857        let super_admins = [creator_id]
858            .into_iter()
859            .collect::<crate::tls_set::TlsSet<_>>()
860            .tls_serialize_detached()?;
861        assert_eq!(
862            dictionary.get(&ComponentId::SUPER_ADMIN_LIST.as_u16()),
863            Some(super_admins.as_slice())
864        );
865        let membership = super::super::migration::decode_group_membership_dict(
866            dictionary
867                .get(&ComponentId::GROUP_MEMBERSHIP.as_u16())
868                .unwrap(),
869        )?;
870        assert!(matches!(
871            membership.get(&creator_id).unwrap().version,
872            Some(xmtp_proto::xmtp::mls::message_contents::group_membership_entry::Version::V1(ref entry))
873                if entry.sequence_id == 0 && entry.failed_installations.is_empty()
874        ));
875        for (id, value) in [
876            (ComponentId::GROUP_NAME, b"name".as_slice()),
877            (ComponentId::GROUP_DESCRIPTION, b"description".as_slice()),
878            (ComponentId::GROUP_IMAGE_URL, b"image".as_slice()),
879            (ComponentId::APP_DATA, b"app data".as_slice()),
880            (ComponentId::MESSAGE_DISAPPEAR_FROM_NS, &7_i64.to_be_bytes()),
881            (ComponentId::MESSAGE_DISAPPEAR_IN_NS, &9_i64.to_be_bytes()),
882        ] {
883            assert_eq!(dictionary.get(&id.as_u16()), Some(value));
884        }
885        assert_eq!(
886            dictionary.get(&ComponentId::COMMIT_LOG_SIGNER.as_u16()),
887            Some(signer.as_slice())
888        );
889        assert_eq!(
890            dictionary.get(&ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16()),
891            Some(xmtp_configuration::PROPOSALS_MIN_PROTOCOL_VERSION.as_bytes())
892        );
893        let conversation_type = encode_conversation_type(
894            xmtp_proto::xmtp::mls::message_contents::ConversationType::Group as i32,
895        );
896        assert_eq!(
897            dictionary.get(&ComponentId::CONVERSATION_TYPE.as_u16()),
898            Some(conversation_type.as_slice())
899        );
900        let serialized_creator_id = creator_id.tls_serialize_detached()?;
901        assert_eq!(
902            dictionary.get(&ComponentId::CREATOR_INBOX_ID.as_u16()),
903            Some(serialized_creator_id.as_slice())
904        );
905        let encoded_oneshot = oneshot.encode_to_vec();
906        assert_eq!(
907            dictionary.get(&ComponentId::ONESHOT_MESSAGE.as_u16()),
908            Some(encoded_oneshot.as_slice())
909        );
910    }
911
912    #[xmtp_common::test(unwrap_try = true)]
913    // verifies: META-018, DMS-002
914    fn initial_dm_dictionary_has_empty_admin_sets_and_dm_members() {
915        let creator = inbox(1);
916        let target = inbox(2);
917        let dictionary = initial_dictionary(
918            InitialGroupKind::Dm {
919                target_inbox_id: &target,
920            },
921            &policy_set(),
922            &crate::group::GroupMetadataOptions::default(),
923            &creator,
924            None,
925            &[],
926        )?;
927
928        let creator_id = InboxId::from_hex(&creator)?;
929        let target_id = InboxId::from_hex(&target)?;
930        let empty = crate::tls_set::TlsSet::<InboxId>::new().tls_serialize_detached()?;
931        assert_eq!(
932            dictionary.get(&ComponentId::ADMIN_LIST.as_u16()),
933            Some(empty.as_slice())
934        );
935        assert_eq!(
936            dictionary.get(&ComponentId::SUPER_ADMIN_LIST.as_u16()),
937            Some(empty.as_slice())
938        );
939        let dm_members = [creator_id, target_id]
940            .into_iter()
941            .collect::<crate::tls_set::TlsSet<_>>()
942            .tls_serialize_detached()?;
943        assert_eq!(
944            dictionary.get(&ComponentId::DM_MEMBERS.as_u16()),
945            Some(dm_members.as_slice())
946        );
947        let conversation_type = encode_conversation_type(
948            xmtp_proto::xmtp::mls::message_contents::ConversationType::Dm as i32,
949        );
950        assert_eq!(
951            dictionary.get(&ComponentId::CONVERSATION_TYPE.as_u16()),
952            Some(conversation_type.as_slice())
953        );
954        let serialized_creator_id = creator_id.tls_serialize_detached()?;
955        assert_eq!(
956            dictionary.get(&ComponentId::CREATOR_INBOX_ID.as_u16()),
957            Some(serialized_creator_id.as_slice())
958        );
959        for id in [
960            ComponentId::GROUP_NAME,
961            ComponentId::GROUP_DESCRIPTION,
962            ComponentId::GROUP_IMAGE_URL,
963            ComponentId::APP_DATA,
964            ComponentId::MESSAGE_DISAPPEAR_FROM_NS,
965            ComponentId::MESSAGE_DISAPPEAR_IN_NS,
966            ComponentId::COMMIT_LOG_SIGNER,
967            ComponentId::ONESHOT_MESSAGE,
968        ] {
969            assert!(!dictionary.contains(&id.as_u16()));
970        }
971    }
972
973    fn definition(
974        component_id: u16,
975        in_groups: bool,
976        in_dms: bool,
977    ) -> ApplicationComponentDefinition {
978        let policy =
979            xmtp_configuration::MetadataPolicy::Base(MetadataBasePolicy::AllowIfAdmin as i32);
980        ApplicationComponentDefinition {
981            component_id,
982            name: format!("field_{component_id:x}"),
983            component_type: ComponentType::String as i32,
984            permissions: xmtp_configuration::ComponentPermissions {
985                insert: Some(policy.clone()),
986                update: Some(policy.clone()),
987                delete: Some(policy),
988            },
989            in_groups,
990            in_dms,
991        }
992    }
993
994    fn registry_of(dictionary: &openmls::extensions::AppDataDictionary) -> ComponentRegistry {
995        ComponentRegistry::from_bytes(
996            dictionary
997                .get(&ComponentId::COMPONENT_REGISTRY.as_u16())
998                .unwrap(),
999        )
1000        .unwrap()
1001    }
1002
1003    fn group_dictionary(
1004        conversation_type: ConversationType,
1005        policy_set: &PolicySetProto,
1006        catalogue: &[ApplicationComponentDefinition],
1007    ) -> openmls::extensions::AppDataDictionary {
1008        initial_dictionary(
1009            InitialGroupKind::Group {
1010                conversation_type,
1011                oneshot_message: None,
1012            },
1013            policy_set,
1014            &crate::group::GroupMetadataOptions::default(),
1015            &inbox(1),
1016            None,
1017            catalogue,
1018        )
1019        .unwrap()
1020    }
1021
1022    fn self_owned() -> ComponentPermissions {
1023        let policy = metadata_policy(MetadataBasePolicy::AllowIfSelfOrNonMember);
1024        ComponentPermissions {
1025            insert_policy: Some(policy.clone()),
1026            update_policy: Some(policy.clone()),
1027            delete_policy: Some(policy),
1028        }
1029    }
1030
1031    /// A new group registers the catalogue's group definitions with their
1032    /// own type and policies, `USER_DISPLAY_NAME` as a self-owned string
1033    /// map, and `GROUP_IMAGE` as bytes, and writes no value for any of them.
1034    /// A DM-only definition stays out of a group.
1035    #[xmtp_common::test(unwrap_try = true)]
1036    // verifies: META-066, PERM-029
1037    fn new_group_registers_configured_fields_without_values() {
1038        let catalogue = [
1039            definition(0xC000, true, false),
1040            definition(0xC001, false, true),
1041            definition(0xFD00, true, true),
1042        ];
1043        let dictionary = group_dictionary(ConversationType::Group, &policy_set(), &catalogue);
1044        let registry = registry_of(&dictionary);
1045
1046        for id in [0xC000, 0xFD00] {
1047            let expected = &catalogue.iter().find(|d| d.component_id == id).unwrap();
1048            let entry = registry.get(&ComponentId::new(id))?.unwrap();
1049            assert_eq!(entry.component_type, ComponentType::String as i32);
1050            assert_eq!(entry.permissions, Some(expected.permissions.clone().into()));
1051        }
1052        assert!(!registry.contains_raw(&ComponentId::new(0xC001)));
1053        assert_eq!(
1054            registry.get(&ComponentId::USER_DISPLAY_NAME)?,
1055            Some(new_component_metadata(
1056                self_owned(),
1057                ComponentType::TlsMapInboxIdString
1058            ))
1059        );
1060        let image = registry.get(&ComponentId::GROUP_IMAGE)?.unwrap();
1061        assert_eq!(image.component_type, ComponentType::Bytes as i32);
1062        for id in [
1063            ComponentId::new(0xC000),
1064            ComponentId::new(0xFD00),
1065            ComponentId::USER_DISPLAY_NAME,
1066            ComponentId::GROUP_IMAGE,
1067        ] {
1068            assert!(!dictionary.contains(&id.as_u16()));
1069        }
1070    }
1071
1072    /// `GROUP_IMAGE` takes the image URL policies chosen at creation, so a
1073    /// group that lets members change its image URL lets them change its
1074    /// image, and the delete policy stays super admin.
1075    #[xmtp_common::test(unwrap_try = true)]
1076    // verifies: PERM-029
1077    fn group_image_copies_the_image_url_policies() {
1078        let mut policies = policy_set();
1079        policies.update_metadata_policy.insert(
1080            MetadataField::GroupImageUrlSquare.as_str().to_string(),
1081            metadata_policy(MetadataBasePolicy::Allow),
1082        );
1083        let registry = registry_of(&group_dictionary(ConversationType::Group, &policies, &[]));
1084        let image_url = registry.get(&ComponentId::GROUP_IMAGE_URL)?.unwrap();
1085        let image = registry.get(&ComponentId::GROUP_IMAGE)?.unwrap();
1086        assert_eq!(image.permissions, image_url.permissions);
1087        let permissions = image.permissions.unwrap();
1088        assert_eq!(
1089            permissions.update_policy,
1090            Some(metadata_policy(MetadataBasePolicy::Allow))
1091        );
1092        assert_eq!(
1093            permissions.delete_policy,
1094            Some(metadata_policy(MetadataBasePolicy::AllowIfSuperAdmin))
1095        );
1096    }
1097
1098    /// A DM registers its DM definitions and `USER_DISPLAY_NAME`, but not
1099    /// `GROUP_IMAGE` or a group-only definition.
1100    #[xmtp_common::test(unwrap_try = true)]
1101    // verifies: META-066, PERM-029
1102    fn new_dm_registers_dm_fields_only() {
1103        let catalogue = [
1104            definition(0xC000, true, false),
1105            definition(0xC001, false, true),
1106        ];
1107        let dictionary = initial_dictionary(
1108            InitialGroupKind::Dm {
1109                target_inbox_id: &inbox(2),
1110            },
1111            &policy_set(),
1112            &crate::group::GroupMetadataOptions::default(),
1113            &inbox(1),
1114            None,
1115            &catalogue,
1116        )?;
1117        let registry = registry_of(&dictionary);
1118        assert!(registry.contains(&ComponentId::new(0xC001)));
1119        assert!(!registry.contains_raw(&ComponentId::new(0xC000)));
1120        assert!(!registry.contains_raw(&ComponentId::GROUP_IMAGE));
1121        assert_eq!(
1122            registry.get(&ComponentId::USER_DISPLAY_NAME)?,
1123            Some(new_component_metadata(
1124                self_owned(),
1125                ComponentType::TlsMapInboxIdString
1126            ))
1127        );
1128        assert!(!dictionary.contains(&ComponentId::USER_DISPLAY_NAME.as_u16()));
1129    }
1130
1131    /// A sync group carries no user or application fields, whatever the
1132    /// catalogue says.
1133    #[xmtp_common::test(unwrap_try = true)]
1134    // verifies: META-066
1135    fn sync_group_registers_no_configured_fields() {
1136        let catalogue = [definition(0xC000, true, true)];
1137        let registry = registry_of(&group_dictionary(
1138            ConversationType::Sync,
1139            &policy_set(),
1140            &catalogue,
1141        ));
1142        for id in [
1143            ComponentId::new(0xC000),
1144            ComponentId::USER_DISPLAY_NAME,
1145            ComponentId::GROUP_IMAGE,
1146        ] {
1147            assert!(!registry.contains_raw(&id));
1148        }
1149    }
1150
1151    /// A catalogue definition outside the application range never reaches
1152    /// the registry, even from a snapshot no one validated: one that named
1153    /// `GROUP_MEMBERSHIP` would otherwise replace its admin-only policies.
1154    #[xmtp_common::test(unwrap_try = true)]
1155    // verifies: META-066
1156    fn definitions_outside_the_application_range_are_skipped() {
1157        use xmtp_configuration::MetadataPolicy;
1158        let mut membership = definition(ComponentId::GROUP_MEMBERSHIP.as_u16(), true, true);
1159        let allow = Some(MetadataPolicy::Base(MetadataBasePolicy::Allow as i32));
1160        membership.permissions.insert = allow.clone();
1161        membership.permissions.update = allow.clone();
1162        membership.permissions.delete = allow;
1163        let expected = registry_of(&group_dictionary(
1164            ConversationType::Group,
1165            &policy_set(),
1166            &[],
1167        ));
1168        let registry = registry_of(&group_dictionary(
1169            ConversationType::Group,
1170            &policy_set(),
1171            &[membership.clone()],
1172        ));
1173        assert_eq!(
1174            registry.get(&ComponentId::GROUP_MEMBERSHIP)?,
1175            expected.get(&ComponentId::GROUP_MEMBERSHIP)?
1176        );
1177        assert_eq!(
1178            catalogue_registry_entries(&[membership], ConversationType::Group).count(),
1179            0
1180        );
1181    }
1182
1183    /// Only the first definition of a repeated ID is registered, whether or
1184    /// not the ID is immutable, so an unvalidated snapshot neither fails
1185    /// creation nor lets a later duplicate replace the first.
1186    #[xmtp_common::test(unwrap_try = true)]
1187    // verifies: META-066
1188    fn only_the_first_definition_of_an_id_is_registered() {
1189        let catalogue: Vec<_> = [0xC000, 0xFD00]
1190            .into_iter()
1191            .flat_map(|id| {
1192                let mut duplicate = definition(id, true, true);
1193                duplicate.component_type = ComponentType::Bytes as i32;
1194                [definition(id, true, true), duplicate]
1195            })
1196            .collect();
1197        let registry = registry_of(&group_dictionary(
1198            ConversationType::Group,
1199            &policy_set(),
1200            &catalogue,
1201        ));
1202        for id in [0xC000, 0xFD00] {
1203            assert_eq!(
1204                registry
1205                    .get(&ComponentId::new(id))?
1206                    .map(|metadata| metadata.component_type),
1207                Some(ComponentType::String as i32)
1208            );
1209        }
1210    }
1211
1212    /// A newer backend may publish a type or policy tag this build does not
1213    /// know. Creation skips that definition instead of failing, and leaves
1214    /// its ID absent so a client that knows the tags can register it.
1215    #[xmtp_common::test(unwrap_try = true)]
1216    // verifies: META-066
1217    fn definitions_with_unknown_tags_are_skipped() {
1218        use xmtp_configuration::MetadataPolicy;
1219        let mut unknown_type = definition(0xC000, true, true);
1220        unknown_type.component_type = 99;
1221        let mut unknown_policy = definition(0xC001, true, true);
1222        unknown_policy.permissions.update = Some(MetadataPolicy::Any(vec![
1223            MetadataPolicy::Base(MetadataBasePolicy::Allow as i32),
1224            MetadataPolicy::Base(99),
1225        ]));
1226        let mut empty_condition = definition(0xC002, true, true);
1227        empty_condition.permissions.delete = Some(MetadataPolicy::And(vec![]));
1228        let catalogue = [
1229            unknown_type,
1230            unknown_policy,
1231            empty_condition,
1232            definition(0xC003, true, true),
1233        ];
1234        let registry = registry_of(&group_dictionary(
1235            ConversationType::Group,
1236            &policy_set(),
1237            &catalogue,
1238        ));
1239        for id in [0xC000, 0xC001, 0xC002] {
1240            assert!(!registry.contains_raw(&ComponentId::new(id)));
1241        }
1242        assert!(registry.contains(&ComponentId::new(0xC003)));
1243    }
1244}