Skip to main content

xmtp_mls_common/app_data/
component_registry.rs

1use crate::tls_map::TlsMap;
2use prost::Message;
3use tls_codec::{Deserialize, Serialize, VLBytes};
4use xmtp_proto::xmtp::mls::message_contents::{
5    ComponentMetadata, ComponentPermissions, ComponentType, MetadataPolicy as MetadataPolicyProto,
6    metadata_policy::{Kind as MetadataPolicyKind, MetadataBasePolicy},
7};
8
9use super::component_id::ComponentId;
10
11/// The operation being performed on a component.
12///
13/// Each variant maps to one of the policy fields in
14/// [`ComponentPermissions`](xmtp_proto::xmtp::mls::message_contents::ComponentPermissions):
15/// `Insert` → `insert_policy`, `Update` → `update_policy`, `Delete` → `delete_policy`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ComponentOp {
18    /// Creating a new value (component does not yet exist).
19    Insert,
20    /// Modifying an existing value.
21    Update,
22    /// Removing a value.
23    Delete,
24}
25
26impl std::fmt::Display for ComponentOp {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            ComponentOp::Insert => write!(f, "insert"),
30            ComponentOp::Update => write!(f, "update"),
31            ComponentOp::Delete => write!(f, "delete"),
32        }
33    }
34}
35
36/// Helper to construct a [`ComponentMetadata`] from permissions and type.
37pub fn new_component_metadata(
38    permissions: ComponentPermissions,
39    component_type: ComponentType,
40) -> ComponentMetadata {
41    ComponentMetadata {
42        permissions: Some(permissions),
43        component_type: component_type as i32,
44        external_committer_permissions: None,
45    }
46}
47
48#[derive(Debug, thiserror::Error)]
49pub enum ComponentRegistryError {
50    #[error("component ID {0} is not in the component ID space")]
51    InvalidComponentId(ComponentId),
52    #[error("component ID {0} is in the reserved range")]
53    ReservedRange(ComponentId),
54    #[error("immutable component {0} cannot be modified after initial insert")]
55    ImmutableComponent(ComponentId),
56    #[error("hardcoded component {0} cannot be removed")]
57    HardcodedComponent(ComponentId),
58    #[error("component {0} not found")]
59    NotFound(ComponentId),
60    #[error("component {0} metadata is missing the permissions field")]
61    MissingPermissions(ComponentId),
62    #[error("component {0} metadata is missing the {1} policy field")]
63    MissingPolicyField(ComponentId, ComponentOp),
64    #[error("decode error for component {component_id}: {source}")]
65    DecodeError {
66        component_id: ComponentId,
67        #[source]
68        source: prost::DecodeError,
69    },
70    #[error("tls codec error: {0}")]
71    TlsCodecError(#[from] tls_codec::Error),
72    #[error("constrained component {0} requires AllowIfAdmin or AllowIfSuperAdmin policies")]
73    ConstrainedPolicyViolation(ComponentId),
74}
75
76/// A component registry stored as a `TlsMap<ComponentId, VLBytes>` where
77/// each value is a protobuf-encoded [`ComponentMetadata`] describing the
78/// component's data type and permission policies.
79///
80/// The registry provides deterministic TLS serialization (sorted by
81/// ComponentId) and enforces that hardcoded and reserved component IDs
82/// cannot be modified through this map (their permissions are enforced in
83/// code).
84///
85/// Stored at well-known component ID `0x8000` (`ComponentId::COMPONENT_REGISTRY`).
86///
87/// ## One raw map, a validated view
88///
89/// The map holds every entry exactly as it was read, byte for byte —
90/// including entries this build cannot validate. Validation is applied
91/// lazily on read: [`get`](Self::get), [`iter`](Self::iter),
92/// [`contains`](Self::contains), and [`len`](Self::len) present only
93/// *recognized* entries (those that pass
94/// [`validate_entry`](Self::validate_entry)). An entry that fails
95/// validation is invisible to all of them, so a write against it falls to
96/// the deny-by-default `NoRegistryEntry` policy verdict. The raw bytes
97/// still surface through [`to_bytes`](Self::to_bytes) (verbatim) and
98/// [`unrecognized_ids`](Self::unrecognized_ids) (a diagnostic).
99///
100/// Two invariants motivate carrying bytes we can't read:
101///
102/// - **Never fork by dropping bytes.** The registry lives inside the
103///   `app_data_dictionary` group-context extension; every member must
104///   compute byte-identical dict bytes or the group splits. A committer
105///   reads the dict, changes one component, and re-emits the whole thing,
106///   so it must round-trip untouched entries exactly — and prost does not
107///   preserve unknown proto fields across a decode/re-encode, which is why
108///   we keep raw `VLBytes` and never re-serialize an entry we didn't
109///   author.
110/// - **Never brick on state we didn't write.** A single entry left by a
111///   newer protocol version, or by a historically buggy writer (a poisoned
112///   group), must degrade to "that one component is unwritable here," never
113///   to "no commit on this group validates again." Rejecting the whole
114///   snapshot would do the latter, because the registry is decoded on the
115///   validation path of *every* commit.
116///
117/// Tolerance is a read-side backstop, not an enforcement hole: new entries
118/// still cannot *enter* a group's registry unvalidated (the steady-state
119/// wire path validates per-mutation and the bootstrap validator validates
120/// each entry of the initial delta).
121#[derive(Debug, Clone, PartialEq)]
122pub struct ComponentRegistry {
123    inner: TlsMap<ComponentId, VLBytes>,
124}
125
126impl ComponentRegistry {
127    pub fn new() -> Self {
128        Self {
129            inner: TlsMap::new(),
130        }
131    }
132
133    /// Get the metadata for a *recognized* component.
134    ///
135    /// Returns `Ok(None)` both when no entry exists and when an entry exists
136    /// but fails [`validate_entry`](Self::validate_entry) — an unreadable
137    /// entry is deny-by-default, indistinguishable from absent, so callers
138    /// never have to tell the two apart. The `Result` is retained for API
139    /// stability; this method does not currently produce an error.
140    pub fn get(
141        &self,
142        id: &ComponentId,
143    ) -> Result<Option<ComponentMetadata>, ComponentRegistryError> {
144        Ok(self
145            .inner
146            .get(id)
147            .and_then(|raw| Self::decode_recognized(*id, raw.as_slice()).ok()))
148    }
149
150    /// Register or update a component's metadata.
151    ///
152    /// For mutable components in the registry, this silently overwrites any
153    /// existing entry — there is no audit log here because the audit trail
154    /// lives in the MLS commit history that produced the change.
155    ///
156    /// A valid write to a mutable id that currently holds an unrecognized
157    /// entry repairs it — the raw bytes are overwritten, so `to_bytes`
158    /// serializes the repaired value rather than resurrecting the broken
159    /// bytes.
160    ///
161    /// Rejects invalid IDs, IDs in the reserved range, hardcoded components
162    /// (whose permissions are enforced in code, not metadata), immutable
163    /// components that already hold any raw entry (write-once semantics),
164    /// metadata missing required fields, and constrained components with
165    /// invalid policy values.
166    pub fn set(
167        &mut self,
168        id: ComponentId,
169        meta: ComponentMetadata,
170    ) -> Result<(), ComponentRegistryError> {
171        self.validate_modifiable(&id)?;
172        Self::validate_metadata(&id, &meta)?;
173        let bytes = VLBytes::new(meta.encode_to_vec());
174        self.inner.set(id, bytes);
175        Ok(())
176    }
177
178    /// Remove a component from the registry.
179    ///
180    /// Rejects everything [`set`](Self::set) rejects (invalid IDs, reserved,
181    /// hardcoded, and overwrite of a write-once-immutable entry). Removing a
182    /// modifiable id drops its bytes whether the entry was recognized or an
183    /// unrecognized one preserved by [`from_bytes`](Self::from_bytes)
184    /// (repair-by-delete).
185    pub fn remove(&mut self, id: &ComponentId) -> Result<(), ComponentRegistryError> {
186        self.validate_modifiable(id)?;
187        self.inner
188            .remove(id)
189            .map_err(|_| ComponentRegistryError::NotFound(*id))?;
190        Ok(())
191    }
192
193    /// Returns true if the registry contains a *recognized* entry for the
194    /// given component. An unrecognized (preserved-but-invalid) entry
195    /// reports `false`.
196    pub fn contains(&self, id: &ComponentId) -> bool {
197        self.inner
198            .get(id)
199            .is_some_and(|raw| Self::decode_recognized(*id, raw.as_slice()).is_ok())
200    }
201
202    /// Returns true if the registry holds any bytes for `id`, recognized
203    /// or not. Reconciliation keys off this rather than
204    /// [`contains`](Self::contains): an entry this build cannot read is
205    /// still the group's entry and must not be replaced from a local copy.
206    // implements: META-067
207    pub fn contains_raw(&self, id: &ComponentId) -> bool {
208        self.inner.contains_key(id)
209    }
210
211    /// Returns the number of *recognized* entries. Unrecognized entries
212    /// preserved by [`from_bytes`](Self::from_bytes) are not counted.
213    pub fn len(&self) -> usize {
214        self.iter().count()
215    }
216
217    /// Returns true if the registry has no recognized entries. A registry
218    /// carrying only unrecognized entries still reports empty.
219    pub fn is_empty(&self) -> bool {
220        self.iter().next().is_none()
221    }
222
223    /// Iterate over the *recognized* component IDs and their decoded
224    /// metadata, in ComponentId order. Unrecognized entries preserved by
225    /// [`from_bytes`](Self::from_bytes) are skipped — writes against them
226    /// fall to deny-by-default — and are surfaced via
227    /// [`unrecognized_ids`](Self::unrecognized_ids) instead.
228    ///
229    /// Each item is a `Result` for API stability; a recognized entry always
230    /// decodes, so this only ever yields `Ok`.
231    pub fn iter(
232        &self,
233    ) -> impl Iterator<Item = Result<(ComponentId, ComponentMetadata), ComponentRegistryError>> + '_
234    {
235        self.inner.iter().filter_map(|(&id, raw)| {
236            Self::decode_recognized(id, raw.as_slice())
237                .ok()
238                .map(|meta| Ok((id, meta)))
239        })
240    }
241
242    /// Serialize the registry as the **dict-storage format**: the raw
243    /// `TlsMap<ComponentId, VLBytes>` snapshot, verbatim. Because the map is
244    /// stored exactly as read (recognized and unrecognized entries alike), a
245    /// load → store round-trip is byte-identical and never drops data
246    /// another (possibly newer) client wrote.
247    ///
248    /// Wire-format encoding (a `TlsMapDelta` describing changes) is done
249    /// piecemeal at the call site — there is no whole-registry wire encoder,
250    /// because every steady-state update emits only the few entries it
251    /// touches, and the bootstrap encoder builds its `TlsMapDelta`-from-empty
252    /// inline at the synthesis site (see
253    /// `xmtp_mls::groups::app_data::migration::synthesize_initial_component_values`).
254    // implements: META-016
255    pub fn to_bytes(&self) -> Result<Vec<u8>, ComponentRegistryError> {
256        Ok(self.inner.tls_serialize_detached()?)
257    }
258
259    /// Deserialize a component registry from its **dict-storage format**: a
260    /// raw `TlsMap<ComponentId, VLBytes>` snapshot.
261    ///
262    /// Fails only when the outer `TlsMap` doesn't decode (truncated /
263    /// non-canonical bytes). Individual entries are *not* validated here —
264    /// they're kept raw and validated lazily on read, so an entry from a
265    /// newer protocol version (or a historical invalid entry) is preserved
266    /// rather than making every future commit on the group unvalidatable.
267    /// Callers that care can inspect
268    /// [`unrecognized_ids`](Self::unrecognized_ids) and log.
269    ///
270    /// New entries cannot *enter* a group's registry unvalidated: the
271    /// steady-state wire path validates per-mutation
272    /// (`ComponentRegistryComponent::apply_update_payload` /
273    /// `expand_to_changes`) and the bootstrap validator validates each entry
274    /// of the initial delta. Tolerance here is the read-side backstop, not
275    /// the enforcement point.
276    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ComponentRegistryError> {
277        let inner = TlsMap::<ComponentId, VLBytes>::tls_deserialize_exact(bytes)?;
278        Ok(Self { inner })
279    }
280
281    /// Component ids of entries [`from_bytes`](Self::from_bytes) preserved
282    /// but that could not validate. Empty in the overwhelmingly common case;
283    /// non-empty means the dict was written by a newer protocol version (or
284    /// carries a historical invalid entry) and is worth a log line at the
285    /// load site.
286    pub fn unrecognized_ids(&self) -> impl Iterator<Item = ComponentId> + '_ {
287        self.inner.iter().filter_map(|(&id, raw)| {
288            Self::decode_recognized(id, raw.as_slice())
289                .is_err()
290                .then_some(id)
291        })
292    }
293
294    /// Validate one `(ComponentId, raw bytes)` entry against the registry's
295    /// invariants — the single definition of "recognized." Used by the
296    /// wire-decode path in the bootstrap validator (which walks a
297    /// `TlsMapDelta`'s mutations directly so it can surface the `Insert`-only
298    /// check before per-entry validation) and, internally, by every read
299    /// method.
300    pub fn validate_entry(id: ComponentId, raw: &[u8]) -> Result<(), ComponentRegistryError> {
301        Self::decode_recognized(id, raw).map(|_| ())
302    }
303
304    /// Decode and fully validate a stored entry, returning its metadata iff
305    /// the entry is recognized. This is the one place that decides
306    /// visibility: [`get`](Self::get), [`iter`](Self::iter),
307    /// [`contains`](Self::contains), [`len`](Self::len), and
308    /// [`unrecognized_ids`](Self::unrecognized_ids) are all defined in terms
309    /// of whether this returns `Ok`.
310    ///
311    /// Rejects ids outside the component space, reserved ids, hardcoded ids
312    /// (their permissions are enforced in code), values that don't decode as
313    /// `ComponentMetadata`, and metadata that is structurally incomplete or
314    /// violates a constrained component's policy allowlist.
315    // implements: META-014
316    fn decode_recognized(
317        id: ComponentId,
318        raw: &[u8],
319    ) -> Result<ComponentMetadata, ComponentRegistryError> {
320        if !id.is_in_component_space() {
321            return Err(ComponentRegistryError::InvalidComponentId(id));
322        }
323        if id.is_reserved() {
324            return Err(ComponentRegistryError::ReservedRange(id));
325        }
326        if id.is_hardcoded() {
327            return Err(ComponentRegistryError::HardcodedComponent(id));
328        }
329        let meta = ComponentMetadata::decode(raw).map_err(|source| {
330            ComponentRegistryError::DecodeError {
331                component_id: id,
332                source,
333            }
334        })?;
335        Self::validate_metadata(&id, &meta)?;
336        Ok(meta)
337    }
338
339    /// Validate that the registry entry for `id` can be inserted, updated,
340    /// or removed.
341    ///
342    /// Rejects:
343    /// - IDs outside the component ID space
344    /// - IDs in the reserved range
345    /// - Hardcoded IDs (their permissions are enforced in code; allowing
346    ///   metadata entries here would create a silent disagreement between
347    ///   what the registry says and what `validate_component_write` actually
348    ///   enforces)
349    /// - Immutable IDs that already hold any raw entry (write-once). An
350    ///   entry this build cannot read may be a newer client's valid one, so
351    ///   write-once covers it too.
352    // implements: META-015
353    fn validate_modifiable(&self, id: &ComponentId) -> Result<(), ComponentRegistryError> {
354        if !id.is_in_component_space() {
355            return Err(ComponentRegistryError::InvalidComponentId(*id));
356        }
357        if id.is_reserved() {
358            return Err(ComponentRegistryError::ReservedRange(*id));
359        }
360        if id.is_hardcoded() {
361            return Err(ComponentRegistryError::HardcodedComponent(*id));
362        }
363        if id.is_immutable() && self.contains_raw(id) {
364            return Err(ComponentRegistryError::ImmutableComponent(*id));
365        }
366        Ok(())
367    }
368
369    /// Validate that the metadata for a component is structurally complete
370    /// and (for constrained components) uses allowed policy values.
371    ///
372    /// Every component must have:
373    /// - `permissions` set to `Some`
374    /// - All three policy fields (`insert_policy`, `update_policy`,
375    ///   `delete_policy`) set to `Some`
376    ///
377    /// Constrained components (e.g. `ADMIN_LIST`) additionally require each
378    /// policy to be the base policy `Deny`, `AllowIfAdmin` (admin or super
379    /// admin), or `AllowIfSuperAdmin` (super admin only). Combinator policies
380    /// (`AndCondition` / `AnyCondition`) are rejected for constrained
381    /// components.
382    fn validate_metadata(
383        id: &ComponentId,
384        meta: &ComponentMetadata,
385    ) -> Result<(), ComponentRegistryError> {
386        let perms = meta
387            .permissions
388            .as_ref()
389            .ok_or(ComponentRegistryError::MissingPermissions(*id))?;
390
391        for (op, policy) in [
392            (ComponentOp::Insert, &perms.insert_policy),
393            (ComponentOp::Update, &perms.update_policy),
394            (ComponentOp::Delete, &perms.delete_policy),
395        ] {
396            let p = policy
397                .as_ref()
398                .ok_or(ComponentRegistryError::MissingPolicyField(*id, op))?;
399
400            if id.is_constrained() && !Self::is_admin_or_super_admin_or_deny_policy(p) {
401                return Err(ComponentRegistryError::ConstrainedPolicyViolation(*id));
402            }
403        }
404        Ok(())
405    }
406
407    /// Returns true if the policy is the base policy `Deny`,
408    /// `AllowIfAdmin` (admin or super admin), or `AllowIfSuperAdmin`
409    /// (super admin only).
410    ///
411    /// All variants are matched explicitly with no catch-all so that adding a
412    /// new `MetadataPolicyKind` variant in the proto forces a compile error
413    /// here, requiring an explicit decision about how it interacts with
414    /// constrained components.
415    fn is_admin_or_super_admin_or_deny_policy(policy: &MetadataPolicyProto) -> bool {
416        match &policy.kind {
417            Some(MetadataPolicyKind::Base(base)) => {
418                // XIP-81 §2.2.1 lists only AllowIfAdmin and
419                // AllowIfSuperAdmin for ADMIN_LIST. The shipped constraint
420                // also accepts Deny because PolicySet::new_dm() uses Deny
421                // for its admin slots; without it, the DM preset cannot be
422                // synthesized into the dictionary.
423                *base == MetadataBasePolicy::Deny as i32
424                    || *base == MetadataBasePolicy::AllowIfAdmin as i32
425                    || *base == MetadataBasePolicy::AllowIfSuperAdmin as i32
426            }
427            // Combinator policies are explicitly rejected for constrained
428            // components — even if every leaf is admin-only, the combinator
429            // wrapper itself is not on the constrained-component allowlist.
430            Some(MetadataPolicyKind::AndCondition(_)) => false,
431            Some(MetadataPolicyKind::AnyCondition(_)) => false,
432            None => false,
433        }
434    }
435}
436
437impl Default for ComponentRegistry {
438    fn default() -> Self {
439        Self::new()
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::app_data::component_permissions::component_permissions;
447    use xmtp_proto::xmtp::mls::message_contents::metadata_policy::{AndCondition, AnyCondition};
448
449    fn allow() -> MetadataPolicyProto {
450        MetadataPolicyProto {
451            kind: Some(MetadataPolicyKind::Base(MetadataBasePolicy::Allow as i32)),
452        }
453    }
454
455    fn deny() -> MetadataPolicyProto {
456        MetadataPolicyProto {
457            kind: Some(MetadataPolicyKind::Base(MetadataBasePolicy::Deny as i32)),
458        }
459    }
460
461    fn admin_only() -> MetadataPolicyProto {
462        MetadataPolicyProto {
463            kind: Some(MetadataPolicyKind::Base(
464                MetadataBasePolicy::AllowIfAdmin as i32,
465            )),
466        }
467    }
468
469    /// An `AndCondition` whose leaves are all `AllowIfAdmin`. The combinator
470    /// wrapper itself must still be rejected for constrained components,
471    /// regardless of how admin-y its contents are.
472    fn and_condition_admin_only() -> MetadataPolicyProto {
473        MetadataPolicyProto {
474            kind: Some(MetadataPolicyKind::AndCondition(AndCondition {
475                policies: vec![admin_only(), admin_only()],
476            })),
477        }
478    }
479
480    /// An `AnyCondition` whose leaves are all `AllowIfAdmin`. Same rationale
481    /// as `and_condition_admin_only`.
482    fn any_condition_admin_only() -> MetadataPolicyProto {
483        MetadataPolicyProto {
484            kind: Some(MetadataPolicyKind::AnyCondition(AnyCondition {
485                policies: vec![admin_only(), admin_only()],
486            })),
487        }
488    }
489
490    fn sample_meta() -> ComponentMetadata {
491        new_component_metadata(
492            component_permissions()
493                .insert(allow())
494                .update(admin_only())
495                .delete(deny())
496                .call(),
497            ComponentType::Bytes,
498        )
499    }
500
501    #[xmtp_common::test]
502    fn test_set_and_get() {
503        let mut reg = ComponentRegistry::new();
504        let id = ComponentId::GROUP_NAME;
505        reg.set(id, sample_meta()).unwrap();
506        let meta = reg.get(&id).unwrap().unwrap();
507        assert_eq!(meta, sample_meta());
508    }
509
510    #[xmtp_common::test]
511    fn test_set_overwrites() {
512        let mut reg = ComponentRegistry::new();
513        let id = ComponentId::GROUP_NAME;
514        reg.set(id, sample_meta()).unwrap();
515
516        let new_meta = new_component_metadata(
517            component_permissions()
518                .insert(deny())
519                .update(deny())
520                .delete(deny())
521                .call(),
522            ComponentType::TlsMapBytesBytes,
523        );
524        reg.set(id, new_meta.clone()).unwrap();
525
526        let got = reg.get(&id).unwrap().unwrap();
527        assert_eq!(got, new_meta);
528        assert_eq!(reg.len(), 1);
529    }
530
531    #[xmtp_common::test]
532    fn test_remove() {
533        let mut reg = ComponentRegistry::new();
534        let id = ComponentId::GROUP_NAME;
535        reg.set(id, sample_meta()).unwrap();
536        reg.remove(&id).unwrap();
537        assert!(!reg.contains(&id));
538    }
539
540    #[xmtp_common::test]
541    fn test_remove_missing_returns_error() {
542        let mut reg = ComponentRegistry::new();
543        let result = reg.remove(&ComponentId::GROUP_NAME);
544        assert!(result.is_err());
545    }
546
547    #[xmtp_common::test(unwrap_try = true)]
548    fn test_reject_hardcoded_set() {
549        // Hardcoded components must NEVER have a registry entry. Their
550        // permissions are enforced in code by `validate_component_write`,
551        // and a stored entry would create a silent disagreement between the
552        // registry and the actual enforcement path.
553        let mut reg = ComponentRegistry::new();
554        assert!(matches!(
555            reg.set(ComponentId::COMPONENT_REGISTRY, sample_meta()),
556            Err(ComponentRegistryError::HardcodedComponent(_))
557        ));
558        assert!(matches!(
559            reg.set(ComponentId::SUPER_ADMIN_LIST, sample_meta()),
560            Err(ComponentRegistryError::HardcodedComponent(_))
561        ));
562        assert!(reg.is_empty());
563    }
564
565    #[xmtp_common::test(unwrap_try = true)]
566    fn test_reject_hardcoded_remove() {
567        // Even though hardcoded entries should never be in the registry, the
568        // remove path still rejects them for defense in depth.
569        let mut reg = ComponentRegistry::new();
570        assert!(matches!(
571            reg.remove(&ComponentId::COMPONENT_REGISTRY),
572            Err(ComponentRegistryError::HardcodedComponent(_))
573        ));
574        assert!(matches!(
575            reg.remove(&ComponentId::SUPER_ADMIN_LIST),
576            Err(ComponentRegistryError::HardcodedComponent(_))
577        ));
578    }
579
580    #[xmtp_common::test]
581    fn test_admin_list_accepts_admin_or_super_admin_policy() {
582        let mut reg = ComponentRegistry::new();
583        let meta = new_component_metadata(
584            component_permissions()
585                .insert(admin_only())
586                .update(admin_only())
587                .delete(admin_only())
588                .call(),
589            ComponentType::Bytes,
590        );
591        assert!(reg.set(ComponentId::ADMIN_LIST, meta).is_ok());
592    }
593
594    #[xmtp_common::test]
595    fn test_admin_list_accepts_super_admin_only_policy() {
596        let mut reg = ComponentRegistry::new();
597        let super_admin_only = MetadataPolicyProto {
598            kind: Some(MetadataPolicyKind::Base(
599                MetadataBasePolicy::AllowIfSuperAdmin as i32,
600            )),
601        };
602        let meta = new_component_metadata(
603            component_permissions()
604                .insert(super_admin_only.clone())
605                .update(super_admin_only.clone())
606                .delete(super_admin_only)
607                .call(),
608            ComponentType::Bytes,
609        );
610        assert!(reg.set(ComponentId::ADMIN_LIST, meta).is_ok());
611    }
612
613    #[xmtp_common::test]
614    fn test_admin_list_rejects_allow_policy() {
615        let mut reg = ComponentRegistry::new();
616        let meta = new_component_metadata(
617            component_permissions()
618                .insert(allow())
619                .update(allow())
620                .delete(allow())
621                .call(),
622            ComponentType::Bytes,
623        );
624        assert!(matches!(
625            reg.set(ComponentId::ADMIN_LIST, meta),
626            Err(ComponentRegistryError::ConstrainedPolicyViolation(_))
627        ));
628    }
629
630    #[xmtp_common::test(unwrap_try = true)]
631    fn test_admin_list_accepts_deny_policy() {
632        let mut reg = ComponentRegistry::new();
633        let meta = new_component_metadata(
634            component_permissions()
635                .insert(admin_only())
636                .update(deny())
637                .delete(admin_only())
638                .call(),
639            ComponentType::Bytes,
640        );
641        assert!(reg.set(ComponentId::ADMIN_LIST, meta).is_ok());
642    }
643
644    #[xmtp_common::test]
645    fn test_admin_list_rejects_mixed_invalid_policy() {
646        let mut reg = ComponentRegistry::new();
647        let meta = new_component_metadata(
648            component_permissions()
649                .insert(admin_only())
650                .update(allow())
651                .delete(admin_only())
652                .call(),
653            ComponentType::Bytes,
654        );
655        assert!(matches!(
656            reg.set(ComponentId::ADMIN_LIST, meta),
657            Err(ComponentRegistryError::ConstrainedPolicyViolation(_))
658        ));
659    }
660
661    #[xmtp_common::test]
662    fn test_admin_list_rejects_and_condition_policy() {
663        // Combinator policies must be rejected for constrained components
664        // even when every leaf is admin-only — it's the wrapper itself that's
665        // disallowed, not the contents. Locks in the explicit rejection in
666        // `is_admin_or_super_admin_or_deny_policy` so a future refactor of
667        // that match
668        // can't silently let combinators through.
669        let mut reg = ComponentRegistry::new();
670        let meta = new_component_metadata(
671            component_permissions()
672                .insert(and_condition_admin_only())
673                .update(admin_only())
674                .delete(admin_only())
675                .call(),
676            ComponentType::Bytes,
677        );
678        assert!(matches!(
679            reg.set(ComponentId::ADMIN_LIST, meta),
680            Err(ComponentRegistryError::ConstrainedPolicyViolation(_))
681        ));
682    }
683
684    #[xmtp_common::test]
685    fn test_admin_list_rejects_any_condition_policy() {
686        // Same rationale as `test_admin_list_rejects_and_condition_policy`,
687        // but exercising the `AnyCondition` branch on a different policy slot
688        // so all three slots and both combinator variants are covered across
689        // the constrained-component test cluster.
690        let mut reg = ComponentRegistry::new();
691        let meta = new_component_metadata(
692            component_permissions()
693                .insert(admin_only())
694                .update(any_condition_admin_only())
695                .delete(admin_only())
696                .call(),
697            ComponentType::Bytes,
698        );
699        assert!(matches!(
700            reg.set(ComponentId::ADMIN_LIST, meta),
701            Err(ComponentRegistryError::ConstrainedPolicyViolation(_))
702        ));
703    }
704
705    #[xmtp_common::test]
706    // verifies: META-014
707    fn test_rejects_missing_permissions() {
708        let mut reg = ComponentRegistry::new();
709        // Construct ComponentMetadata directly (bypassing new_component_metadata)
710        // because the helper doesn't allow `permissions: None` — that's exactly
711        // the negative case we're testing here.
712        let meta = ComponentMetadata {
713            permissions: None,
714            external_committer_permissions: None,
715            component_type: ComponentType::Bytes as i32,
716        };
717        // Applies to ALL components, not just constrained ones.
718        assert!(matches!(
719            reg.set(ComponentId::GROUP_NAME, meta.clone()),
720            Err(ComponentRegistryError::MissingPermissions(_))
721        ));
722        assert!(matches!(
723            reg.set(ComponentId::ADMIN_LIST, meta),
724            Err(ComponentRegistryError::MissingPermissions(_))
725        ));
726    }
727
728    #[xmtp_common::test]
729    // verifies: META-014
730    fn test_rejects_missing_policy_field() {
731        let mut reg = ComponentRegistry::new();
732        let meta = new_component_metadata(
733            ComponentPermissions {
734                insert_policy: Some(allow()),
735                update_policy: None, // missing
736                delete_policy: Some(allow()),
737            },
738            ComponentType::Bytes,
739        );
740        // Applies to ALL components, not just constrained ones.
741        assert!(matches!(
742            reg.set(ComponentId::GROUP_NAME, meta),
743            Err(ComponentRegistryError::MissingPolicyField(
744                _,
745                ComponentOp::Update
746            ))
747        ));
748    }
749
750    #[xmtp_common::test]
751    fn test_reject_reserved_set() {
752        let mut reg = ComponentRegistry::new();
753        assert!(matches!(
754            reg.set(ComponentId::new(0xFF00), sample_meta()),
755            Err(ComponentRegistryError::ReservedRange(_))
756        ));
757    }
758
759    #[xmtp_common::test]
760    fn test_reject_invalid_id() {
761        let mut reg = ComponentRegistry::new();
762        assert!(matches!(
763            reg.set(ComponentId::new(0x0001), sample_meta()),
764            Err(ComponentRegistryError::InvalidComponentId(_))
765        ));
766    }
767
768    #[xmtp_common::test]
769    fn test_app_range_allowed() {
770        let mut reg = ComponentRegistry::new();
771        let id = ComponentId::new(0xC000);
772        reg.set(id, sample_meta()).unwrap();
773        assert!(reg.contains(&id));
774    }
775
776    #[xmtp_common::test]
777    fn test_immutable_first_insert_allowed() {
778        // Immutable components can be inserted once.
779        let mut reg = ComponentRegistry::new();
780        let id = ComponentId::new(0xBE00);
781        reg.set(id, sample_meta()).unwrap();
782        assert!(reg.contains(&id));
783    }
784
785    #[xmtp_common::test]
786    fn test_immutable_subsequent_set_rejected() {
787        let mut reg = ComponentRegistry::new();
788        let id = ComponentId::new(0xBE00);
789        reg.set(id, sample_meta()).unwrap();
790        // Second set on the same immutable id is rejected.
791        assert!(matches!(
792            reg.set(id, sample_meta()),
793            Err(ComponentRegistryError::ImmutableComponent(_))
794        ));
795    }
796
797    #[xmtp_common::test]
798    fn test_immutable_remove_rejected() {
799        let mut reg = ComponentRegistry::new();
800        let id = ComponentId::new(0xBE00);
801        reg.set(id, sample_meta()).unwrap();
802        assert!(matches!(
803            reg.remove(&id),
804            Err(ComponentRegistryError::ImmutableComponent(_))
805        ));
806        assert!(reg.contains(&id));
807    }
808
809    #[xmtp_common::test]
810    fn test_tls_round_trip() {
811        let mut reg = ComponentRegistry::new();
812        reg.set(ComponentId::GROUP_NAME, sample_meta()).unwrap();
813        reg.set(
814            ComponentId::GROUP_DESCRIPTION,
815            new_component_metadata(
816                component_permissions()
817                    .insert(deny())
818                    .update(deny())
819                    .delete(deny())
820                    .call(),
821                ComponentType::Bytes,
822            ),
823        )
824        .unwrap();
825
826        let bytes = reg.to_bytes().unwrap();
827        let restored = ComponentRegistry::from_bytes(&bytes).unwrap();
828        assert_eq!(reg, restored);
829    }
830
831    #[xmtp_common::test]
832    fn test_iter() {
833        let mut reg = ComponentRegistry::new();
834        reg.set(ComponentId::GROUP_NAME, sample_meta()).unwrap();
835        reg.set(ComponentId::GROUP_DESCRIPTION, sample_meta())
836            .unwrap();
837
838        let entries: Vec<_> = reg.iter().collect::<Result<Vec<_>, _>>().unwrap();
839        assert_eq!(entries.len(), 2);
840        assert!(entries[0].0 < entries[1].0);
841    }
842
843    #[xmtp_common::test]
844    fn test_empty_registry_round_trip() {
845        let reg = ComponentRegistry::new();
846        let bytes = reg.to_bytes().unwrap();
847        let restored = ComponentRegistry::from_bytes(&bytes).unwrap();
848        assert_eq!(reg, restored);
849        assert!(restored.is_empty());
850    }
851
852    /// Assert the tolerance contract for one invalid entry: the load
853    /// succeeds, the entry is invisible to every read, it is reported
854    /// via `unrecognized_ids`, and `to_bytes` preserves it verbatim.
855    fn assert_tolerated(bytes: &[u8], id: ComponentId) {
856        let reg = ComponentRegistry::from_bytes(bytes).unwrap();
857        assert!(reg.get(&id).unwrap().is_none());
858        assert!(!reg.contains(&id));
859        assert_eq!(reg.len(), 0);
860        assert!(reg.iter().next().is_none());
861        assert_eq!(reg.unrecognized_ids().collect::<Vec<_>>(), vec![id]);
862        // Round-trip preserves the raw entry byte-for-byte.
863        assert_eq!(reg.to_bytes().unwrap(), bytes);
864    }
865
866    #[xmtp_common::test]
867    fn test_from_bytes_tolerates_out_of_space_id() {
868        // An out-of-space key (0x0001) is preserved-but-invisible: it
869        // must not poison the load, because the registry is what makes
870        // every other commit on the group validatable.
871        let id = ComponentId::new(0x0001);
872        let bytes = raw_bytes_with_entry(id, sample_meta().encode_to_vec());
873        assert_tolerated(&bytes, id);
874    }
875
876    #[xmtp_common::test]
877    fn test_from_bytes_tolerates_reserved_id() {
878        // Reserved-range ids are future protocol slots — a newer
879        // version allocating one must degrade to "unknown entry" on
880        // this version, not "registry unloadable".
881        let id = ComponentId::new(0xFF50);
882        let bytes = raw_bytes_with_entry(id, sample_meta().encode_to_vec());
883        assert_tolerated(&bytes, id);
884    }
885
886    /// Build a TLS-encoded `TlsMap<ComponentId, VLBytes>` snapshot
887    /// containing a single entry. Bypasses `ComponentRegistry::set` so
888    /// we can construct payloads that the public API would refuse to
889    /// produce.
890    fn raw_bytes_with_entry(id: ComponentId, value: Vec<u8>) -> Vec<u8> {
891        let mut map: TlsMap<ComponentId, VLBytes> = TlsMap::new();
892        map.insert(id, VLBytes::new(value)).unwrap();
893        map.tls_serialize_detached().unwrap()
894    }
895
896    #[xmtp_common::test]
897    fn test_from_bytes_tolerates_hardcoded_id() {
898        // A smuggled shadow entry for a hardcoded component stays
899        // invisible — its permissions are enforced in code, so hiding
900        // the entry preserves exactly the enforcement that matters.
901        let bytes = raw_bytes_with_entry(
902            ComponentId::COMPONENT_REGISTRY,
903            sample_meta().encode_to_vec(),
904        );
905        assert_tolerated(&bytes, ComponentId::COMPONENT_REGISTRY);
906
907        let bytes =
908            raw_bytes_with_entry(ComponentId::SUPER_ADMIN_LIST, sample_meta().encode_to_vec());
909        assert_tolerated(&bytes, ComponentId::SUPER_ADMIN_LIST);
910    }
911
912    #[xmtp_common::test]
913    fn test_from_bytes_tolerates_missing_permissions() {
914        // A metadata entry whose permissions field is None is invisible
915        // rather than fatal: writes to that component then fail the
916        // deny-by-default `NoRegistryEntry` policy check, which is the
917        // safe direction.
918        let meta = ComponentMetadata {
919            permissions: None,
920            external_committer_permissions: None,
921            component_type: ComponentType::Bytes as i32,
922        };
923        let bytes = raw_bytes_with_entry(ComponentId::GROUP_NAME, meta.encode_to_vec());
924        assert_tolerated(&bytes, ComponentId::GROUP_NAME);
925    }
926
927    #[xmtp_common::test]
928    fn test_from_bytes_tolerates_missing_policy_field() {
929        // Permissions present, but one of the three policy fields is missing.
930        let meta = new_component_metadata(
931            ComponentPermissions {
932                insert_policy: Some(allow()),
933                update_policy: None,
934                delete_policy: Some(allow()),
935            },
936            ComponentType::Bytes,
937        );
938        let bytes = raw_bytes_with_entry(ComponentId::GROUP_NAME, meta.encode_to_vec());
939        assert_tolerated(&bytes, ComponentId::GROUP_NAME);
940    }
941
942    #[xmtp_common::test]
943    fn test_from_bytes_tolerates_constrained_violation() {
944        // An ADMIN_LIST entry with an Allow policy violates the
945        // constrained-component invariant; hiding it means admin-list
946        // writes deny until a super admin repairs the entry — instead
947        // of every commit on the group failing to validate.
948        let meta = new_component_metadata(
949            component_permissions()
950                .insert(allow())
951                .update(allow())
952                .delete(allow())
953                .call(),
954            ComponentType::Bytes,
955        );
956        let bytes = raw_bytes_with_entry(ComponentId::ADMIN_LIST, meta.encode_to_vec());
957        assert_tolerated(&bytes, ComponentId::ADMIN_LIST);
958    }
959
960    #[xmtp_common::test]
961    fn test_reads_skip_entry_with_undecodable_value() {
962        // A snapshot with one good entry and one whose value at a valid id
963        // isn't decodable `ComponentMetadata`. The good entry stays fully
964        // visible; the corrupt one is skipped by `iter`, invisible to `get`
965        // (deny-by-default rather than a surfaced error), reported by
966        // `unrecognized_ids`, and preserved verbatim by `to_bytes`.
967        let mut map: TlsMap<ComponentId, VLBytes> = TlsMap::new();
968        map.insert(
969            ComponentId::GROUP_NAME,
970            VLBytes::new(sample_meta().encode_to_vec()),
971        )
972        .unwrap();
973        map.insert(
974            ComponentId::GROUP_DESCRIPTION,
975            VLBytes::new(vec![0xFF, 0xFF, 0xFF, 0xFF]),
976        )
977        .unwrap();
978        let bytes = map.tls_serialize_detached().unwrap();
979
980        let reg = ComponentRegistry::from_bytes(&bytes).unwrap();
981
982        let entries: Vec<_> = reg.iter().collect::<Result<Vec<_>, _>>().unwrap();
983        assert_eq!(entries.len(), 1);
984        assert_eq!(entries[0].0, ComponentId::GROUP_NAME);
985        assert_eq!(
986            reg.get(&ComponentId::GROUP_NAME).unwrap(),
987            Some(sample_meta())
988        );
989        assert!(reg.get(&ComponentId::GROUP_DESCRIPTION).unwrap().is_none());
990        assert_eq!(
991            reg.unrecognized_ids().collect::<Vec<_>>(),
992            vec![ComponentId::GROUP_DESCRIPTION]
993        );
994        assert_eq!(reg.to_bytes().unwrap(), bytes);
995    }
996
997    #[xmtp_common::test]
998    // verifies: META-016
999    fn test_from_bytes_tolerates_malformed_protobuf_value() {
1000        // The key is valid but the value bytes are not a parseable
1001        // ComponentMetadata (e.g. a future breaking re-encoding).
1002        // Preserved-but-invisible, like every other invalid entry.
1003        let bytes = raw_bytes_with_entry(ComponentId::GROUP_NAME, vec![0xFF, 0xFF, 0xFF, 0xFF]);
1004        assert_tolerated(&bytes, ComponentId::GROUP_NAME);
1005    }
1006
1007    #[xmtp_common::test]
1008    fn test_from_bytes_rejects_undecodable_outer_map() {
1009        // Entry-level tolerance never extends to the outer container:
1010        // truncated or non-canonical snapshot bytes are a hard error.
1011        let mut bytes =
1012            raw_bytes_with_entry(ComponentId::GROUP_NAME, sample_meta().encode_to_vec());
1013        bytes.truncate(bytes.len() - 1);
1014        assert!(matches!(
1015            ComponentRegistry::from_bytes(&bytes),
1016            Err(ComponentRegistryError::TlsCodecError(_))
1017        ));
1018    }
1019
1020    #[xmtp_common::test]
1021    // verifies: META-016
1022    fn test_from_bytes_mixed_valid_and_invalid_entries() {
1023        // One valid entry, one reserved-range entry: the valid one is
1024        // fully readable, the invalid one is preserved-but-invisible,
1025        // and the round-trip loses neither.
1026        let reserved = ComponentId::new(0xFF00);
1027        let mut map: TlsMap<ComponentId, VLBytes> = TlsMap::new();
1028        map.insert(
1029            ComponentId::GROUP_NAME,
1030            VLBytes::new(sample_meta().encode_to_vec()),
1031        )
1032        .unwrap();
1033        map.insert(reserved, VLBytes::new(b"future-slot".to_vec()))
1034            .unwrap();
1035        let bytes = map.tls_serialize_detached().unwrap();
1036
1037        let reg = ComponentRegistry::from_bytes(&bytes).unwrap();
1038        assert_eq!(reg.len(), 1);
1039        assert_eq!(
1040            reg.get(&ComponentId::GROUP_NAME).unwrap(),
1041            Some(sample_meta())
1042        );
1043        assert!(!reg.contains(&reserved));
1044        assert_eq!(reg.unrecognized_ids().collect::<Vec<_>>(), vec![reserved]);
1045        assert_eq!(reg.to_bytes().unwrap(), bytes);
1046    }
1047
1048    /// The headline tolerance property end-to-end at the unit level: a
1049    /// blob carrying one valid entry and one poisoned entry must load,
1050    /// keep the healthy component fully usable — including the
1051    /// per-element policy gate commit validation runs
1052    /// ([`validate_component_write`]) — deny writes against the
1053    /// poisoned component, and round-trip byte-identically so the
1054    /// poison is never dropped.
1055    ///
1056    /// The load/lookup/round-trip half deliberately overlaps
1057    /// `test_from_bytes_mixed_valid_and_invalid_entries`; this test
1058    /// adds the validation-gate half.
1059    // TODO: the fuller end-to-end version — a poisoned
1060    // COMPONENT_REGISTRY dict entry on a real group still validating
1061    // unrelated commits through `ValidatedCommit::from_staged_commit`
1062    // — belongs in xmtp_mls's group tests, where the commit pipeline
1063    // exists.
1064    #[xmtp_common::test(unwrap_try = true)]
1065    // verifies: PERM-012
1066    fn test_poisoned_registry_still_validates_unrelated_writes() {
1067        use crate::app_data::validation::{
1068            ActorAuthority, ComponentChange, ComponentPermissionError, validate_component_write,
1069        };
1070
1071        // One valid GROUP_NAME entry plus one reserved-range entry
1072        // whose bytes aren't even valid ComponentMetadata — the shape
1073        // a newer protocol version (or a historical bad write) would
1074        // leave behind.
1075        let poisoned = ComponentId::new(0xFF20);
1076        let mut map: TlsMap<ComponentId, VLBytes> = TlsMap::new();
1077        map.insert(
1078            ComponentId::GROUP_NAME,
1079            VLBytes::new(sample_meta().encode_to_vec()),
1080        )?;
1081        map.insert(poisoned, VLBytes::new(vec![0xDE, 0xAD, 0xBE, 0xEF]))?;
1082        let bytes = map.tls_serialize_detached()?;
1083
1084        // The load tolerates the poison...
1085        let reg = ComponentRegistry::from_bytes(&bytes)?;
1086        assert_eq!(reg.unrecognized_ids().collect::<Vec<_>>(), vec![poisoned]);
1087
1088        // ...and the healthy entry stays fully usable: readable, and
1089        // its raw bytes still pass entry validation.
1090        assert_eq!(reg.get(&ComponentId::GROUP_NAME)?, Some(sample_meta()));
1091        ComponentRegistry::validate_entry(ComponentId::GROUP_NAME, &sample_meta().encode_to_vec())?;
1092
1093        // The policy gate still allows a write to the healthy
1094        // component against the poisoned registry...
1095        let member = ActorAuthority {
1096            is_admin: false,
1097            is_super_admin: false,
1098        };
1099        let healthy_write = ComponentChange::builder()
1100            .component_id(ComponentId::GROUP_NAME)
1101            .op(ComponentOp::Insert)
1102            .actor(member)
1103            .build();
1104        validate_component_write(&healthy_write, &reg)?;
1105
1106        // ...while a write against the poisoned component falls to
1107        // deny-by-default (the entry is preserved but invisible).
1108        let poisoned_write = ComponentChange::builder()
1109            .component_id(poisoned)
1110            .op(ComponentOp::Insert)
1111            .actor(member)
1112            .build();
1113        assert!(matches!(
1114            validate_component_write(&poisoned_write, &reg),
1115            Err(ComponentPermissionError::NoRegistryEntry(id)) if id == poisoned
1116        ));
1117
1118        // And serialization round-trips byte-identically — the poison
1119        // is preserved, never dropped.
1120        assert_eq!(reg.to_bytes()?, bytes);
1121    }
1122
1123    #[xmtp_common::test]
1124    fn test_set_repairs_unrecognized_entry() {
1125        // A valid write to an id that currently holds an unrecognized
1126        // entry replaces it — `to_bytes` must serialize the repaired
1127        // value, not resurrect the broken bytes.
1128        let broken = ComponentMetadata {
1129            permissions: None,
1130            external_committer_permissions: None,
1131            component_type: ComponentType::Bytes as i32,
1132        };
1133        let bytes = raw_bytes_with_entry(ComponentId::GROUP_NAME, broken.encode_to_vec());
1134        let mut reg = ComponentRegistry::from_bytes(&bytes).unwrap();
1135        assert_eq!(reg.unrecognized_ids().count(), 1);
1136
1137        reg.set(ComponentId::GROUP_NAME, sample_meta()).unwrap();
1138        assert_eq!(reg.unrecognized_ids().count(), 0);
1139        assert_eq!(
1140            reg.get(&ComponentId::GROUP_NAME).unwrap(),
1141            Some(sample_meta())
1142        );
1143
1144        let round_tripped = ComponentRegistry::from_bytes(&reg.to_bytes().unwrap()).unwrap();
1145        assert_eq!(round_tripped.unrecognized_ids().count(), 0);
1146        assert_eq!(
1147            round_tripped.get(&ComponentId::GROUP_NAME).unwrap(),
1148            Some(sample_meta())
1149        );
1150    }
1151
1152    /// An immutable-range entry this build cannot read may be a newer
1153    /// client's valid write. Write-once must hold for it, or a local write
1154    /// would replace the group's entry because `contains` hides it.
1155    #[xmtp_common::test]
1156    // verifies: META-015
1157    fn test_set_refuses_unrecognized_immutable_entry() {
1158        let id = ComponentId::new(0xFD00);
1159        let bytes = raw_bytes_with_entry(id, vec![0xFF, 0xFF]);
1160        let mut reg = ComponentRegistry::from_bytes(&bytes).unwrap();
1161        assert!(!reg.contains(&id));
1162        assert!(matches!(
1163            reg.set(id, sample_meta()),
1164            Err(ComponentRegistryError::ImmutableComponent(_))
1165        ));
1166        assert_eq!(reg.to_bytes().unwrap(), bytes);
1167    }
1168
1169    /// Reconciliation asks whether the group holds any entry for an ID, so
1170    /// `contains_raw` must see an unreadable entry that `contains` hides.
1171    #[xmtp_common::test]
1172    // verifies: META-067
1173    fn test_contains_raw_sees_unrecognized_entry() {
1174        let id = ComponentId::new(0xC001);
1175        let reg = ComponentRegistry::from_bytes(&raw_bytes_with_entry(id, vec![0xFF])).unwrap();
1176        assert!(!reg.contains(&id));
1177        assert!(reg.contains_raw(&id));
1178        assert!(!reg.contains_raw(&ComponentId::new(0xC002)));
1179    }
1180
1181    #[xmtp_common::test]
1182    fn test_remove_deletes_unrecognized_entry() {
1183        // Repair-by-delete: removing a modifiable id that only exists
1184        // as an unrecognized entry drops the preserved bytes.
1185        let bytes = raw_bytes_with_entry(ComponentId::GROUP_NAME, vec![0xFF, 0xFF]);
1186        let mut reg = ComponentRegistry::from_bytes(&bytes).unwrap();
1187        assert_eq!(reg.unrecognized_ids().count(), 1);
1188
1189        reg.remove(&ComponentId::GROUP_NAME).unwrap();
1190        assert_eq!(reg.unrecognized_ids().count(), 0);
1191        let empty = ComponentRegistry::new();
1192        assert_eq!(reg.to_bytes().unwrap(), empty.to_bytes().unwrap());
1193    }
1194
1195    #[xmtp_common::test]
1196    fn test_preserves_component_type() {
1197        let mut reg = ComponentRegistry::new();
1198        let meta = new_component_metadata(
1199            component_permissions()
1200                .insert(allow())
1201                .update(allow())
1202                .delete(deny())
1203                .call(),
1204            ComponentType::TlsMapBytesBytes,
1205        );
1206        reg.set(ComponentId::GROUP_MEMBERSHIP, meta).unwrap();
1207
1208        let got = reg.get(&ComponentId::GROUP_MEMBERSHIP).unwrap().unwrap();
1209        assert_eq!(got.component_type, ComponentType::TlsMapBytesBytes as i32);
1210    }
1211}