Skip to main content

xmtp_mls/groups/app_data/
component_source.rs

1//! Single source-of-truth for the per-`ComponentId` read/encode/apply logic.
2//!
3//! Centralizes everything the rest of the MLS pipeline needs to know about
4//! a well-known `ComponentId`: its logical [`ComponentType`], where its
5//! current bytes live (OpenMLS AppData dictionary vs. legacy group context
6//! extensions), and how to encode/apply `AppDataUpdate` payloads.
7//!
8//! The module declaration is `pub` only so `ComponentSourceError` can
9//! satisfy the `private_interfaces` lint on the public `GroupError` variant
10//! it's embedded in. All helpers remain `pub(crate)`.
11//!
12//! ## Inbox-id encoding
13//!
14//! The legacy GMM extension stores inbox ids as 64-character hex strings.
15//! Anything serialized through the new `AppDataUpdate` path uses the
16//! versioned [`InboxId`] newtype instead — the legacy on-the-wire format
17//! is left untouched for unmigrated groups.
18//!
19//! See [`xmtp_mls_common::inbox_id`] for the full wire-format contract;
20//! the short version is `varint(version) || 32-byte payload`, with
21//! version 0 producing a 33-byte encoding.
22
23// `ComponentMutation`, `component_type`, and the standalone
24// `expand_app_data_update_to_changes` entry point are scaffolding for
25// the standalone proposal-by-reference flow (`IntentKind::ProposeAppDataUpdate`)
26// described in XIP §1.5.2 / §3.4. They have unit-test coverage but no
27// production caller yet — the inline path goes through
28// `apply_app_data_update_payload` instead. `expect` (not `allow`) so the
29// compiler trips this when standalone-propose wiring lands, and we
30// either drop the attribute or trim whichever scaffolding the new path
31// supersedes.
32#![expect(dead_code)]
33
34use openmls::{
35    extensions::Extensions,
36    group::{GroupContext, MlsGroup as OpenMlsGroup, StagedCommit},
37    messages::proposals::AppDataUpdateOperation,
38};
39use tls_codec::{Deserialize, Serialize};
40use xmtp_mls_common::{
41    app_data::{
42        component_id::ComponentId,
43        component_registry::ComponentRegistry,
44        components::type_dispatch::{apply_update_payload_for_type, expand_to_changes_for_type},
45        registry_table::lookup_component,
46        typed::ComponentTypedError,
47    },
48    group_mutable_metadata::{
49        GroupMutableMetadata, GroupMutableMetadataError, MetadataField,
50        find_mutable_metadata_extension,
51    },
52    inbox_id::{InboxId, InboxIdError},
53    tls_map::TlsMapError,
54    tls_set::{TlsSet, TlsSetDelta, TlsSetError, TlsSetMutation},
55};
56use xmtp_proto::xmtp::mls::message_contents::ComponentType;
57
58/// Errors surfaced by the component_source layer.
59///
60/// `pub` (rather than `pub(crate)`) because [`GroupError`] embeds it via
61/// `#[from]` for the AppDataUpdate path; `pub(crate)` would trigger
62/// `private_interfaces` warnings on the public `GroupError` variant.
63///
64/// [`GroupError`]: super::super::error::GroupError
65#[derive(Debug, thiserror::Error)]
66pub enum ComponentSourceError {
67    /// The component id is outside the well-known XMTP range.
68    #[error("unknown component {0}")]
69    UnknownComponent(ComponentId),
70
71    /// The component is known but its wiring hasn't been built yet.
72    #[error("component {0} wiring is not yet implemented")]
73    NotImplemented(ComponentId),
74
75    /// An `AppDataUpdate::Update` write was attempted against an immutable
76    /// component. Insert-once writes should be expressed as `Insert`, not
77    /// caught here.
78    #[error("component {0} is immutable and cannot be updated via AppDataUpdate")]
79    ImmutableUpdate(ComponentId),
80
81    /// The supplied [`ComponentMutation`] does not match the component type
82    /// of the component it targets (e.g. a `Bytes` mutation against
83    /// `ADMIN_LIST`).
84    #[error("mutation shape does not match component {0}")]
85    MismatchedMutation(ComponentId),
86
87    /// Failed to convert an inbox id string or byte slice into an
88    /// [`InboxId`]. Wraps [`InboxIdError`] — callers that need to
89    /// distinguish "not hex" from "wrong length" can match the inner
90    /// variant.
91    #[error("invalid inbox id: {0}")]
92    InvalidInboxId(#[from] InboxIdError),
93
94    /// A wire-format violation on a component value: the bytes stored in
95    /// the AppData dictionary for a known component don't decode under the
96    /// expected encoding (e.g. non-UTF-8 bytes for a `Bytes`-typed
97    /// metadata attribute, malformed `TlsSet` for a collection component).
98    #[error("malformed value for component {component_id}: {reason}")]
99    MalformedComponentValue {
100        /// The component whose stored bytes failed to decode.
101        component_id: ComponentId,
102        /// Human-readable reason — surface to logs, not user-facing.
103        reason: String,
104    },
105
106    /// A `MetadataUpdate` intent referenced a metadata field name that has
107    /// no corresponding `ComponentId`. Most commonly fires when a future
108    /// metadata field is added to one of the senders without also being
109    /// added to [`metadata_field_to_component_id`].
110    #[error("unknown metadata field name: {0}")]
111    UnknownMetadataField(String),
112
113    /// Failed to read, decode, or encode the legacy group mutable metadata
114    /// extension while servicing a component-source request.
115    #[error(transparent)]
116    GroupMutableMetadata(#[from] GroupMutableMetadataError),
117
118    /// A TLS-codec operation on a delta or stored collection value failed.
119    #[error("tls codec error: {0}")]
120    TlsCodec(#[from] tls_codec::Error),
121
122    /// A `TlsSet::apply_delta` call failed while synthesizing the new full
123    /// value of a collection component from an incoming delta.
124    #[error("tls set apply error: {0}")]
125    TlsSetApply(#[from] TlsSetError),
126
127    /// A `TlsMap::apply_delta` call failed while synthesizing the new full
128    /// value of a map component from an incoming delta.
129    #[error("tls map apply error: {0}")]
130    TlsMapApply(#[from] TlsMapError),
131}
132
133impl ComponentSourceError {
134    /// Best-effort `ComponentId` extraction for the variants that carry
135    /// one — so error-mapping shims can preserve structured context
136    /// across the crate boundary into
137    /// [`GroupMutableMetadataError::MalformedComponent`] without
138    /// stringifying.
139    pub(crate) fn component_id(&self) -> Option<ComponentId> {
140        match self {
141            Self::UnknownComponent(id)
142            | Self::NotImplemented(id)
143            | Self::ImmutableUpdate(id)
144            | Self::MismatchedMutation(id)
145            | Self::MalformedComponentValue {
146                component_id: id, ..
147            } => Some(*id),
148            _ => None,
149        }
150    }
151}
152
153impl From<ComponentTypedError> for ComponentSourceError {
154    /// Surface trait-layer errors at the dispatch boundary. The
155    /// dispatch layer adds `UnknownComponent` / `NotImplemented` /
156    /// `UnknownMetadataField` / `GroupMutableMetadata` for things the
157    /// trait can't see; the variants below are the trait's domain
158    /// and round-trip 1:1.
159    fn from(err: ComponentTypedError) -> Self {
160        match err {
161            ComponentTypedError::ImmutableUpdate(id) => Self::ImmutableUpdate(id),
162            ComponentTypedError::MismatchedMutation(id) => Self::MismatchedMutation(id),
163            ComponentTypedError::MalformedValue {
164                component_id,
165                reason,
166            } => Self::MalformedComponentValue {
167                component_id,
168                reason,
169            },
170            ComponentTypedError::InvalidInboxId(e) => Self::InvalidInboxId(e),
171            ComponentTypedError::TlsCodec(e) => Self::TlsCodec(e),
172            ComponentTypedError::TlsSetApply(e) => Self::TlsSetApply(e),
173            ComponentTypedError::TlsMapApply(e) => Self::TlsMapApply(e),
174            ComponentTypedError::UnspecifiedType(id) => Self::MalformedComponentValue {
175                component_id: id,
176                reason: "registered ComponentType is Unspecified".to_string(),
177            },
178            // A COMPONENT_REGISTRY delta that violates the registry's
179            // write invariants (reserved/hardcoded/out-of-space id,
180            // immutable overwrite, undecodable metadata). Structurally
181            // a malformed value for the registry component; the reason
182            // string carries the specific violation.
183            ComponentTypedError::RegistryMutation(e) => Self::MalformedComponentValue {
184                component_id: ComponentId::COMPONENT_REGISTRY,
185                reason: e.to_string(),
186            },
187        }
188    }
189}
190
191impl From<ComponentSourceError> for GroupMutableMetadataError {
192    /// Preserve structure where possible. If the source already wraps a
193    /// `GroupMutableMetadataError` (e.g. `MissingExtension` raised by the
194    /// legacy `TryFrom<&OpenMlsGroup>` path on an unmigrated group),
195    /// unwrap and return that inner variant verbatim so callers can
196    /// match on `MissingExtension` / `MissingMetadataField` / etc.
197    ///
198    /// For every other variant, surface as `MalformedComponent` and
199    /// preserve the offending `component_id` when it's available so
200    /// downstream consumers (bindings, error-mapping) can match
201    /// structurally on it. Variants without one surface as
202    /// `component_id: None`; the display string stays the
203    /// authoritative diagnostic.
204    fn from(err: ComponentSourceError) -> Self {
205        if let ComponentSourceError::GroupMutableMetadata(inner) = err {
206            return inner;
207        }
208        let component_id = err.component_id();
209        GroupMutableMetadataError::MalformedComponent {
210            component_id,
211            reason: err.to_string(),
212        }
213    }
214}
215
216/// Describes a single, atomic mutation that a per-field intent handler wants
217/// to apply to a component. The encoder picks the wire shape (single-element
218/// [`TlsSetDelta`] for collections, passthrough for bytes components).
219///
220/// The wire format supports batching (`TlsSetDelta.mutations` is a
221/// `Vec<TlsSetMutation<K>>`), but this enum intentionally models a single
222/// atomic mutation per variant — admin-list updates today arrive as
223/// single-action intents (`UpdateAdminListIntentData` carries one inbox
224/// id and one action), and coalescing happens at the commit layer via
225/// [`super::accumulate_app_data_updates`]. The migration PR that wires
226/// admin-list paths through `AppDataUpdate` should reshape this into
227/// batched variants (e.g. `InboxIdSetDelta { component_id, mutations }`)
228/// so a single proposal can carry multiple set mutations.
229#[derive(Debug, Clone)]
230pub(crate) enum ComponentMutation<'a> {
231    /// A whole-value replacement for a `Bytes`-typed component.
232    Bytes {
233        component_id: ComponentId,
234        new_value: &'a [u8],
235    },
236    /// Add a single inbox id to the admin list.
237    AdminListAdd { inbox_id: &'a str },
238    /// Remove a single inbox id from the admin list.
239    AdminListRemove { inbox_id: &'a str },
240    /// Add a single inbox id to the super-admin list.
241    SuperAdminListAdd { inbox_id: &'a str },
242    /// Remove a single inbox id from the super-admin list.
243    SuperAdminListRemove { inbox_id: &'a str },
244}
245
246impl ComponentMutation<'_> {
247    /// The `ComponentId` that this mutation targets.
248    pub(crate) fn component_id(&self) -> ComponentId {
249        match self {
250            Self::Bytes { component_id, .. } => *component_id,
251            Self::AdminListAdd { .. } | Self::AdminListRemove { .. } => ComponentId::ADMIN_LIST,
252            Self::SuperAdminListAdd { .. } | Self::SuperAdminListRemove { .. } => {
253                ComponentId::SUPER_ADMIN_LIST
254            }
255        }
256    }
257}
258
259/// Hardcoded logical type of a well-known component. Returns `None` for
260/// app-range components (`0xC000-0xFEFF`) and for any well-known id that
261/// is not yet wired into this match.
262pub(crate) fn component_type(id: ComponentId) -> Option<ComponentType> {
263    match id {
264        // Hardcoded registry / list components. ComponentRegistry itself is a
265        // TlsMap, but permissions are enforced in code — it never flows
266        // through this module.
267        ComponentId::COMPONENT_REGISTRY => Some(ComponentType::TlsMapBytesBytes),
268        ComponentId::SUPER_ADMIN_LIST => Some(ComponentType::TlsSetInboxId),
269        ComponentId::ADMIN_LIST => Some(ComponentType::TlsSetInboxId),
270
271        // GroupMembership — TlsMap<InboxId, bytes>
272        ComponentId::GROUP_MEMBERSHIP => Some(ComponentType::TlsMapInboxIdBytes),
273
274        // GroupMutableMetadata-backed string components.
275        ComponentId::GROUP_NAME
276        | ComponentId::GROUP_DESCRIPTION
277        | ComponentId::GROUP_IMAGE_URL
278        | ComponentId::APP_DATA
279        | ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION => Some(ComponentType::String),
280
281        // GroupMutableMetadata-backed bytes components.
282        ComponentId::MESSAGE_DISAPPEAR_FROM_NS
283        | ComponentId::MESSAGE_DISAPPEAR_IN_NS
284        | ComponentId::COMMIT_LOG_SIGNER => Some(ComponentType::Bytes),
285
286        // Immutable metadata (not flowable through AppDataUpdate writes,
287        // but we still advertise the type for completeness).
288        ComponentId::CONVERSATION_TYPE
289        | ComponentId::CREATOR_INBOX_ID
290        | ComponentId::ONESHOT_MESSAGE => Some(ComponentType::Bytes),
291        ComponentId::DM_MEMBERS => Some(ComponentType::TlsSetInboxId),
292
293        _ => None,
294    }
295}
296
297/// Re-export of the `MetadataField` ↔ `ComponentId` bijection, moved
298/// to `xmtp_mls_common` (single source of truth shared with the
299/// dict↔legacy merge and the archive exporter).
300pub(crate) use xmtp_mls_common::group_mutable_metadata::METADATA_FIELD_COMPONENT_MAP;
301
302/// Map a [`MetadataField`] string to its corresponding `ComponentId`.
303///
304/// Returns `None` for unknown field names so this can also be called with a
305/// raw string coming from a legacy intent payload.
306pub(crate) fn metadata_field_to_component_id(field_name: &str) -> Option<ComponentId> {
307    METADATA_FIELD_COMPONENT_MAP
308        .iter()
309        .find(|(field, _)| field.as_str() == field_name)
310        .map(|(_, id)| *id)
311}
312
313/// Map a `ComponentId` back to the `MetadataField` attribute name that stores
314/// it in the legacy `GroupMutableMetadata` extension.
315///
316/// Returns `None` for component ids that are not backed by a
317/// `GroupMutableMetadata` attribute (e.g. `ADMIN_LIST`, `GROUP_MEMBERSHIP`,
318/// or anything outside the mutable metadata family).
319pub(crate) fn component_id_to_metadata_field(id: ComponentId) -> Option<MetadataField> {
320    METADATA_FIELD_COMPONENT_MAP
321        .iter()
322        .find(|(_, component_id)| *component_id == id)
323        .map(|(field, _)| *field)
324}
325
326/// Read the component's current bytes from whichever storage the group's
327/// capability flag indicates: the OpenMLS AppData dictionary when
328/// `proposals_enabled` is on, otherwise the legacy group context
329/// extensions (translated into the new app-data wire format on the fly).
330pub(crate) fn read_component_bytes(
331    id: ComponentId,
332    extensions: &Extensions<GroupContext>,
333    proposals_enabled: bool,
334) -> Result<Option<Vec<u8>>, ComponentSourceError> {
335    if proposals_enabled {
336        Ok(read_from_app_data_dict_from_extensions(id, extensions))
337    } else {
338        read_from_legacy(id, extensions)
339    }
340}
341
342/// Compute the post-commit value of a single component on a migrated group
343/// by overlaying the staged commit's `AppDataUpdate` proposals on top of
344/// the pre-commit dict. Last-write-wins matches the lazy-batching apply
345/// order in [`super::accumulate_app_data_updates`]: every `Update(payload)`
346/// is decoded against the running value (so collection deltas compose),
347/// and `Remove` collapses to `None`.
348///
349/// Returns `Ok(None)` when the component is absent both before and after
350/// the commit, or when it was explicitly removed. Returns `Err` only when
351/// an `Update` payload fails to decode against the running value — the
352/// same condition `validate_app_data_update_proposals_in_commit` would
353/// also reject upstream, so callers can treat decode failure here as
354/// "validator will surface the real error" and short-circuit.
355///
356/// Used by the commit validator to evaluate per-component invariants
357/// (notably `MIN_SUPPORTED_PROTOCOL_VERSION`) that need the post-commit
358/// view on migrated groups, where the legacy `GroupMutableMetadata`
359/// extension diff that drives the same check on unmigrated groups is
360/// unavailable.
361///
362/// # Registry semantics
363///
364/// `registry` is the **pre-commit** `COMPONENT_REGISTRY` (i.e. the state
365/// of the dictionary entry before the staged commit applies). Callers
366/// should load it once via [`super::load_component_registry`] on the
367/// live `mls_group` and reuse it across all validator helpers — same
368/// registry feeds [`super::validate_app_data_update_proposals_in_commit`]
369/// and any other per-component checks.
370///
371/// **Implication for commits that modify `COMPONENT_REGISTRY` in the
372/// same commit as a write to a newly-registered component**: such a
373/// write fails with `UnknownComponent` here because the new entry is
374/// not yet visible in the pre-commit registry. This matches what the
375/// receiver-side validator
376/// ([`super::validate_app_data_update_proposals_in_commit`]) enforces
377/// today and is the documented convention across the migrated commit
378/// path: registry mutations and writes that depend on those mutations
379/// MUST land in separate commits.
380///
381/// The bootstrap commit is the only legitimate "register + write in
382/// the same commit" pattern and is routed through a dedicated
383/// validator ([`super::bootstrap_validator::validate_bootstrap_commit`])
384/// that does not flow through this function.
385pub(crate) fn read_post_commit_component_bytes(
386    id: ComponentId,
387    mls_group: &OpenMlsGroup,
388    staged_commit: &StagedCommit,
389    registry: &ComponentRegistry,
390) -> Result<Option<Vec<u8>>, ComponentSourceError> {
391    let openmls_id: openmls::component::ComponentId = id.as_u16();
392
393    // Owned snapshot of operations targeting this specific component.
394    // Iterating `app_data_update_proposals()` yields short-lived
395    // `QueuedAppDataUpdateProposal` views that borrow into the staged
396    // commit — we can't hold their byte slices across iterations, so
397    // we materialize an owned form up front. `Update` payloads are
398    // typically tiny (version strings, single-key deltas), so the
399    // clone cost is negligible.
400    enum Op {
401        Update(Vec<u8>),
402        Remove,
403    }
404    let ops: Vec<Op> = staged_commit
405        .app_data_update_proposals()
406        .filter_map(|queued| {
407            let proposal = queued.app_data_update_proposal();
408            if proposal.component_id() != openmls_id {
409                return None;
410            }
411            Some(match proposal.operation() {
412                AppDataUpdateOperation::Update(payload) => Op::Update(payload.as_slice().to_vec()),
413                AppDataUpdateOperation::Remove => Op::Remove,
414            })
415        })
416        .collect();
417    if ops.is_empty() {
418        return Ok(read_from_app_data_dict(id, mls_group));
419    }
420
421    let mut current = read_from_app_data_dict(id, mls_group);
422    for op in &ops {
423        match op {
424            Op::Update(payload) => {
425                current = Some(apply_app_data_update_payload(
426                    id,
427                    payload,
428                    current.as_deref(),
429                    registry,
430                )?);
431            }
432            Op::Remove => current = None,
433        }
434    }
435    Ok(current)
436}
437
438/// Look up the component's bytes in the OpenMLS AppData dictionary.
439///
440/// `pub(crate)` so the commit validator (`validated_commit.rs`) can
441/// pull the pre-commit stored bytes for a component and thread them
442/// into [`expand_app_data_update_to_changes`] as `old_value` — the
443/// validator uses that to resolve `RemoveByHash` mutations back to
444/// their underlying inbox id. The parent `app_data` module also uses
445/// it from `process_message_with_app_data`, `stage_app_data_propose_and_commit`,
446/// and `pending_app_data_updates`.
447pub(crate) fn read_from_app_data_dict(
448    id: ComponentId,
449    mls_group: &OpenMlsGroup,
450) -> Option<Vec<u8>> {
451    read_from_app_data_dict_from_extensions(id, mls_group.extensions())
452}
453
454/// Extensions-only counterpart of [`read_from_app_data_dict`] — reads the
455/// component's bytes straight from a group's `GroupContext` extensions, with no
456/// full `OpenMlsGroup`. openmls keys the dictionary by its own `ComponentId`,
457/// which is just a `u16` alias, so `id.as_u16()` unwraps our newtype to the key.
458pub(crate) fn read_from_app_data_dict_from_extensions(
459    id: ComponentId,
460    extensions: &Extensions<GroupContext>,
461) -> Option<Vec<u8>> {
462    extensions
463        .app_data_dictionary()
464        .and_then(|ext| ext.dictionary().get(&id.as_u16()))
465        .map(|bytes| bytes.to_vec())
466}
467
468/// Look up the component's bytes in the legacy group-context extensions and
469/// translate them into the new app-data wire format.
470///
471/// For `GroupMutableMetadata`-backed bytes components this returns the
472/// attribute's UTF-8 bytes. For `ADMIN_LIST` / `SUPER_ADMIN_LIST` it
473/// re-encodes the legacy `Vec<String>` of hex inbox ids as a
474/// `TlsSet<InboxId>`.
475///
476/// `GROUP_MEMBERSHIP` is intentionally unsupported here and returns
477/// [`ComponentSourceError::NotImplemented`]: unmigrated groups read
478/// membership via the dedicated `GROUP_MEMBERSHIP_EXTENSION_ID`
479/// GroupContext extension (see [`extract_group_membership`]), not as
480/// an AppData component. Migrated groups use the dict directly.
481///
482/// [`extract_group_membership`]: crate::groups::group_membership::extract_group_membership
483fn read_from_legacy(
484    id: ComponentId,
485    extensions: &Extensions<GroupContext>,
486) -> Result<Option<Vec<u8>>, ComponentSourceError> {
487    // Mutable-metadata-backed bytes components: pull the attribute out of
488    // the GMM extension. Missing extension → None; missing attribute → None.
489    if let Some(field) = component_id_to_metadata_field(id) {
490        let gmm = match find_mutable_metadata_extension(extensions) {
491            Some(bytes) => GroupMutableMetadata::try_from(bytes)?,
492            None => return Ok(None),
493        };
494        return Ok(gmm
495            .attributes
496            .get(field.as_str())
497            .map(|s| s.as_bytes().to_vec()));
498    }
499
500    match id {
501        ComponentId::ADMIN_LIST => {
502            let gmm = match find_mutable_metadata_extension(extensions) {
503                Some(bytes) => GroupMutableMetadata::try_from(bytes)?,
504                None => return Ok(None),
505            };
506            Ok(Some(encode_inbox_id_set(&gmm.admin_list)?))
507        }
508        ComponentId::SUPER_ADMIN_LIST => {
509            let gmm = match find_mutable_metadata_extension(extensions) {
510                Some(bytes) => GroupMutableMetadata::try_from(bytes)?,
511                None => return Ok(None),
512            };
513            Ok(Some(encode_inbox_id_set(&gmm.super_admin_list)?))
514        }
515        ComponentId::GROUP_MEMBERSHIP => Err(ComponentSourceError::NotImplemented(id)),
516        _ => Err(ComponentSourceError::UnknownComponent(id)),
517    }
518}
519
520/// Encode a [`ComponentMutation`] into the bytes that go inside an
521/// `AppDataUpdateOperation::Update(bytes)` payload on the wire.
522///
523/// - `Bytes` components pass through verbatim.
524/// - `AdminList*` / `SuperAdminList*` produce a single-element
525///   [`TlsSetDelta`] keyed on an [`InboxId`].
526pub(crate) fn encode_app_data_update_payload(
527    mutation: &ComponentMutation<'_>,
528) -> Result<Vec<u8>, ComponentSourceError> {
529    match mutation {
530        ComponentMutation::Bytes {
531            component_id,
532            new_value,
533        } => {
534            // Phase-1 bytes components only cover the GMM-attribute family.
535            if component_id_to_metadata_field(*component_id).is_none() {
536                return Err(ComponentSourceError::MismatchedMutation(*component_id));
537            }
538            Ok(new_value.to_vec())
539        }
540        ComponentMutation::AdminListAdd { inbox_id }
541        | ComponentMutation::SuperAdminListAdd { inbox_id } => {
542            let key = inbox_id_str_to_bytes(inbox_id)?;
543            encode_inbox_id_set_delta(TlsSetMutation::Insert(key))
544        }
545        ComponentMutation::AdminListRemove { inbox_id }
546        | ComponentMutation::SuperAdminListRemove { inbox_id } => {
547            let key = inbox_id_str_to_bytes(inbox_id)?;
548            encode_inbox_id_set_delta(TlsSetMutation::Remove(key))
549        }
550    }
551}
552
553// `ExpandedComponentChange` lives in `xmtp_mls_common::app_data::typed`
554// so the `Component` trait there can return it. Re-exported here so
555// in-crate callers can construct the change list without pulling the
556// xmtp_mls_common path in directly.
557pub(crate) use xmtp_mls_common::app_data::typed::ExpandedComponentChange;
558
559/// Expand an `AppDataUpdate` proposal payload into the per-element changes
560/// that should be checked against the component registry.
561///
562/// - `Bytes` components: returns a single `Update` change with the new
563///   payload bytes.
564/// - Collection components (`ADMIN_LIST` / `SUPER_ADMIN_LIST`): parses the
565///   payload as a `TlsSetDelta<InboxId>` and emits one entry per
566///   mutation, with `op = Insert` for `Insert`, `op = Delete` for
567///   `Remove` / `RemoveByHash`.
568/// - `AppDataUpdateOperation::Remove` (any component): a single
569///   `Delete` entry with no value.
570///
571/// `old_value` is the component's pre-commit stored bytes (from the
572/// AppData dictionary). It's only consulted for `RemoveByHash`
573/// resolution on collection components: given the prior `TlsSet<InboxId>`,
574/// we build a `hash → InboxId` index and resolve each `RemoveByHash` back
575/// to the concrete key being removed so the validator sees the inbox id
576/// the peer is targeting. If the hash doesn't match any prior key (or
577/// `old_value` is `None`), the expansion surfaces `value: None` and the
578/// subsequent CRDT apply step surfaces the real error.
579///
580/// Used on the receiver side to feed `validate_component_write` for each
581/// distinct change inside a single `AppDataUpdate` proposal.
582///
583/// The steady-state validator dispatches through `lookup_component`
584/// directly so it can also call `Component::validate_invariant`
585/// without a second binary search. This wrapper is retained for
586/// callers that don't need the invariant hook.
587pub(crate) fn expand_app_data_update_to_changes(
588    component_id: ComponentId,
589    operation: &AppDataUpdateOperation,
590    old_value: Option<&[u8]>,
591    registry: &ComponentRegistry,
592) -> Result<Vec<ExpandedComponentChange>, ComponentSourceError> {
593    if let Some(component) = lookup_component(component_id) {
594        return component
595            .expand_to_changes(operation, old_value)
596            .map_err(Into::into);
597    }
598
599    // No per-id [`Component`] impl on this client. Two type-resolution
600    // sources, tried in order:
601    //
602    // 1. In-code [`component_type`] mapping — covers well-known XMTP
603    //    ids whose type is known to this release but which have no
604    //    typed decoder (e.g. the immutable seeds CREATOR_INBOX_ID,
605    //    ONESHOT_MESSAGE — handled by bootstrap byte-compare, not by a
606    //    `Component` impl).
607    // 2. On-dict [`ComponentRegistry`] entry — covers components a
608    //    *newer* release ships that this client has never heard of;
609    //    the registry's `component_type` tag is the type oracle.
610    //
611    // Either way, the closed type universe (6 variants) means every
612    // shape — including `TlsSet` / `TlsMap` deltas — surfaces a proper
613    // per-element change list to the validator. Old and new clients
614    // converge on the same dict state for the same wire bytes.
615    let ty = component_type(component_id)
616        .map_or_else(|| registered_component_type(component_id, registry), Ok)?;
617    expand_to_changes_for_type(component_id, ty, operation, old_value).map_err(Into::into)
618}
619
620/// Decode an incoming `AppDataUpdateOperation::Update(bytes)` payload
621/// and produce the new full bytes of the component, given the prior
622/// stored bytes (if any). `Update`-only — `Remove` carries no payload
623/// and is handled directly by the caller.
624///
625/// Immutable components are rejected with
626/// [`ComponentSourceError::ImmutableUpdate`] **only when a prior
627/// value already exists** — the bootstrap commit is the canonical
628/// first-insert path for immutable seeds, so this layer must allow
629/// an `Update` whose `old_value` is `None`. The bootstrap validator
630/// catches malicious initial values upstream via byte-compare.
631pub(crate) fn apply_app_data_update_payload(
632    id: ComponentId,
633    payload: &[u8],
634    old_value: Option<&[u8]>,
635    registry: &ComponentRegistry,
636) -> Result<Vec<u8>, ComponentSourceError> {
637    // Immutability gate. Reject only on overwrite — a fresh insert
638    // (no prior value) is the bootstrap commit's first write of an
639    // immutable seed and must succeed for honest receivers to reach
640    // the migrated state. Steady-state immutables always have a prior
641    // (inserted at bootstrap), so a Byzantine peer trying to mutate
642    // them post-bootstrap still hits this branch and gets rejected.
643    if id.is_immutable() && old_value.is_some() {
644        return Err(ComponentSourceError::ImmutableUpdate(id));
645    }
646
647    // Per-id `Component` impl on this client — handles all 13 well-
648    // known mutable components with a typed decoder.
649    if let Some(component) = lookup_component(id) {
650        return component
651            .apply_update_payload(payload, old_value)
652            .map_err(Into::into);
653    }
654
655    // Two type-resolution sources for components without a per-id
656    // impl, tried in order:
657    //
658    // 1. In-code [`component_type`] mapping — covers well-known XMTP
659    //    ids whose type is known but which have no typed decoder
660    //    (immutable seeds like CREATOR_INBOX_ID — bootstrap-only
661    //    first-write path).
662    // 2. On-dict [`ComponentRegistry`] entry — covers components a
663    //    *newer* release ships that this client has never heard of;
664    //    the registry's `component_type` tag is the type oracle.
665    let ty = component_type(id).map_or_else(|| registered_component_type(id, registry), Ok)?;
666    apply_update_payload_for_type(id, ty, payload, old_value).map_err(Into::into)
667}
668
669/// Look up the [`ComponentType`] registered for a component id in the
670/// on-dict [`ComponentRegistry`]. Returns
671/// [`ComponentSourceError::UnknownComponent`] when no registry entry
672/// exists — the deny-by-default rule that keeps unrecognized payloads
673/// from being applied opaquely.
674fn registered_component_type(
675    id: ComponentId,
676    registry: &ComponentRegistry,
677) -> Result<ComponentType, ComponentSourceError> {
678    let meta = registry
679        .get(&id)
680        .map_err(|e| ComponentSourceError::MalformedComponentValue {
681            component_id: id,
682            reason: format!("registry lookup: {e}"),
683        })?
684        .ok_or(ComponentSourceError::UnknownComponent(id))?;
685    ComponentType::try_from(meta.component_type).map_err(|_| {
686        ComponentSourceError::MalformedComponentValue {
687            component_id: id,
688            reason: format!(
689                "registry entry has unknown component_type tag {}",
690                meta.component_type
691            ),
692        }
693    })
694}
695
696/// Overlay AppData-dict component values onto a base [`GroupMutableMetadata`]
697/// read from the legacy extension. On migrated groups the dict is
698/// authoritative; for unmigrated components the legacy GMM stays as the
699/// fallback, so callers always get a complete view.
700///
701/// Gated on [`super::is_migrated_group`] (defense-in-depth) so a stray
702/// dict entry on a pre-bootstrap group can't shadow legacy GMM.
703///
704/// Wire formats (must match what the sender emits via
705/// [`encode_app_data_update_payload`] / [`apply_app_data_update_payload`]):
706/// - Bytes components: raw UTF-8 string bytes.
707/// - `ADMIN_LIST` / `SUPER_ADMIN_LIST`: TLS-serialized `TlsSet<InboxId>`,
708///   each id hex-encoded back to string form.
709///
710/// ## Independence from `COMPONENT_REGISTRY` parseability
711///
712/// This function reads metadata field entries directly from the dict and
713/// **never** loads or validates the `COMPONENT_REGISTRY` payload — it
714/// only uses [`super::is_migrated_extensions`] (key-existence check) as
715/// the gate. So a malformed `COMPONENT_REGISTRY` blob does NOT cause
716/// metadata reads to drop authoritative data: as long as the individual
717/// metadata field bytes (`GROUP_NAME`, `ADMIN_LIST`, …) decode
718/// correctly, they round-trip into the returned GMM. Registry corruption
719/// is surfaced loudly on the *write* paths instead — the sender gate in
720/// `mls_sync.rs` and the commit validator in `validated_commit.rs` both
721/// call [`super::load_component_registry`] and propagate decode errors
722/// — so a corrupt registry blocks state changes without making readable
723/// data unreachable. See
724/// `merge_with_malformed_registry_returns_valid_field` for the test
725/// that pins this invariant.
726pub(crate) fn merge_app_data_into_mutable_metadata(
727    base: &mut GroupMutableMetadata,
728    mls_group: &OpenMlsGroup,
729) -> Result<(), ComponentSourceError> {
730    merge_app_data_into_mutable_metadata_from_extensions(base, mls_group.extensions())
731}
732
733/// Capability-aware [`GroupMutableMetadata`] extractor.
734///
735/// On migrated groups the legacy `GroupMutableMetadata` group context
736/// extension is stripped by the bootstrap commit, so the static
737/// [`xmtp_mls_common::group_mutable_metadata::extract_legacy_group_mutable_metadata`]
738/// returns `MissingExtension` and any caller that swallows the error
739/// with `.ok()` silently defaults every metadata field (notably:
740/// disappearing-message settings and `MinimumSupportedProtocolVersion`
741/// — the latter is what gates the XIP §3 pause-on-version-bump flow).
742///
743/// This helper returns the same `GroupMutableMetadata` shape but reads
744/// from the right source per migration state:
745///
746/// - **Migrated** ([`super::is_migrated_group`] returns `true`): starts
747///   from an empty composite and overlays every field from the AppData
748///   dictionary via [`merge_app_data_into_mutable_metadata`].
749/// - **Unmigrated**: parses the legacy GMM extension via
750///   `GroupMutableMetadata::try_from(&OpenMlsGroup)`, matching the
751///   legacy static helper byte-for-byte.
752pub(crate) fn extract_group_mutable_metadata_capability_aware(
753    mls_group: &OpenMlsGroup,
754) -> Result<GroupMutableMetadata, ComponentSourceError> {
755    if super::is_migrated_group(mls_group) {
756        let mut base =
757            GroupMutableMetadata::new(std::collections::HashMap::new(), Vec::new(), Vec::new());
758        merge_app_data_into_mutable_metadata(&mut base, mls_group)?;
759        Ok(base)
760    } else {
761        Ok(GroupMutableMetadata::try_from(mls_group)?)
762    }
763}
764
765/// Same as [`extract_group_mutable_metadata_capability_aware`], but driven from
766/// the group's `GroupContext` extensions alone — no full `OpenMlsGroup::load`.
767/// The mutable metadata lives entirely in the context extensions, so a single
768/// `StorageProvider::group_context` read (one KV round-trip, no ratchet tree)
769/// is all this needs.
770pub(crate) fn extract_group_mutable_metadata_capability_aware_from_extensions(
771    extensions: &Extensions<GroupContext>,
772) -> Result<GroupMutableMetadata, ComponentSourceError> {
773    if super::is_migrated_extensions(extensions) {
774        let mut base =
775            GroupMutableMetadata::new(std::collections::HashMap::new(), Vec::new(), Vec::new());
776        merge_app_data_into_mutable_metadata_from_extensions(&mut base, extensions)?;
777        Ok(base)
778    } else {
779        Ok(GroupMutableMetadata::try_from(extensions)?)
780    }
781}
782
783/// Extensions-only variant of [`merge_app_data_into_mutable_metadata`].
784/// Mirrors the [`super::is_migrated_group`] / [`super::is_migrated_extensions`]
785/// and [`super::load_component_registry`] /
786/// [`super::load_component_registry_from_extensions`] splits so unit
787/// tests can pin the merge contract without materializing an
788/// `OpenMlsGroup`.
789pub(crate) fn merge_app_data_into_mutable_metadata_from_extensions(
790    base: &mut GroupMutableMetadata,
791    extensions: &openmls::extensions::Extensions<openmls::group::GroupContext>,
792) -> Result<(), ComponentSourceError> {
793    if !super::is_migrated_extensions(extensions) {
794        return Ok(());
795    }
796    // The merge body lives in `xmtp_mls_common` so crates below
797    // `xmtp_mls` in the dependency graph (the archive exporter) can
798    // reuse it; only the (test-override-aware) migration gate above
799    // stays here. Map the per-component error back to
800    // `MalformedComponentValue` so this function's error shape (which
801    // callers and tests match on, and `component_id()` extracts from)
802    // is unchanged by the move.
803    xmtp_mls_common::group_mutable_metadata::merge_dict_into_mutable_metadata(base, extensions)
804        .map_err(|e| match e {
805            GroupMutableMetadataError::MalformedComponent {
806                component_id: Some(component_id),
807                reason,
808            } => ComponentSourceError::MalformedComponentValue {
809                component_id,
810                reason,
811            },
812            other => ComponentSourceError::GroupMutableMetadata(other),
813        })
814}
815
816// ============================================================================
817// Inbox-id encoding helpers
818// ============================================================================
819//
820// Inbox ids are SHA-256 hashes (see `xmtp_id::associations::member::inbox_id`).
821// Their canonical string form is a 64-character hex string. Anything we put
822// on the wire through the new `AppDataUpdate` path uses the
823// versioned `InboxId` newtype instead — see the module-level docs for
824// the rationale and `xmtp_mls_common::inbox_id` for the full contract.
825
826/// Decode a hex-string inbox id into an [`InboxId`].
827///
828/// Returns [`ComponentSourceError::InvalidInboxId`] wrapping either
829/// [`InboxIdError::InvalidHex`] (input wasn't hex) or
830/// [`InboxIdError::InvalidLength`] (wrong byte length after decoding).
831/// Callers that need to distinguish the failure modes can match the
832/// inner variant.
833pub(crate) fn inbox_id_str_to_bytes(inbox_id: &str) -> Result<InboxId, ComponentSourceError> {
834    InboxId::from_hex(inbox_id).map_err(Into::into)
835}
836
837/// Read the super-admin list from the AppData dictionary on a migrated
838/// group. Returns `Ok(None)` on unmigrated groups (or migrated groups
839/// that happen not to have written `SUPER_ADMIN_LIST` yet).
840///
841/// Gated on [`super::is_migrated_group`] for the same reason as
842/// [`merge_app_data_into_mutable_metadata`] — keep stray dict entries
843/// from shadowing the authoritative legacy path pre-bootstrap.
844pub(crate) fn read_super_admin_list_from_dict(
845    mls_group: &OpenMlsGroup,
846) -> Result<Option<Vec<String>>, ComponentSourceError> {
847    read_super_admin_list_from_extensions(mls_group.extensions())
848}
849
850/// Extensions-only variant of [`read_super_admin_list_from_dict`]. Use
851/// the shim above when an `OpenMlsGroup` is at hand; this form is
852/// available primarily for unit testing and for commit-validation
853/// paths that only carry an `Extensions` reference.
854pub(crate) fn read_super_admin_list_from_extensions(
855    extensions: &Extensions<GroupContext>,
856) -> Result<Option<Vec<String>>, ComponentSourceError> {
857    if !super::is_migrated_extensions(extensions) {
858        return Ok(None);
859    }
860    let Some(ext) = extensions.app_data_dictionary() else {
861        return Ok(None);
862    };
863    let Some(bytes) = ext
864        .dictionary()
865        .get(&ComponentId::SUPER_ADMIN_LIST.as_u16())
866    else {
867        return Ok(None);
868    };
869    let set = TlsSet::<InboxId>::tls_deserialize_exact(bytes).map_err(|e| {
870        ComponentSourceError::MalformedComponentValue {
871            component_id: ComponentId::SUPER_ADMIN_LIST,
872            reason: format!("invalid TlsSet<InboxId>: {e}"),
873        }
874    })?;
875    Ok(Some(set.iter().map(|id| id.to_hex()).collect()))
876}
877
878/// Synthesize a [`GroupMetadata`] from the AppData dictionary on a
879/// migrated group. Returns `Ok(None)` if the critical immutable seeds
880/// aren't present (unmigrated group).
881///
882/// Encoding mirrors the sender-side synthesis in
883/// [`xmtp_mls_common::app_data::migration::synthesize_canonical_subset_for_validation`]:
884/// - `CONVERSATION_TYPE`: 4 big-endian bytes of `ConversationType as i32`
885///   (see `encode_conversation_type` there).
886/// - `CREATOR_INBOX_ID`: the versioned `InboxId` TLS wire form
887///   (`varint(version) || 32-byte payload`) — the same shape every
888///   other inbox-id-bearing component on the new path uses. Reader
889///   hex-encodes the decoded id back into the legacy
890///   `GroupMetadata::creator_inbox_id: String` slot.
891/// - `DM_MEMBERS`: `TlsSet<InboxId>` with exactly two elements —
892///   matches the declared `ComponentType::TlsSetInboxId` and the
893///   sender's `encode_dm_members`. The writer rejects self-DMs
894///   (identical slots) up front; readers that see a 1-element set
895///   surface `MalformedComponentValue`.
896/// - `ONESHOT_MESSAGE`: prost-encoded `OneshotMessage`.
897pub(crate) fn read_group_metadata_from_dict(
898    mls_group: &OpenMlsGroup,
899) -> Result<Option<GroupMetadataReturn>, ComponentSourceError> {
900    read_group_metadata_from_extensions(mls_group.extensions())
901}
902
903/// Extensions-only variant of [`read_group_metadata_from_dict`]. Same
904/// rationale for the split as [`read_super_admin_list_from_extensions`].
905pub(crate) fn read_group_metadata_from_extensions(
906    extensions: &Extensions<GroupContext>,
907) -> Result<Option<GroupMetadataReturn>, ComponentSourceError> {
908    use prost::Message;
909    use xmtp_proto::xmtp::mls::message_contents::{
910        DmMembers as DmMembersProto, Inbox as InboxProto, OneshotMessage,
911    };
912
913    // Gated on the unified migration predicate — see
914    // `merge_app_data_into_mutable_metadata` for the rationale.
915    if !super::is_migrated_extensions(extensions) {
916        return Ok(None);
917    }
918
919    let Some(ext) = extensions.app_data_dictionary() else {
920        return Ok(None);
921    };
922    let dict = ext.dictionary();
923
924    let Some(ct_bytes) = dict.get(&ComponentId::CONVERSATION_TYPE.as_u16()) else {
925        return Ok(None);
926    };
927    let Some(creator_bytes) = dict.get(&ComponentId::CREATOR_INBOX_ID.as_u16()) else {
928        return Ok(None);
929    };
930
931    let ct_arr: [u8; 4] =
932        ct_bytes
933            .try_into()
934            .map_err(|_| ComponentSourceError::MalformedComponentValue {
935                component_id: ComponentId::CONVERSATION_TYPE,
936                reason: format!("expected 4 bytes, got {}", ct_bytes.len()),
937            })?;
938    let conversation_type = i32::from_be_bytes(ct_arr);
939
940    let creator_inbox_id = InboxId::tls_deserialize_exact(creator_bytes)
941        .map_err(|e| ComponentSourceError::MalformedComponentValue {
942            component_id: ComponentId::CREATOR_INBOX_ID,
943            reason: format!("invalid InboxId TLS encoding: {e}"),
944        })?
945        .to_hex();
946
947    // `DM_MEMBERS` on the wire is `TlsSet<InboxId>`; re-shape to
948    // `DmMembersProto` so downstream `GroupMetadata::try_from` is unchanged.
949    let dm_members = match dict.get(&ComponentId::DM_MEMBERS.as_u16()) {
950        Some(b) => {
951            let set = TlsSet::<InboxId>::tls_deserialize_exact(b).map_err(|e| {
952                ComponentSourceError::MalformedComponentValue {
953                    component_id: ComponentId::DM_MEMBERS,
954                    reason: format!("invalid TlsSet<InboxId>: {e}"),
955                }
956            })?;
957            let ids: Vec<InboxId> = set.iter().copied().collect();
958            if ids.len() != 2 {
959                return Err(ComponentSourceError::MalformedComponentValue {
960                    component_id: ComponentId::DM_MEMBERS,
961                    reason: format!("expected 2 inbox ids, got {}", ids.len()),
962                });
963            }
964            Some(DmMembersProto {
965                dm_member_one: Some(InboxProto {
966                    inbox_id: ids[0].to_hex(),
967                }),
968                dm_member_two: Some(InboxProto {
969                    inbox_id: ids[1].to_hex(),
970                }),
971            })
972        }
973        None => None,
974    };
975
976    let oneshot = match dict.get(&ComponentId::ONESHOT_MESSAGE.as_u16()) {
977        Some(b) => Some(OneshotMessage::decode(b).map_err(|e| {
978            ComponentSourceError::MalformedComponentValue {
979                component_id: ComponentId::ONESHOT_MESSAGE,
980                reason: format!("OneshotMessage prost decode: {e}"),
981            }
982        })?),
983        None => None,
984    };
985
986    Ok(Some(GroupMetadataReturn {
987        conversation_type,
988        creator_inbox_id,
989        dm_members,
990        oneshot,
991    }))
992}
993
994/// Intermediate proto-shaped result of [`read_group_metadata_from_extensions`].
995/// Caller converts to the final [`xmtp_mls_common::group_metadata::GroupMetadata`].
996#[derive(Debug)]
997pub(crate) struct GroupMetadataReturn {
998    pub conversation_type: i32,
999    pub creator_inbox_id: String,
1000    pub dm_members: Option<xmtp_proto::xmtp::mls::message_contents::DmMembers>,
1001    pub oneshot: Option<xmtp_proto::xmtp::mls::message_contents::OneshotMessage>,
1002}
1003
1004/// Read the `GROUP_MEMBERSHIP` dict entry and decode it into the
1005/// legacy `GroupMembership` proto shape. Returns `Ok(None)` for
1006/// unmigrated groups. Used by `extract_group_membership` on the
1007/// receive-side validator to bridge the dict-stored membership back
1008/// into the existing `GroupMembership` Rust type without rewriting
1009/// every caller.
1010pub(crate) fn read_group_membership_from_dict(
1011    extensions: &Extensions<GroupContext>,
1012) -> Result<Option<xmtp_proto::xmtp::mls::message_contents::GroupMembership>, ComponentSourceError>
1013{
1014    use xmtp_mls_common::app_data::migration::decode_group_membership_dict;
1015    use xmtp_proto::xmtp::mls::message_contents::GroupMembership as GroupMembershipProto;
1016
1017    // Gate on the unified migration predicate so a stray
1018    // `GROUP_MEMBERSHIP` dict entry on a pre-bootstrap group can't
1019    // shadow the authoritative legacy extension. Matches the gating
1020    // used by [`merge_app_data_into_mutable_metadata`] and the
1021    // `mutable_metadata()` / `is_super_admin_without_lock` callers.
1022    if !super::is_migrated_extensions(extensions) {
1023        return Ok(None);
1024    }
1025
1026    let Some(ext) = extensions.app_data_dictionary() else {
1027        return Ok(None);
1028    };
1029    let Some(bytes) = ext
1030        .dictionary()
1031        .get(&ComponentId::GROUP_MEMBERSHIP.as_u16())
1032    else {
1033        return Ok(None);
1034    };
1035
1036    let entries = decode_group_membership_dict(bytes).map_err(|e| {
1037        ComponentSourceError::MalformedComponentValue {
1038            component_id: ComponentId::GROUP_MEMBERSHIP,
1039            reason: format!("TlsMap decode: {e}"),
1040        }
1041    })?;
1042
1043    // Flatten per-inbox GroupMembershipEntryV1 back into the legacy
1044    // proto shape: members (inbox_id → sequence_id), failed_installations
1045    // (flat Vec). The proto still has a flat failed_installations field
1046    // for backward compat — we concatenate per-inbox failed lists for
1047    // callers that still read the flat list.
1048    //
1049    // `decode_group_membership_dict` already rejects entries with
1050    // `version: None` (`MigrationError::GroupMembershipEntryUnknownVersion`),
1051    // so the only legal post-decode shape today is `Some(Version::V1(_))`.
1052    // Anything else (a future Version variant we can't interpret) is a
1053    // forward-compat hazard and surfaces as `MalformedComponentValue`.
1054    use xmtp_proto::xmtp::mls::message_contents::group_membership_entry::Version as GroupMembershipEntryVersion;
1055    let mut members: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
1056    let mut failed: Vec<Vec<u8>> = Vec::new();
1057    for (inbox_id, entry) in entries {
1058        let v1 = match entry.version {
1059            Some(GroupMembershipEntryVersion::V1(v1)) => v1,
1060            None => {
1061                return Err(ComponentSourceError::MalformedComponentValue {
1062                    component_id: ComponentId::GROUP_MEMBERSHIP,
1063                    reason: format!(
1064                        "GroupMembershipEntry for {} has no version",
1065                        inbox_id.to_hex()
1066                    ),
1067                });
1068            }
1069        };
1070        members.insert(inbox_id.to_hex(), v1.sequence_id);
1071        failed.extend(v1.failed_installations);
1072    }
1073
1074    Ok(Some(GroupMembershipProto {
1075        members,
1076        failed_installations: failed,
1077    }))
1078}
1079
1080/// Encode a list of hex inbox ids as a TLS-serialized `TlsSet<InboxId>`.
1081fn encode_inbox_id_set(inbox_ids: &[String]) -> Result<Vec<u8>, ComponentSourceError> {
1082    let ids: Vec<InboxId> = inbox_ids
1083        .iter()
1084        .map(|s| inbox_id_str_to_bytes(s))
1085        .collect::<Result<Vec<_>, _>>()?;
1086    let set: TlsSet<InboxId> = ids.into_iter().collect();
1087    Ok(set.tls_serialize_detached()?)
1088}
1089
1090/// Wrap a single set mutation in a `TlsSetDelta` and serialize it.
1091fn encode_inbox_id_set_delta(
1092    mutation: TlsSetMutation<InboxId>,
1093) -> Result<Vec<u8>, ComponentSourceError> {
1094    let delta = TlsSetDelta::<InboxId> {
1095        mutations: vec![mutation],
1096    };
1097    Ok(delta.tls_serialize_detached()?)
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102    use super::*;
1103    use prost::Message;
1104    use tls_codec::VLBytes;
1105    use xmtp_mls_common::{
1106        app_data::{
1107            component_permissions::component_permissions,
1108            component_registry::{ComponentOp, new_component_metadata},
1109        },
1110        inbox_id::INBOX_ID_BYTE_LEN,
1111        tls_map::{TlsMap, TlsMapDelta},
1112        tls_set::TlsKeyHash,
1113    };
1114    use xmtp_proto::xmtp::mls::message_contents::{
1115        MetadataPolicy as MetadataPolicyProto,
1116        metadata_policy::{Kind as MetadataPolicyKind, MetadataBasePolicy},
1117    };
1118
1119    /// Build a deterministic 64-character hex inbox id from a tag byte. The
1120    /// tag is repeated 32 times, giving a unique inbox id per call without
1121    /// needing real cryptographic generation.
1122    fn fake_inbox_id(tag: u8) -> String {
1123        hex::encode([tag; INBOX_ID_BYTE_LEN])
1124    }
1125
1126    /// Build the [`InboxId`] form of [`fake_inbox_id`] directly.
1127    fn fake_inbox(tag: u8) -> InboxId {
1128        InboxId::from_bytes([tag; INBOX_ID_BYTE_LEN])
1129    }
1130
1131    /// Empty registry constant for tests that exercise known-id paths —
1132    /// `lookup_component` resolves first, so the registry is never
1133    /// consulted and an empty one is sufficient.
1134    fn empty_registry() -> ComponentRegistry {
1135        ComponentRegistry::new()
1136    }
1137
1138    /// Build a single-entry registry for tests that exercise the
1139    /// type-aware fallback on unknown ids. Permissions are `Allow` for
1140    /// every op so the policy layer does not interfere with the
1141    /// dispatch test under question.
1142    fn registry_with(id: ComponentId, ty: ComponentType) -> ComponentRegistry {
1143        fn allow() -> MetadataPolicyProto {
1144            MetadataPolicyProto {
1145                kind: Some(MetadataPolicyKind::Base(MetadataBasePolicy::Allow as i32)),
1146            }
1147        }
1148        let perms = component_permissions()
1149            .insert(allow())
1150            .update(allow())
1151            .delete(allow())
1152            .call();
1153        let meta = new_component_metadata(perms, ty);
1154        let mut reg = ComponentRegistry::new();
1155        reg.set(id, meta).unwrap();
1156        reg
1157    }
1158
1159    // --- inbox-id helpers --------------------------------------------------
1160
1161    #[xmtp_common::test]
1162    fn test_inbox_id_round_trip() {
1163        let original = fake_inbox_id(0xAB);
1164        let id = inbox_id_str_to_bytes(&original).unwrap();
1165        assert_eq!(id.as_bytes(), &[0xAB; 32]);
1166        assert_eq!(id.to_hex(), original);
1167    }
1168
1169    #[xmtp_common::test]
1170    fn test_inbox_id_invalid_hex() {
1171        let err = inbox_id_str_to_bytes("not_hex").unwrap_err();
1172        assert!(
1173            matches!(
1174                err,
1175                ComponentSourceError::InvalidInboxId(InboxIdError::InvalidHex(_))
1176            ),
1177            "got {err:?}"
1178        );
1179    }
1180
1181    #[xmtp_common::test]
1182    fn test_inbox_id_wrong_length() {
1183        // Valid hex but too short — only 16 bytes.
1184        let err = inbox_id_str_to_bytes(&"ab".repeat(16)).unwrap_err();
1185        assert!(
1186            matches!(
1187                err,
1188                ComponentSourceError::InvalidInboxId(InboxIdError::InvalidLength {
1189                    expected: INBOX_ID_BYTE_LEN,
1190                    actual: 16,
1191                })
1192            ),
1193            "got {err:?}"
1194        );
1195    }
1196
1197    // --- component_type lookups --------------------------------------------
1198
1199    #[xmtp_common::test]
1200    fn test_component_type_string_family() {
1201        // GMM-backed components whose wire format is UTF-8 text — names,
1202        // descriptions, URLs, and the app-data string blob.
1203        for id in [
1204            ComponentId::GROUP_NAME,
1205            ComponentId::GROUP_DESCRIPTION,
1206            ComponentId::GROUP_IMAGE_URL,
1207            ComponentId::APP_DATA,
1208            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
1209        ] {
1210            assert_eq!(component_type(id), Some(ComponentType::String));
1211        }
1212    }
1213
1214    #[xmtp_common::test]
1215    fn test_component_type_bytes_family() {
1216        // Components whose payload is opaque bytes, not UTF-8 text:
1217        // timestamped disappearance windows and the commit-log signer key.
1218        for id in [
1219            ComponentId::MESSAGE_DISAPPEAR_FROM_NS,
1220            ComponentId::MESSAGE_DISAPPEAR_IN_NS,
1221            ComponentId::COMMIT_LOG_SIGNER,
1222        ] {
1223            assert_eq!(component_type(id), Some(ComponentType::Bytes));
1224        }
1225    }
1226
1227    #[xmtp_common::test]
1228    fn test_component_type_set_inbox_id_family() {
1229        for id in [
1230            ComponentId::ADMIN_LIST,
1231            ComponentId::SUPER_ADMIN_LIST,
1232            ComponentId::DM_MEMBERS,
1233        ] {
1234            assert_eq!(component_type(id), Some(ComponentType::TlsSetInboxId));
1235        }
1236    }
1237
1238    #[xmtp_common::test]
1239    fn test_component_type_group_membership() {
1240        assert_eq!(
1241            component_type(ComponentId::GROUP_MEMBERSHIP),
1242            Some(ComponentType::TlsMapInboxIdBytes)
1243        );
1244    }
1245
1246    #[xmtp_common::test]
1247    fn test_component_type_app_range_is_none() {
1248        assert_eq!(component_type(ComponentId::new(0xC000)), None);
1249        assert_eq!(component_type(ComponentId::new(0xFDAB)), None);
1250    }
1251
1252    // --- MetadataField <-> ComponentId mapping -----------------------------
1253
1254    #[xmtp_common::test]
1255    fn test_metadata_field_round_trip() {
1256        for field in [
1257            MetadataField::GroupName,
1258            MetadataField::Description,
1259            MetadataField::GroupImageUrlSquare,
1260            MetadataField::MessageDisappearFromNS,
1261            MetadataField::MessageDisappearInNS,
1262            MetadataField::MinimumSupportedProtocolVersion,
1263            MetadataField::CommitLogSigner,
1264            MetadataField::AppData,
1265        ] {
1266            let id = metadata_field_to_component_id(field.as_str())
1267                .expect("every MetadataField has a ComponentId");
1268            let back = component_id_to_metadata_field(id)
1269                .expect("every mapped ComponentId has a MetadataField");
1270            assert_eq!(back, field, "round-trip mismatch for {field:?}");
1271        }
1272    }
1273
1274    #[xmtp_common::test]
1275    fn test_metadata_field_unknown_name_returns_none() {
1276        assert!(metadata_field_to_component_id("nonexistent_field").is_none());
1277    }
1278
1279    #[xmtp_common::test]
1280    fn test_component_id_to_metadata_field_non_gmm_returns_none() {
1281        assert!(component_id_to_metadata_field(ComponentId::ADMIN_LIST).is_none());
1282        assert!(component_id_to_metadata_field(ComponentId::GROUP_MEMBERSHIP).is_none());
1283        assert!(component_id_to_metadata_field(ComponentId::new(0xC000)).is_none());
1284    }
1285
1286    // --- encode_app_data_update_payload ------------------------------------
1287
1288    #[xmtp_common::test]
1289    fn test_encode_bytes_payload_passthrough() {
1290        let value = b"hello world";
1291        let payload = encode_app_data_update_payload(&ComponentMutation::Bytes {
1292            component_id: ComponentId::GROUP_NAME,
1293            new_value: value,
1294        })
1295        .unwrap();
1296        assert_eq!(payload, value);
1297    }
1298
1299    #[xmtp_common::test]
1300    fn test_encode_bytes_payload_rejects_non_bytes_component() {
1301        // GROUP_MEMBERSHIP isn't a bytes component, so shoving a Bytes
1302        // mutation at it is a programming error — callers should build a
1303        // membership-specific mutation shape instead.
1304        let err = encode_app_data_update_payload(&ComponentMutation::Bytes {
1305            component_id: ComponentId::GROUP_MEMBERSHIP,
1306            new_value: b"x",
1307        })
1308        .unwrap_err();
1309        assert!(matches!(err, ComponentSourceError::MismatchedMutation(_)));
1310    }
1311
1312    #[xmtp_common::test]
1313    fn test_encode_admin_list_insert_delta() {
1314        let inbox = fake_inbox_id(0x11);
1315        let payload =
1316            encode_app_data_update_payload(&ComponentMutation::AdminListAdd { inbox_id: &inbox })
1317                .unwrap();
1318        let delta = TlsSetDelta::<InboxId>::tls_deserialize_exact(&payload).unwrap();
1319        assert_eq!(delta.mutations.len(), 1);
1320        match &delta.mutations[0] {
1321            TlsSetMutation::Insert(k) => assert_eq!(*k, fake_inbox(0x11)),
1322            other => panic!("expected Insert, got {other:?}"),
1323        }
1324    }
1325
1326    #[xmtp_common::test]
1327    fn test_encode_admin_list_remove_delta() {
1328        let inbox = fake_inbox_id(0x22);
1329        let payload = encode_app_data_update_payload(&ComponentMutation::AdminListRemove {
1330            inbox_id: &inbox,
1331        })
1332        .unwrap();
1333        let delta = TlsSetDelta::<InboxId>::tls_deserialize_exact(&payload).unwrap();
1334        assert_eq!(delta.mutations.len(), 1);
1335        match &delta.mutations[0] {
1336            TlsSetMutation::Remove(k) => assert_eq!(*k, fake_inbox(0x22)),
1337            other => panic!("expected Remove, got {other:?}"),
1338        }
1339    }
1340
1341    #[xmtp_common::test]
1342    fn test_encode_super_admin_list_delta() {
1343        let inbox = fake_inbox_id(0x33);
1344        let add = encode_app_data_update_payload(&ComponentMutation::SuperAdminListAdd {
1345            inbox_id: &inbox,
1346        })
1347        .unwrap();
1348        let remove = encode_app_data_update_payload(&ComponentMutation::SuperAdminListRemove {
1349            inbox_id: &inbox,
1350        })
1351        .unwrap();
1352        let add_delta = TlsSetDelta::<InboxId>::tls_deserialize_exact(&add).unwrap();
1353        let remove_delta = TlsSetDelta::<InboxId>::tls_deserialize_exact(&remove).unwrap();
1354        assert!(matches!(&add_delta.mutations[0], TlsSetMutation::Insert(_)));
1355        assert!(matches!(
1356            &remove_delta.mutations[0],
1357            TlsSetMutation::Remove(_)
1358        ));
1359    }
1360
1361    #[xmtp_common::test]
1362    fn test_encode_admin_list_invalid_inbox_id() {
1363        // "not-a-real-inbox-id" is non-hex, so the failure is the
1364        // hex-decode variant rather than the length variant.
1365        let err = encode_app_data_update_payload(&ComponentMutation::AdminListAdd {
1366            inbox_id: "not-a-real-inbox-id",
1367        })
1368        .unwrap_err();
1369        assert!(
1370            matches!(
1371                err,
1372                ComponentSourceError::InvalidInboxId(InboxIdError::InvalidHex(_))
1373            ),
1374            "got {err:?}"
1375        );
1376    }
1377
1378    // --- apply_app_data_update_payload -------------------------------------
1379
1380    #[xmtp_common::test]
1381    fn test_apply_bytes_payload_returns_payload_verbatim() {
1382        let payload = b"new_name";
1383        let new_value = apply_app_data_update_payload(
1384            ComponentId::GROUP_NAME,
1385            payload,
1386            None,
1387            &empty_registry(),
1388        )
1389        .unwrap();
1390        assert_eq!(new_value, payload);
1391    }
1392
1393    #[xmtp_common::test]
1394    fn test_apply_bytes_payload_ignores_old_value() {
1395        // Full replacement — the old value is irrelevant.
1396        let new_value = apply_app_data_update_payload(
1397            ComponentId::GROUP_DESCRIPTION,
1398            b"replacement",
1399            Some(b"old_description"),
1400            &empty_registry(),
1401        )
1402        .unwrap();
1403        assert_eq!(new_value, b"replacement");
1404    }
1405
1406    #[xmtp_common::test]
1407    fn test_apply_admin_list_insert_against_none() {
1408        // Apply an insert delta against a group that has no prior admin list.
1409        // The synthesized full value should be a TlsSet with the one inbox id.
1410        let inbox = fake_inbox_id(0x44);
1411        let insert_payload =
1412            encode_app_data_update_payload(&ComponentMutation::AdminListAdd { inbox_id: &inbox })
1413                .unwrap();
1414
1415        let new_bytes = apply_app_data_update_payload(
1416            ComponentId::ADMIN_LIST,
1417            &insert_payload,
1418            None,
1419            &empty_registry(),
1420        )
1421        .unwrap();
1422
1423        let set = TlsSet::<InboxId>::tls_deserialize_exact(&new_bytes).unwrap();
1424        assert_eq!(set.len(), 1);
1425        assert!(set.contains(&fake_inbox(0x44)));
1426    }
1427
1428    #[xmtp_common::test]
1429    fn test_apply_admin_list_insert_against_existing_set() {
1430        // Build a prior set with one entry, then apply a delta that inserts a
1431        // second entry. Confirm both entries are present in the new value.
1432        let alice = fake_inbox_id(0x01);
1433        let bob = fake_inbox_id(0x02);
1434
1435        let prior = encode_inbox_id_set(std::slice::from_ref(&alice)).unwrap();
1436        let insert_payload =
1437            encode_app_data_update_payload(&ComponentMutation::AdminListAdd { inbox_id: &bob })
1438                .unwrap();
1439
1440        let new_bytes = apply_app_data_update_payload(
1441            ComponentId::ADMIN_LIST,
1442            &insert_payload,
1443            Some(&prior),
1444            &empty_registry(),
1445        )
1446        .unwrap();
1447
1448        let set = TlsSet::<InboxId>::tls_deserialize_exact(&new_bytes).unwrap();
1449        assert_eq!(set.len(), 2);
1450        assert!(set.contains(&fake_inbox(0x01)));
1451        assert!(set.contains(&fake_inbox(0x02)));
1452    }
1453
1454    #[xmtp_common::test]
1455    fn test_apply_admin_list_remove_against_existing_set() {
1456        let alice = fake_inbox_id(0x01);
1457        let bob = fake_inbox_id(0x02);
1458
1459        let prior = encode_inbox_id_set(&[alice.clone(), bob.clone()]).unwrap();
1460        let remove_payload = encode_app_data_update_payload(&ComponentMutation::AdminListRemove {
1461            inbox_id: &alice,
1462        })
1463        .unwrap();
1464
1465        let new_bytes = apply_app_data_update_payload(
1466            ComponentId::ADMIN_LIST,
1467            &remove_payload,
1468            Some(&prior),
1469            &empty_registry(),
1470        )
1471        .unwrap();
1472
1473        let set = TlsSet::<InboxId>::tls_deserialize_exact(&new_bytes).unwrap();
1474        assert_eq!(set.len(), 1);
1475        assert!(set.contains(&fake_inbox(0x02)));
1476        assert!(!set.contains(&fake_inbox(0x01)));
1477    }
1478
1479    #[xmtp_common::test]
1480    fn test_apply_super_admin_list_delta() {
1481        let owner = fake_inbox_id(0xAA);
1482        let new_sa = fake_inbox_id(0xBB);
1483
1484        let prior = encode_inbox_id_set(std::slice::from_ref(&owner)).unwrap();
1485        let add_payload = encode_app_data_update_payload(&ComponentMutation::SuperAdminListAdd {
1486            inbox_id: &new_sa,
1487        })
1488        .unwrap();
1489
1490        let new_bytes = apply_app_data_update_payload(
1491            ComponentId::SUPER_ADMIN_LIST,
1492            &add_payload,
1493            Some(&prior),
1494            &empty_registry(),
1495        )
1496        .unwrap();
1497
1498        let set = TlsSet::<InboxId>::tls_deserialize_exact(&new_bytes).unwrap();
1499        assert_eq!(set.len(), 2);
1500        assert!(set.contains(&fake_inbox(0xAA)));
1501        assert!(set.contains(&fake_inbox(0xBB)));
1502    }
1503
1504    #[xmtp_common::test]
1505    fn test_apply_admin_list_malformed_payload_returns_tls_codec_error() {
1506        // Garbage bytes that aren't a valid TlsSetDelta — exercises the
1507        // wire-format decode failure path that ProcessMessageWithAppDataError
1508        // ::AppDataDecode wraps in production. The receiver-side
1509        // `process_message_with_app_data` propagates this through the new
1510        // GroupMessageProcessingError::OpenMlsProcessMessageWithAppData
1511        // variant rather than masking it as `FoundAppDataUpdateProposal`.
1512        let err = apply_app_data_update_payload(
1513            ComponentId::ADMIN_LIST,
1514            &[0xff, 0xff, 0xff, 0xff],
1515            None,
1516            &empty_registry(),
1517        )
1518        .unwrap_err();
1519        assert!(
1520            matches!(err, ComponentSourceError::TlsCodec(_)),
1521            "got {err:?}"
1522        );
1523    }
1524
1525    #[xmtp_common::test]
1526    fn test_apply_admin_list_malformed_old_value_returns_tls_codec_error() {
1527        // The delta payload is well-formed, but the old_value isn't a
1528        // valid TlsSet — also a TlsCodec error, just from the other side.
1529        let inbox = fake_inbox_id(0x55);
1530        let payload =
1531            encode_app_data_update_payload(&ComponentMutation::AdminListAdd { inbox_id: &inbox })
1532                .unwrap();
1533        let err = apply_app_data_update_payload(
1534            ComponentId::ADMIN_LIST,
1535            &payload,
1536            Some(&[0xde, 0xad, 0xbe, 0xef]),
1537            &empty_registry(),
1538        )
1539        .unwrap_err();
1540        assert!(
1541            matches!(err, ComponentSourceError::TlsCodec(_)),
1542            "got {err:?}"
1543        );
1544    }
1545
1546    #[xmtp_common::test]
1547    fn test_apply_immutable_first_insert_allowed() {
1548        // Bootstrap-shape: an `Update(payload)` against an immutable
1549        // component with no prior value is the bootstrap commit's
1550        // first-write path. Apply must store the payload bytes
1551        // verbatim — the bootstrap validator's byte-compare catches a
1552        // peer that crafts a malicious initial value, so the apply
1553        // layer doesn't need its own decode/check step.
1554        let bytes = apply_app_data_update_payload(
1555            ComponentId::CONVERSATION_TYPE,
1556            b"seed",
1557            None,
1558            &empty_registry(),
1559        )
1560        .unwrap();
1561        assert_eq!(bytes, b"seed");
1562    }
1563
1564    #[xmtp_common::test]
1565    fn test_apply_immutable_overwrite_rejected() {
1566        // Steady-state: an `Update(payload)` against an immutable
1567        // component that already has a prior value must fail. This is
1568        // the only path Byzantine peers have to mutate immutables
1569        // post-bootstrap, and the apply layer is the gatekeeper.
1570        let err = apply_app_data_update_payload(
1571            ComponentId::CONVERSATION_TYPE,
1572            b"junk",
1573            Some(b"prior"),
1574            &empty_registry(),
1575        )
1576        .unwrap_err();
1577        assert!(matches!(err, ComponentSourceError::ImmutableUpdate(_)));
1578    }
1579
1580    #[xmtp_common::test]
1581    fn test_apply_component_registry_delta_against_empty() {
1582        // Bootstrap shape: a `TlsMapDelta<ComponentId, VLBytes>` of
1583        // all-`Insert` mutations applied against an empty map produces
1584        // a materialized snapshot containing those entries. Values must
1585        // be structurally valid `ComponentMetadata` — registry deltas
1586        // are entry-validated at apply.
1587        let id_a = ComponentId::GROUP_NAME;
1588        let id_b = ComponentId::GROUP_DESCRIPTION;
1589        let meta_a = registry_with(id_a, ComponentType::String)
1590            .get(&id_a)
1591            .unwrap()
1592            .unwrap()
1593            .encode_to_vec();
1594        let meta_b = registry_with(id_b, ComponentType::Bytes)
1595            .get(&id_b)
1596            .unwrap()
1597            .unwrap()
1598            .encode_to_vec();
1599        let delta = TlsMapDelta::<ComponentId, VLBytes>::new()
1600            .insert(id_a, VLBytes::new(meta_a.clone()))
1601            .insert(id_b, VLBytes::new(meta_b.clone()));
1602        let payload = delta.tls_serialize_detached().unwrap();
1603
1604        let new_bytes = apply_app_data_update_payload(
1605            ComponentId::COMPONENT_REGISTRY,
1606            &payload,
1607            None,
1608            &empty_registry(),
1609        )
1610        .unwrap();
1611        let map = TlsMap::<ComponentId, VLBytes>::tls_deserialize_exact(&new_bytes).unwrap();
1612        assert_eq!(map.len(), 2);
1613        assert_eq!(
1614            map.get(&id_a).map(|v| v.as_slice()),
1615            Some(meta_a.as_slice())
1616        );
1617        assert_eq!(
1618            map.get(&id_b).map(|v| v.as_slice()),
1619            Some(meta_b.as_slice())
1620        );
1621    }
1622
1623    #[xmtp_common::test]
1624    fn test_apply_group_membership_delta_against_existing_map() {
1625        // Post-bootstrap shape: an `Update` mutation applied on top of
1626        // a prior `TlsMap<InboxId, VLBytes>` snapshot produces a new
1627        // snapshot with the updated value.
1628        let alice = fake_inbox(0xAA);
1629        let bob = fake_inbox(0xBB);
1630        let mut prior: TlsMap<InboxId, VLBytes> = TlsMap::new();
1631        prior.set(alice, VLBytes::new(vec![0x01]));
1632        prior.set(bob, VLBytes::new(vec![0x02]));
1633        let prior_bytes = prior.tls_serialize_detached().unwrap();
1634
1635        let delta = TlsMapDelta::<InboxId, VLBytes>::new()
1636            .update(alice, VLBytes::new(vec![0x99]))
1637            .delete(bob);
1638        let payload = delta.tls_serialize_detached().unwrap();
1639
1640        let new_bytes = apply_app_data_update_payload(
1641            ComponentId::GROUP_MEMBERSHIP,
1642            &payload,
1643            Some(&prior_bytes),
1644            &empty_registry(),
1645        )
1646        .unwrap();
1647        let map = TlsMap::<InboxId, VLBytes>::tls_deserialize_exact(&new_bytes).unwrap();
1648        assert_eq!(map.len(), 1);
1649        assert_eq!(
1650            map.get(&alice).map(|v| v.as_slice()),
1651            Some([0x99].as_slice())
1652        );
1653        assert!(!map.contains_key(&bob));
1654    }
1655
1656    #[xmtp_common::test]
1657    fn test_apply_map_component_malformed_delta_returns_codec_error() {
1658        // Garbage bytes that aren't a valid TlsMapDelta surface as a
1659        // TLS-codec error, same shape as the set-component path.
1660        let err = apply_app_data_update_payload(
1661            ComponentId::COMPONENT_REGISTRY,
1662            &[0xff, 0xff, 0xff, 0xff],
1663            None,
1664            &empty_registry(),
1665        )
1666        .unwrap_err();
1667        assert!(
1668            matches!(err, ComponentSourceError::TlsCodec(_)),
1669            "got {err:?}"
1670        );
1671    }
1672
1673    #[xmtp_common::test]
1674    fn test_apply_map_component_apply_failure_surfaces_apply_error() {
1675        // A delta that updates a key not present in the prior snapshot
1676        // fails at apply time — surfaced as `TlsMapApply(KeyNotFound)`.
1677        let alice = fake_inbox(0x01);
1678        let delta = TlsMapDelta::<InboxId, VLBytes>::new().update(alice, VLBytes::new(vec![0x42]));
1679        let payload = delta.tls_serialize_detached().unwrap();
1680        let err = apply_app_data_update_payload(
1681            ComponentId::GROUP_MEMBERSHIP,
1682            &payload,
1683            None,
1684            &empty_registry(),
1685        )
1686        .unwrap_err();
1687        assert!(
1688            matches!(
1689                err,
1690                ComponentSourceError::TlsMapApply(TlsMapError::KeyNotFound)
1691            ),
1692            "got {err:?}"
1693        );
1694    }
1695
1696    // --- Unknown-component tolerance via type-aware dispatch ---------------
1697    //
1698    // No per-id `Component` impl exists for these ids on this client; the
1699    // sender shipped a newer release. Old clients look up the
1700    // `ComponentType` registered for the id in the on-dict
1701    // [`ComponentRegistry`] and route the payload through the
1702    // type-level decoder. The six `ComponentType` variants cover the
1703    // wire-format universe, so any future well-known or
1704    // application-range component lands convergently — including
1705    // `TlsSet` / `TlsMap` deltas, which previously needed a per-id
1706    // impl to apply correctly.
1707
1708    #[xmtp_common::test]
1709    fn test_apply_unknown_id_with_bytes_registry_entry_stores_payload() {
1710        // Bytes shape: opaque passthrough, registry entry supplies the
1711        // type tag so the dispatch knows *not* to try a TLS-delta
1712        // decode (which would corrupt the bytes).
1713        let id = ComponentId::new(0xC123);
1714        let registry = registry_with(id, ComponentType::Bytes);
1715        let new_value = apply_app_data_update_payload(id, b"opaque", None, &registry).unwrap();
1716        assert_eq!(new_value, b"opaque");
1717    }
1718
1719    #[xmtp_common::test]
1720    fn test_apply_unknown_id_with_string_registry_entry_validates_utf8() {
1721        // String shape: payload must be valid UTF-8. Bad bytes surface
1722        // as `MalformedComponentValue` rather than silently corrupting
1723        // the dict.
1724        let id = ComponentId::new(0xC222);
1725        let registry = registry_with(id, ComponentType::String);
1726        let ok = apply_app_data_update_payload(id, b"hello", None, &registry).unwrap();
1727        assert_eq!(ok, b"hello");
1728        let err = apply_app_data_update_payload(id, &[0xC3, 0x28], None, &registry).unwrap_err();
1729        assert!(matches!(
1730            err,
1731            ComponentSourceError::MalformedComponentValue { .. }
1732        ));
1733    }
1734
1735    #[xmtp_common::test]
1736    fn test_apply_unknown_id_with_tls_set_inbox_id_applies_delta() {
1737        // The whole point of registry-typed dispatch: a new
1738        // `TlsSet<InboxId>` component lands as a typed delta apply, not
1739        // as an opaque blob replacement — old and new clients converge
1740        // on the same `TlsSet` snapshot byte-for-byte.
1741        let id = ComponentId::new(0xC333);
1742        let registry = registry_with(id, ComponentType::TlsSetInboxId);
1743        let bob = fake_inbox(0x02);
1744        let delta = TlsSetDelta::<InboxId>::new().insert(bob);
1745        let payload = delta.tls_serialize_detached().unwrap();
1746        let new_bytes = apply_app_data_update_payload(id, &payload, None, &registry).unwrap();
1747        let set = TlsSet::<InboxId>::tls_deserialize_exact(&new_bytes).unwrap();
1748        assert_eq!(set.len(), 1);
1749        assert!(set.contains(&bob));
1750    }
1751
1752    #[xmtp_common::test]
1753    fn test_apply_unknown_id_with_no_registry_entry_rejected() {
1754        // Deny-by-default: no per-id impl AND no registry entry means
1755        // we have no type to decode against. Reject rather than store
1756        // bytes whose shape we can't reason about — the alternative
1757        // would fork the dict the moment a typed client did know the
1758        // shape.
1759        let err =
1760            apply_app_data_update_payload(ComponentId::new(0xC456), b"x", None, &empty_registry())
1761                .unwrap_err();
1762        assert!(matches!(err, ComponentSourceError::UnknownComponent(_)));
1763    }
1764
1765    /// Immutability is enforced range-by-id, not per-`Component`-impl,
1766    /// so an unknown id sitting in the XMTP immutable range
1767    /// (`0xBE00-0xBFFF`) still gets the bootstrap-style "first insert
1768    /// allowed, subsequent overwrite rejected" contract — even when
1769    /// the type-aware dispatcher (not a per-id `Component` impl) is
1770    /// the path being exercised. Pins the reviewer's concern that the
1771    /// tolerance branch might bypass immutability for newly-defined
1772    /// immutable components that a NEWER release ships.
1773    #[xmtp_common::test]
1774    fn test_apply_unknown_id_in_xmtp_immutable_range_first_write_allowed() {
1775        let id = ComponentId::new(0xBE05); // XMTP immutable range
1776        assert!(id.is_immutable());
1777        let registry = registry_with(id, ComponentType::Bytes);
1778        let new_value = apply_app_data_update_payload(id, b"seed", None, &registry).unwrap();
1779        assert_eq!(new_value, b"seed");
1780    }
1781
1782    #[xmtp_common::test]
1783    fn test_apply_unknown_id_in_xmtp_immutable_range_overwrite_rejected() {
1784        let id = ComponentId::new(0xBE05);
1785        assert!(id.is_immutable());
1786        let registry = registry_with(id, ComponentType::Bytes);
1787        let err = apply_app_data_update_payload(id, b"new", Some(b"old"), &registry).unwrap_err();
1788        assert!(matches!(err, ComponentSourceError::ImmutableUpdate(_)));
1789    }
1790
1791    /// Same contract for the application immutable range
1792    /// (`0xFD00-0xFEFF`) — overwrites of an unknown immutable
1793    /// application component are rejected even though no per-id impl
1794    /// exists on this client.
1795    #[xmtp_common::test]
1796    fn test_apply_unknown_id_in_app_immutable_range_overwrite_rejected() {
1797        let id = ComponentId::new(0xFD42); // app immutable range
1798        assert!(id.is_immutable());
1799        let registry = registry_with(id, ComponentType::Bytes);
1800        let err = apply_app_data_update_payload(id, b"new", Some(b"old"), &registry).unwrap_err();
1801        assert!(matches!(err, ComponentSourceError::ImmutableUpdate(_)));
1802    }
1803
1804    #[xmtp_common::test]
1805    fn test_apply_reserved_range_component_rejected_with_or_without_registry() {
1806        // 0xFF00+ is the reserved range. `ComponentRegistry::set`
1807        // refuses to insert reserved ids, so a registry entry can't
1808        // even be constructed — the apply path falls through to
1809        // `UnknownComponent`.
1810        let err =
1811            apply_app_data_update_payload(ComponentId::new(0xFF01), b"x", None, &empty_registry())
1812                .unwrap_err();
1813        assert!(matches!(err, ComponentSourceError::UnknownComponent(_)));
1814    }
1815
1816    #[xmtp_common::test]
1817    fn test_apply_out_of_range_component_rejected() {
1818        // Ids below 0x8000 are outside the AppData address space.
1819        let err =
1820            apply_app_data_update_payload(ComponentId::new(0x0042), b"x", None, &empty_registry())
1821                .unwrap_err();
1822        assert!(matches!(err, ComponentSourceError::UnknownComponent(_)));
1823    }
1824
1825    #[xmtp_common::test]
1826    fn test_apply_unknown_xmtp_range_id_with_registry_dispatches() {
1827        // Same path applies to the XMTP-defined range, not just the
1828        // app range: any 0x8000-0xBFFF id that lacks a per-id impl
1829        // routes through the registry.
1830        let id = ComponentId::new(0x8FFF);
1831        assert!(id.is_xmtp_range());
1832        let registry = registry_with(id, ComponentType::Bytes);
1833        let new_value = apply_app_data_update_payload(id, b"opaque", None, &registry).unwrap();
1834        assert_eq!(new_value, b"opaque");
1835    }
1836
1837    // --- expand_app_data_update_to_changes ---------------------------------
1838
1839    /// Helper: wrap a `TlsSetDelta<InboxId>` payload in an
1840    /// `AppDataUpdateOperation::Update(...)`.
1841    fn update_op(delta: TlsSetDelta<InboxId>) -> AppDataUpdateOperation {
1842        AppDataUpdateOperation::Update(delta.tls_serialize_detached().unwrap().into())
1843    }
1844
1845    #[xmtp_common::test]
1846    fn test_expand_insert_surfaces_inbox_id_bytes() {
1847        let alice = fake_inbox(0x11);
1848        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1849            mutations: vec![TlsSetMutation::Insert(alice)],
1850        };
1851        let changes = expand_app_data_update_to_changes(
1852            ComponentId::ADMIN_LIST,
1853            &update_op(delta),
1854            None,
1855            &empty_registry(),
1856        )
1857        .unwrap();
1858        assert_eq!(changes.len(), 1);
1859        assert_eq!(changes[0].op, ComponentOp::Insert);
1860        assert_eq!(
1861            changes[0].value.as_deref(),
1862            Some(alice.as_bytes().as_slice())
1863        );
1864    }
1865
1866    #[xmtp_common::test]
1867    fn test_expand_remove_surfaces_inbox_id_bytes() {
1868        let alice = fake_inbox(0x22);
1869        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1870            mutations: vec![TlsSetMutation::Remove(alice)],
1871        };
1872        let changes = expand_app_data_update_to_changes(
1873            ComponentId::ADMIN_LIST,
1874            &update_op(delta),
1875            None,
1876            &empty_registry(),
1877        )
1878        .unwrap();
1879        assert_eq!(changes.len(), 1);
1880        assert_eq!(changes[0].op, ComponentOp::Delete);
1881        assert_eq!(
1882            changes[0].value.as_deref(),
1883            Some(alice.as_bytes().as_slice())
1884        );
1885    }
1886
1887    #[xmtp_common::test]
1888    fn test_expand_remove_by_hash_resolves_to_inbox_id_from_old_value() {
1889        // Prior set has alice + bob; RemoveByHash(hash(alice)) should
1890        // resolve back to alice's raw 32 bytes so the validator sees
1891        // *which* identity is being removed.
1892        let alice = fake_inbox(0xAA);
1893        let bob = fake_inbox(0xBB);
1894        let prior: TlsSet<InboxId> = [alice, bob].into_iter().collect();
1895        let old_bytes = prior.tls_serialize_detached().unwrap();
1896
1897        let hash = TlsKeyHash::of(&alice).unwrap();
1898        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1899            mutations: vec![TlsSetMutation::RemoveByHash(hash)],
1900        };
1901        let changes = expand_app_data_update_to_changes(
1902            ComponentId::ADMIN_LIST,
1903            &update_op(delta),
1904            Some(&old_bytes),
1905            &empty_registry(),
1906        )
1907        .unwrap();
1908        assert_eq!(changes.len(), 1);
1909        assert_eq!(changes[0].op, ComponentOp::Delete);
1910        assert_eq!(
1911            changes[0].value.as_deref(),
1912            Some(alice.as_bytes().as_slice())
1913        );
1914    }
1915
1916    #[xmtp_common::test]
1917    fn test_expand_remove_by_hash_miss_surfaces_none_value() {
1918        // Prior set has alice; RemoveByHash targets bob's hash (not in set).
1919        // Expansion surfaces value: None — the CRDT apply step will later
1920        // reject with KeyNotFound. Expansion's job is reshape, not auth.
1921        let alice = fake_inbox(0x01);
1922        let bob = fake_inbox(0x02);
1923        let prior: TlsSet<InboxId> = [alice].into_iter().collect();
1924        let old_bytes = prior.tls_serialize_detached().unwrap();
1925
1926        let hash = TlsKeyHash::of(&bob).unwrap();
1927        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1928            mutations: vec![TlsSetMutation::RemoveByHash(hash)],
1929        };
1930        let changes = expand_app_data_update_to_changes(
1931            ComponentId::ADMIN_LIST,
1932            &update_op(delta),
1933            Some(&old_bytes),
1934            &empty_registry(),
1935        )
1936        .unwrap();
1937        assert_eq!(changes.len(), 1);
1938        assert_eq!(changes[0].op, ComponentOp::Delete);
1939        assert!(changes[0].value.is_none());
1940    }
1941
1942    #[xmtp_common::test]
1943    fn test_expand_remove_by_hash_with_no_old_value_surfaces_none() {
1944        // No prior bytes → no set to search → every RemoveByHash misses.
1945        let hash = TlsKeyHash::of(&fake_inbox(0x33)).unwrap();
1946        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1947            mutations: vec![TlsSetMutation::RemoveByHash(hash)],
1948        };
1949        let changes = expand_app_data_update_to_changes(
1950            ComponentId::SUPER_ADMIN_LIST,
1951            &update_op(delta),
1952            None,
1953            &empty_registry(),
1954        )
1955        .unwrap();
1956        assert_eq!(changes.len(), 1);
1957        assert_eq!(changes[0].op, ComponentOp::Delete);
1958        assert!(changes[0].value.is_none());
1959    }
1960
1961    #[xmtp_common::test]
1962    fn test_expand_remove_by_hash_malformed_old_value_surfaces_codec_error() {
1963        // Our own dict bytes are corrupt — surface the decode error
1964        // loudly rather than silently degrading to value: None, since a
1965        // corrupt prior set signals a local-state bug, not a peer bug.
1966        let hash = TlsKeyHash::of(&fake_inbox(0x44)).unwrap();
1967        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1968            mutations: vec![TlsSetMutation::RemoveByHash(hash)],
1969        };
1970        let err = expand_app_data_update_to_changes(
1971            ComponentId::ADMIN_LIST,
1972            &update_op(delta),
1973            Some(&[0xde, 0xad, 0xbe, 0xef]),
1974            &empty_registry(),
1975        )
1976        .unwrap_err();
1977        assert!(
1978            matches!(err, ComponentSourceError::TlsCodec(_)),
1979            "got {err:?}"
1980        );
1981    }
1982
1983    /// XIP §2.2: an unknown component id with `Update(payload)` and a
1984    /// registered [`ComponentType::Bytes`] expands to a single Update
1985    /// change carrying the payload, so registry-policy validation runs
1986    /// against bytes a typed client would also accept opaquely.
1987    #[xmtp_common::test]
1988    fn test_expand_unknown_component_bytes_typed_emits_single_update() {
1989        let id = ComponentId::new(0x80FF);
1990        let registry = registry_with(id, ComponentType::Bytes);
1991        let changes = expand_app_data_update_to_changes(
1992            id,
1993            &AppDataUpdateOperation::Update(b"opaque".to_vec().into()),
1994            None,
1995            &registry,
1996        )
1997        .unwrap();
1998        assert_eq!(changes.len(), 1);
1999        assert_eq!(changes[0].op, ComponentOp::Update);
2000        assert_eq!(changes[0].value.as_deref(), Some(b"opaque".as_slice()));
2001    }
2002
2003    /// Unknown id with `Remove` and any registered type expands to a
2004    /// single Delete change — the wipe semantics are type-agnostic.
2005    #[xmtp_common::test]
2006    fn test_expand_unknown_component_remove_emits_single_delete() {
2007        let id = ComponentId::new(0x80FF);
2008        let registry = registry_with(id, ComponentType::Bytes);
2009        let changes =
2010            expand_app_data_update_to_changes(id, &AppDataUpdateOperation::Remove, None, &registry)
2011                .unwrap();
2012        assert_eq!(changes.len(), 1);
2013        assert_eq!(changes[0].op, ComponentOp::Delete);
2014        assert!(changes[0].value.is_none());
2015    }
2016
2017    /// Type-aware dispatch is the whole point: an unknown
2018    /// `TlsSet<InboxId>` component expands to a per-element change
2019    /// list, not a single opaque blob — so the validator policy loop
2020    /// inspects each `Insert` individually, the same as it would for a
2021    /// known set-shaped component.
2022    #[xmtp_common::test]
2023    fn test_expand_unknown_component_tls_set_inbox_id_emits_per_element_changes() {
2024        let id = ComponentId::new(0x80FE);
2025        let registry = registry_with(id, ComponentType::TlsSetInboxId);
2026        let alice = fake_inbox(0x11);
2027        let bob = fake_inbox(0x22);
2028        let delta = TlsSetDelta::<InboxId>::new().insert(alice).insert(bob);
2029        let payload = delta.tls_serialize_detached().unwrap();
2030        let changes = expand_app_data_update_to_changes(
2031            id,
2032            &AppDataUpdateOperation::Update(payload.into()),
2033            None,
2034            &registry,
2035        )
2036        .unwrap();
2037        assert_eq!(changes.len(), 2);
2038        assert!(changes.iter().all(|c| c.op == ComponentOp::Insert));
2039    }
2040
2041    /// Reserved-range ids are not tolerated. `ComponentRegistry::set`
2042    /// rejects reserved ids at construction time, so a registry entry
2043    /// can't even be built — the dispatcher falls through to the
2044    /// `UnknownComponent` rejection.
2045    #[xmtp_common::test]
2046    fn test_expand_reserved_range_id_still_rejected() {
2047        let err = expand_app_data_update_to_changes(
2048            ComponentId::new(0xFF02),
2049            &AppDataUpdateOperation::Update(b"x".to_vec().into()),
2050            None,
2051            &empty_registry(),
2052        )
2053        .unwrap_err();
2054        assert!(matches!(err, ComponentSourceError::UnknownComponent(_)));
2055    }
2056
2057    /// Deny-by-default: an unknown id with no registry entry is
2058    /// rejected. Without a type tag we have no idea how to decode the
2059    /// payload, and storing it opaquely would diverge from any future
2060    /// typed client that DID know the shape.
2061    #[xmtp_common::test]
2062    fn test_expand_unknown_component_no_registry_entry_rejected() {
2063        let err = expand_app_data_update_to_changes(
2064            ComponentId::new(0x80FF),
2065            &AppDataUpdateOperation::Update(b"opaque".to_vec().into()),
2066            None,
2067            &empty_registry(),
2068        )
2069        .unwrap_err();
2070        assert!(matches!(err, ComponentSourceError::UnknownComponent(_)));
2071    }
2072
2073    #[xmtp_common::test]
2074    fn test_expand_skips_old_value_decode_when_no_remove_by_hash() {
2075        // Delta has only Insert/Remove — we should never touch old_value,
2076        // so even a garbage old_value must not fail the expansion.
2077        let alice = fake_inbox(0x55);
2078        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
2079            mutations: vec![TlsSetMutation::Insert(alice)],
2080        };
2081        let changes = expand_app_data_update_to_changes(
2082            ComponentId::ADMIN_LIST,
2083            &update_op(delta),
2084            Some(&[0xff, 0xff, 0xff]), // intentionally malformed
2085            &empty_registry(),
2086        )
2087        .unwrap();
2088        assert_eq!(changes.len(), 1);
2089        assert_eq!(changes[0].op, ComponentOp::Insert);
2090    }
2091
2092    // ========================================================================
2093    // Dict-reader helpers — happy path / unmigrated / malformed coverage
2094    // ========================================================================
2095    //
2096    // The three `read_*_from_dict` helpers execute before bootstrap is
2097    // wired end-to-end; the unit tests below pin the wire-format
2098    // contract so a decoder drift can't silently ship a broken read path.
2099
2100    use openmls::extensions::{
2101        AppDataDictionary, AppDataDictionaryExtension, Extension as OpenMlsExtension, Extensions,
2102    };
2103
2104    /// Build a synthetic `Extensions<GroupContext>` that carries only
2105    /// an `AppDataDictionary`. `migrated=true` seeds
2106    /// `COMPONENT_REGISTRY` with placeholder bytes so
2107    /// `is_migrated_extensions` returns true; `migrated=false` leaves
2108    /// the marker absent.
2109    fn extensions_with_entries(
2110        migrated: bool,
2111        entries: &[(u16, Vec<u8>)],
2112    ) -> Extensions<openmls::group::GroupContext> {
2113        let mut dict = AppDataDictionary::new();
2114        if migrated {
2115            let _ = dict.insert(ComponentId::COMPONENT_REGISTRY.as_u16(), vec![0xCA; 4]);
2116        }
2117        for (id, bytes) in entries {
2118            let _ = dict.insert(*id, bytes.clone());
2119        }
2120        Extensions::from_vec(vec![OpenMlsExtension::AppDataDictionary(
2121            AppDataDictionaryExtension::new(dict),
2122        )])
2123        .expect("valid group-context extension set")
2124    }
2125
2126    // --- read_super_admin_list_from_extensions ------------------------------
2127
2128    #[xmtp_common::test]
2129    fn read_super_admin_list_unmigrated_returns_none() {
2130        // No COMPONENT_REGISTRY marker => unmigrated, overlay stays off
2131        // even if SUPER_ADMIN_LIST bytes happen to exist.
2132        let exts = extensions_with_entries(
2133            false,
2134            &[(
2135                ComponentId::SUPER_ADMIN_LIST.as_u16(),
2136                encode_inbox_id_set(&[fake_inbox_id(0x11)]).unwrap(),
2137            )],
2138        );
2139        assert!(
2140            read_super_admin_list_from_extensions(&exts)
2141                .unwrap()
2142                .is_none()
2143        );
2144    }
2145
2146    #[xmtp_common::test]
2147    fn read_super_admin_list_migrated_absent_returns_none() {
2148        // Migrated group but the dict has no SUPER_ADMIN_LIST entry —
2149        // `Ok(None)` rather than surfacing a malformed-value error.
2150        let exts = extensions_with_entries(true, &[]);
2151        assert!(
2152            read_super_admin_list_from_extensions(&exts)
2153                .unwrap()
2154                .is_none()
2155        );
2156    }
2157
2158    #[xmtp_common::test]
2159    fn read_super_admin_list_migrated_happy_path() {
2160        let ids = vec![fake_inbox_id(0xAA), fake_inbox_id(0xBB)];
2161        let bytes = encode_inbox_id_set(&ids).unwrap();
2162        let exts =
2163            extensions_with_entries(true, &[(ComponentId::SUPER_ADMIN_LIST.as_u16(), bytes)]);
2164        let got = read_super_admin_list_from_extensions(&exts)
2165            .unwrap()
2166            .unwrap();
2167        assert_eq!(got.len(), 2);
2168        // TlsSet sorts by value so 0xAA sorts before 0xBB.
2169        assert_eq!(got[0], fake_inbox_id(0xAA));
2170        assert_eq!(got[1], fake_inbox_id(0xBB));
2171    }
2172
2173    #[xmtp_common::test]
2174    fn read_super_admin_list_malformed_bytes_surface_error() {
2175        let exts = extensions_with_entries(
2176            true,
2177            &[(
2178                ComponentId::SUPER_ADMIN_LIST.as_u16(),
2179                vec![0x00, 0xDE, 0xAD],
2180            )],
2181        );
2182        let err = read_super_admin_list_from_extensions(&exts).unwrap_err();
2183        assert!(matches!(
2184            err,
2185            ComponentSourceError::MalformedComponentValue {
2186                component_id,
2187                ..
2188            } if component_id == ComponentId::SUPER_ADMIN_LIST
2189        ));
2190    }
2191
2192    // --- read_group_metadata_from_extensions --------------------------------
2193
2194    fn encode_conv_type_bytes(value: i32) -> Vec<u8> {
2195        value.to_be_bytes().to_vec()
2196    }
2197
2198    fn encode_dm_pair(tag_a: u8, tag_b: u8) -> Vec<u8> {
2199        encode_inbox_id_set(&[fake_inbox_id(tag_a), fake_inbox_id(tag_b)]).unwrap()
2200    }
2201
2202    /// Encode a single tagged inbox id in the `CREATOR_INBOX_ID` wire
2203    /// form (versioned `InboxId` TLS encoding).
2204    fn encode_creator_bytes(tag: u8) -> Vec<u8> {
2205        fake_inbox(tag).tls_serialize_detached().unwrap()
2206    }
2207
2208    #[xmtp_common::test]
2209    fn read_group_metadata_unmigrated_returns_none() {
2210        let exts = extensions_with_entries(
2211            false,
2212            &[
2213                (
2214                    ComponentId::CONVERSATION_TYPE.as_u16(),
2215                    encode_conv_type_bytes(1),
2216                ),
2217                (
2218                    ComponentId::CREATOR_INBOX_ID.as_u16(),
2219                    encode_creator_bytes(0x11),
2220                ),
2221            ],
2222        );
2223        assert!(
2224            read_group_metadata_from_extensions(&exts)
2225                .unwrap()
2226                .is_none()
2227        );
2228    }
2229
2230    #[xmtp_common::test]
2231    fn read_group_metadata_missing_required_seeds_returns_none() {
2232        // Migrated group but CONVERSATION_TYPE is absent — treat as
2233        // "seeds not ready yet" (Ok(None)) rather than malformed.
2234        let exts = extensions_with_entries(true, &[]);
2235        assert!(
2236            read_group_metadata_from_extensions(&exts)
2237                .unwrap()
2238                .is_none()
2239        );
2240    }
2241
2242    #[xmtp_common::test]
2243    fn read_group_metadata_happy_path_non_dm() {
2244        let exts = extensions_with_entries(
2245            true,
2246            &[
2247                (
2248                    ComponentId::CONVERSATION_TYPE.as_u16(),
2249                    encode_conv_type_bytes(1),
2250                ),
2251                (
2252                    ComponentId::CREATOR_INBOX_ID.as_u16(),
2253                    encode_creator_bytes(0x11),
2254                ),
2255            ],
2256        );
2257        let got = read_group_metadata_from_extensions(&exts).unwrap().unwrap();
2258        assert_eq!(got.conversation_type, 1);
2259        assert_eq!(got.creator_inbox_id, fake_inbox_id(0x11));
2260        assert!(got.dm_members.is_none());
2261        assert!(got.oneshot.is_none());
2262    }
2263
2264    #[xmtp_common::test]
2265    fn read_group_metadata_dm_happy_path() {
2266        // DM group — DM_MEMBERS decodes as TlsSet<InboxId>, re-shaped
2267        // to the proto's two-slot form.
2268        let exts = extensions_with_entries(
2269            true,
2270            &[
2271                (
2272                    ComponentId::CONVERSATION_TYPE.as_u16(),
2273                    encode_conv_type_bytes(2),
2274                ),
2275                (
2276                    ComponentId::CREATOR_INBOX_ID.as_u16(),
2277                    encode_creator_bytes(0x22),
2278                ),
2279                (ComponentId::DM_MEMBERS.as_u16(), encode_dm_pair(0x22, 0x33)),
2280            ],
2281        );
2282        let got = read_group_metadata_from_extensions(&exts).unwrap().unwrap();
2283        let dm = got.dm_members.unwrap();
2284        assert_eq!(dm.dm_member_one.unwrap().inbox_id, fake_inbox_id(0x22));
2285        assert_eq!(dm.dm_member_two.unwrap().inbox_id, fake_inbox_id(0x33));
2286    }
2287
2288    #[xmtp_common::test]
2289    fn read_group_metadata_dm_wrong_cardinality_errors() {
2290        // A 1-element TlsSet<InboxId> is invalid for DM_MEMBERS —
2291        // surfaces `MalformedComponentValue`.
2292        let one_element = encode_inbox_id_set(&[fake_inbox_id(0x44)]).unwrap();
2293        let exts = extensions_with_entries(
2294            true,
2295            &[
2296                (
2297                    ComponentId::CONVERSATION_TYPE.as_u16(),
2298                    encode_conv_type_bytes(2),
2299                ),
2300                (
2301                    ComponentId::CREATOR_INBOX_ID.as_u16(),
2302                    encode_creator_bytes(0x44),
2303                ),
2304                (ComponentId::DM_MEMBERS.as_u16(), one_element),
2305            ],
2306        );
2307        let err = read_group_metadata_from_extensions(&exts).unwrap_err();
2308        assert!(matches!(
2309            err,
2310            ComponentSourceError::MalformedComponentValue {
2311                component_id,
2312                ..
2313            } if component_id == ComponentId::DM_MEMBERS
2314        ));
2315    }
2316
2317    #[xmtp_common::test]
2318    fn read_group_metadata_malformed_creator_errors() {
2319        // CREATOR_INBOX_ID is the versioned `InboxId` TLS encoding —
2320        // a few stray bytes won't satisfy the varint length prefix
2321        // plus 32-byte payload, so deserialization must fail loud as
2322        // `MalformedComponentValue` rather than silently producing
2323        // a phantom inbox id.
2324        let exts = extensions_with_entries(
2325            true,
2326            &[
2327                (
2328                    ComponentId::CONVERSATION_TYPE.as_u16(),
2329                    encode_conv_type_bytes(1),
2330                ),
2331                (
2332                    ComponentId::CREATOR_INBOX_ID.as_u16(),
2333                    vec![0xFF, 0xFE, 0xFD],
2334                ),
2335            ],
2336        );
2337        let err = read_group_metadata_from_extensions(&exts).unwrap_err();
2338        assert!(matches!(
2339            err,
2340            ComponentSourceError::MalformedComponentValue {
2341                component_id,
2342                ..
2343            } if component_id == ComponentId::CREATOR_INBOX_ID
2344        ));
2345    }
2346
2347    // --- read_group_membership_from_dict ------------------------------------
2348
2349    #[xmtp_common::test]
2350    fn read_group_membership_unmigrated_returns_none() {
2351        use std::collections::BTreeMap;
2352        use xmtp_mls_common::app_data::migration::encode_group_membership_dict;
2353        use xmtp_proto::xmtp::mls::message_contents::{
2354            GroupMembershipEntry,
2355            group_membership_entry::{V1 as GroupMembershipEntryV1, Version},
2356        };
2357        let mut entries: BTreeMap<InboxId, GroupMembershipEntry> = BTreeMap::new();
2358        entries.insert(
2359            InboxId::from_bytes([0x11; INBOX_ID_BYTE_LEN]),
2360            GroupMembershipEntry {
2361                version: Some(Version::V1(GroupMembershipEntryV1 {
2362                    sequence_id: 1,
2363                    failed_installations: vec![],
2364                })),
2365            },
2366        );
2367        let bytes = encode_group_membership_dict(&entries).unwrap();
2368        let exts =
2369            extensions_with_entries(false, &[(ComponentId::GROUP_MEMBERSHIP.as_u16(), bytes)]);
2370        assert!(read_group_membership_from_dict(&exts).unwrap().is_none());
2371    }
2372
2373    #[xmtp_common::test]
2374    fn read_group_membership_happy_path_flattens_per_inbox() {
2375        use std::collections::BTreeMap;
2376        use xmtp_mls_common::app_data::migration::encode_group_membership_dict;
2377        use xmtp_proto::xmtp::mls::message_contents::{
2378            GroupMembershipEntry,
2379            group_membership_entry::{V1 as GroupMembershipEntryV1, Version},
2380        };
2381        let mut entries: BTreeMap<InboxId, GroupMembershipEntry> = BTreeMap::new();
2382        entries.insert(
2383            InboxId::from_bytes([0x11; INBOX_ID_BYTE_LEN]),
2384            GroupMembershipEntry {
2385                version: Some(Version::V1(GroupMembershipEntryV1 {
2386                    sequence_id: 7,
2387                    failed_installations: vec![vec![0xA1; 16]],
2388                })),
2389            },
2390        );
2391        entries.insert(
2392            InboxId::from_bytes([0x22; INBOX_ID_BYTE_LEN]),
2393            GroupMembershipEntry {
2394                version: Some(Version::V1(GroupMembershipEntryV1 {
2395                    sequence_id: 42,
2396                    failed_installations: vec![vec![0xB1; 16]],
2397                })),
2398            },
2399        );
2400        let bytes = encode_group_membership_dict(&entries).unwrap();
2401        let exts =
2402            extensions_with_entries(true, &[(ComponentId::GROUP_MEMBERSHIP.as_u16(), bytes)]);
2403
2404        let proto = read_group_membership_from_dict(&exts).unwrap().unwrap();
2405        // `members` is flat <hex_inbox_id, seq>
2406        assert_eq!(proto.members.len(), 2);
2407        assert_eq!(proto.members.get(&fake_inbox_id(0x11)), Some(&7));
2408        assert_eq!(proto.members.get(&fake_inbox_id(0x22)), Some(&42));
2409        // `failed_installations` concatenates the per-inbox lists.
2410        assert_eq!(proto.failed_installations.len(), 2);
2411    }
2412
2413    #[xmtp_common::test]
2414    fn read_group_membership_malformed_bytes_surface_error() {
2415        let exts = extensions_with_entries(
2416            true,
2417            &[(
2418                ComponentId::GROUP_MEMBERSHIP.as_u16(),
2419                vec![0xDE, 0xAD, 0xBE, 0xEF],
2420            )],
2421        );
2422        let err = read_group_membership_from_dict(&exts).unwrap_err();
2423        assert!(matches!(
2424            err,
2425            ComponentSourceError::MalformedComponentValue {
2426                component_id,
2427                ..
2428            } if component_id == ComponentId::GROUP_MEMBERSHIP
2429        ));
2430    }
2431
2432    // ========================================================================
2433    // merge_app_data_into_mutable_metadata_from_extensions —
2434    //   independence from COMPONENT_REGISTRY parseability
2435    // ========================================================================
2436    //
2437    // These pin the call-graph invariant that a malformed
2438    // `COMPONENT_REGISTRY` does **not** cause `mutable_metadata()` to
2439    // drop authoritative dict-backed fields. The migration-marker check
2440    // (`is_migrated_extensions`) uses key existence; the merge function
2441    // reads each metadata field directly from the dict; nothing on this
2442    // read path calls `load_component_registry`. Registry corruption is
2443    // surfaced loudly on the *write* paths (sender gate in `mls_sync.rs`
2444    // and the commit validator in `validated_commit.rs`), where it
2445    // belongs.
2446    //
2447    // Note: the existing `extensions_with_entries(migrated=true, …)`
2448    // helper already seeds `COMPONENT_REGISTRY` with placeholder bytes
2449    // (`vec![0xCA; 4]`) that don't decode as a valid registry, so every
2450    // migrated-test in this file already exercises the malformed-
2451    // registry branch implicitly. The tests below pin it explicitly so
2452    // a reviewer doesn't have to chase the helper to verify.
2453
2454    use xmtp_mls_common::group_mutable_metadata::{GroupMutableMetadata, MetadataField};
2455
2456    fn empty_base_gmm() -> GroupMutableMetadata {
2457        GroupMutableMetadata::new(std::collections::HashMap::new(), Vec::new(), Vec::new())
2458    }
2459
2460    #[xmtp_common::test]
2461    fn merge_with_malformed_registry_returns_valid_field() {
2462        // Reviewer's alleged "data loss" scenario: COMPONENT_REGISTRY
2463        // contains malformed bytes, but a metadata field (GROUP_NAME)
2464        // is present and valid. The merge function does NOT validate
2465        // the registry — it reads GROUP_NAME directly — so the result
2466        // must contain "My Group" with no error.
2467        let exts = extensions_with_entries(
2468            true, // seeds COMPONENT_REGISTRY with non-decodable 0xCA bytes
2469            &[(ComponentId::GROUP_NAME.as_u16(), b"My Group".to_vec())],
2470        );
2471        let mut base = empty_base_gmm();
2472        merge_app_data_into_mutable_metadata_from_extensions(&mut base, &exts)
2473            .expect("merge ignores registry parseability and reads field directly");
2474        assert_eq!(
2475            base.attributes
2476                .get(MetadataField::GroupName.as_str())
2477                .map(String::as_str),
2478            Some("My Group"),
2479        );
2480    }
2481
2482    #[xmtp_common::test]
2483    fn merge_unmigrated_is_noop() {
2484        // Sanity: pre-migration, the merge gate stays closed — even
2485        // if a stray dict entry exists, the base GMM is left untouched
2486        // so legacy GMM remains authoritative.
2487        let exts = extensions_with_entries(
2488            false,
2489            &[(ComponentId::GROUP_NAME.as_u16(), b"Stray".to_vec())],
2490        );
2491        let mut base = empty_base_gmm();
2492        merge_app_data_into_mutable_metadata_from_extensions(&mut base, &exts).unwrap();
2493        assert!(
2494            !base
2495                .attributes
2496                .contains_key(MetadataField::GroupName.as_str()),
2497            "merge must be a no-op on unmigrated extensions"
2498        );
2499    }
2500
2501    #[xmtp_common::test]
2502    fn merge_with_malformed_field_surfaces_error_not_silent_loss() {
2503        // The other half of the invariant: when a metadata field's bytes
2504        // ARE malformed, the merge fails loudly with
2505        // `MalformedComponentValue` carrying the offending component id —
2506        // it never silently swallows the value. Pairs with the test
2507        // above to disprove "all metadata may be lost" framings.
2508        let exts = extensions_with_entries(
2509            true,
2510            &[(ComponentId::ADMIN_LIST.as_u16(), vec![0xff, 0xff, 0xff])],
2511        );
2512        let mut base = empty_base_gmm();
2513        let err =
2514            merge_app_data_into_mutable_metadata_from_extensions(&mut base, &exts).unwrap_err();
2515        assert!(
2516            matches!(
2517                err,
2518                ComponentSourceError::MalformedComponentValue { component_id, .. }
2519                    if component_id == ComponentId::ADMIN_LIST
2520            ),
2521            "expected MalformedComponentValue for ADMIN_LIST, got: {err:?}"
2522        );
2523    }
2524}