Skip to main content

xmtp_mls_common/app_data/
registry_table.rs

1//! Static dispatch table for well-known [`Component`] impls.
2//!
3//! Maps each well-known [`ComponentId`] to its zero-sized
4//! [`ErasedComponent`] impl so dispatch sites can resolve a runtime
5//! [`ComponentId`] to the right per-component logic without per-call
6//! boxing. The table is hand-maintained and sorted by
7//! `ComponentId::as_u16()` so [`lookup_component`] does a single
8//! binary search.
9//!
10//! ## Adding a new well-known component
11//!
12//! 1. Add a `Component` impl in `app_data::components::*`.
13//! 2. Insert a `(ComponentId::FOO, &FooComponent)` entry into
14//!    [`WELL_KNOWN`], maintaining ascending sort order.
15//! 3. The compile-time `assert_table_is_sorted_and_unique` check at
16//!    the bottom of this file verifies invariants on every build.
17//!
18//! Application-range components have no entry and no process-local
19//! handler: every client decodes them with the standard codec for the
20//! type in the group's committed registry, so no local code can change
21//! which commits a client accepts.
22
23use crate::app_data::{
24    component_id::ComponentId,
25    components::{
26        inbox_id_set::{AdminListComponent, DmMembersComponent, SuperAdminListComponent},
27        metadata_attributes::{
28            AppDataComponent, CommitLogSignerComponent, GroupDescriptionComponent,
29            GroupImageUrlComponent, GroupNameComponent, MessageDisappearFromNsComponent,
30            MessageDisappearInNsComponent, MinSupportedProtocolVersionComponent,
31        },
32        tls_map_components::{ComponentRegistryComponent, GroupMembershipComponent},
33    },
34    typed::ErasedComponent,
35};
36
37/// Sorted-ascending table of `(ComponentId, &dyn ErasedComponent)`
38/// entries for every well-known XMTP component.
39///
40/// Order is enforced by [`assert_table_is_sorted_and_unique`] at
41/// compile time. Tests further pin specific lookup expectations.
42///
43/// # Change control
44///
45/// Registry policy expresses **authority** only: every policy variant is a
46/// predicate over the actor. A component whose correctness depends on a
47/// predicate over its resulting value, or on a bound on that value, cannot be
48/// expressed in the registry and MUST implement
49/// [`Component::validate_invariant`](crate::app_data::typed::Component::validate_invariant).
50/// Prefer a registry rule whenever it can express the requirement. Add an
51/// invariant only when it cannot, and record that justification at the impl.
52///
53/// Component-id ranges in play (mirror of [`lookup_component`] below):
54///
55/// | Range            | Purpose                                       |
56/// |------------------|-----------------------------------------------|
57/// | `0x8000-0xBFFF`  | XMTP-allocated well-known ids (this table)    |
58/// | `0xC000-0xFEFF`  | Application ids, decoded by registry type     |
59/// | `0xFF00-0xFFFF`  | Reserved (hard-rejected, no graceful-degrade) |
60///
61/// Adding a new well-known entry here changes the protocol's
62/// receiver-side acceptance set. Old clients (released before the new
63/// entry) handle the new id via the **type-aware unknown-component
64/// tolerance** path in:
65///   - `apply_app_data_update_payload`
66///   - `expand_app_data_update_to_changes`
67///   - `validate_one_app_data_update_with_old_value`
68///
69/// That path looks the unknown id up in the on-dict
70/// [`ComponentRegistry`](crate::app_data::component_registry::ComponentRegistry),
71/// pulls its registered [`ComponentType`], and dispatches through the
72/// type-level decoder. The closed type universe covers every shape:
73/// Bytes / String pass-through, `TlsSet<InboxId>` / `TlsSet<bytes>` /
74/// `TlsMap<InboxId, bytes>` / `TlsMap<InboxId, UTF-8 bytes>` /
75/// `TlsMap<bytes, bytes>` apply their deltas element-wise — old and new
76/// clients converge on the same dict bytes.
77/// The tolerance path covers the XMTP range (`0x8000-0xBFFF`) and the
78/// application range (`0xC000-0xFEFF`); the reserved range
79/// (`0xFF00-0xFFFF`) is **still hard-rejected** — those slots are
80/// protocol-level and have no graceful-degrade story. Do not allocate
81/// new ids there.
82///
83/// **Requirements when adding a new well-known component:**
84/// - The component MUST be reachable through one of the seven
85///   [`ComponentType`] variants. The wire codec for each is fixed; an
86///   old client decodes it the same way a typed client would.
87/// - The component MUST NOT carry receive-side invariants beyond
88///   registry policy. Old (type-dispatched) clients lack the per-id
89///   `Component::validate_invariant` hook — diverging invariant
90///   behavior would fork the dict.
91/// - Read-side accessors surface **the default value for the
92///   component's type** on old clients (empty `Bytes` / `String` /
93///   `TlsSet` / `TlsMap`). The "absent" state is indistinguishable
94///   from "explicitly cleared" on old clients — design semantics
95///   accordingly and document that degradation at the accessor
96///   boundary.
97/// - The component MUST land in the registry **before or with** the
98///   first commit that writes to it. Old clients consult the
99///   pre-commit registry snapshot, so a same-commit registration
100///   followed by a same-commit write fails to dispatch.
101///
102/// **Floor-bump convention (pause, don't fork).** Any release that
103/// introduces something old receivers cannot interpret — a new
104/// [`ComponentType`], a new set/map delta mutation tag, a new registry
105/// entry format, a reserved-range (`0xFF00+`) allocation, or a change
106/// to the bootstrap synthesis encoding — MUST raise
107/// `PROPOSALS_MIN_PROTOCOL_VERSION` in the same release AND land each
108/// group's `MIN_SUPPORTED_PROTOCOL_VERSION` floor bump in a commit
109/// **strictly earlier** than the first commit using the new construct
110/// (the floor-bump commit itself must contain nothing format-novel).
111/// Receivers below a committed floor pause the group
112/// (defer-and-reprocess after upgrade) via the pause-before-parse
113/// guards in `xmtp_mls::groups::app_data` and
114/// `ValidatedCommit::from_staged_commit`; a same-commit floor bump is
115/// NOT protected — its proposal hasn't passed the super-admin policy
116/// check when the guards run, and pausing on unvalidated input would
117/// let any member freeze a group.
118///
119/// Two ergonomic patterns for shipping a new component without
120/// editing `WELL_KNOWN`:
121///
122/// 1. **Application-range component.** A component in
123///    `0xC000-0xFEFF` registered in a group's `COMPONENT_REGISTRY`
124///    with one of the standard types ships without touching
125///    `WELL_KNOWN`; every client type-dispatches it via the registry.
126/// 2. **Coordinated protocol-version bump.** Required only when the
127///    new component must reject specific bytes that the type-level
128///    codec would otherwise accept (e.g. a per-id invariant beyond
129///    type shape).
130///
131/// [`ComponentType`]: xmtp_proto::xmtp::mls::message_contents::ComponentType
132pub static WELL_KNOWN: &[(ComponentId, &'static dyn ErasedComponent)] = &[
133    (ComponentId::COMPONENT_REGISTRY, &ComponentRegistryComponent),
134    (ComponentId::SUPER_ADMIN_LIST, &SuperAdminListComponent),
135    (ComponentId::ADMIN_LIST, &AdminListComponent),
136    (ComponentId::GROUP_MEMBERSHIP, &GroupMembershipComponent),
137    (ComponentId::GROUP_NAME, &GroupNameComponent),
138    (ComponentId::GROUP_DESCRIPTION, &GroupDescriptionComponent),
139    (ComponentId::GROUP_IMAGE_URL, &GroupImageUrlComponent),
140    (
141        ComponentId::MESSAGE_DISAPPEAR_FROM_NS,
142        &MessageDisappearFromNsComponent,
143    ),
144    (
145        ComponentId::MESSAGE_DISAPPEAR_IN_NS,
146        &MessageDisappearInNsComponent,
147    ),
148    (ComponentId::APP_DATA, &AppDataComponent),
149    (
150        ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
151        &MinSupportedProtocolVersionComponent,
152    ),
153    (ComponentId::COMMIT_LOG_SIGNER, &CommitLogSignerComponent),
154    (ComponentId::DM_MEMBERS, &DmMembersComponent),
155];
156
157/// Look up the well-known [`ErasedComponent`] for a [`ComponentId`].
158///
159/// Returns `None` for every application-range and reserved id, and for
160/// a well-known id with no impl (e.g. the `0xBE0x` immutable seeds —
161/// handled by the bootstrap validator's byte-compare path rather than
162/// the trait). Callers then decode by the id's registered type.
163// implements: META-010
164pub fn lookup_component(id: ComponentId) -> Option<&'static dyn ErasedComponent> {
165    WELL_KNOWN
166        .binary_search_by_key(&id.as_u16(), |(component_id, _)| component_id.as_u16())
167        .ok()
168        .map(|idx| WELL_KNOWN[idx].1)
169}
170
171/// Compile-time check that [`WELL_KNOWN`] is strictly ascending by
172/// `ComponentId::as_u16()` (so [`lookup_component`]'s binary search is
173/// correct) and that no entry's declared id disagrees with its impl's
174/// `Component::ID`.
175///
176/// Triggered as `const _: () = assert_table_is_sorted_and_unique();`
177/// at module scope below.
178const fn assert_table_is_sorted_and_unique() {
179    let mut i = 1;
180    while i < WELL_KNOWN.len() {
181        let prev = WELL_KNOWN[i - 1].0.as_u16();
182        let curr = WELL_KNOWN[i].0.as_u16();
183        assert!(prev < curr, "WELL_KNOWN must be strictly ascending");
184        i += 1;
185    }
186}
187
188const _: () = assert_table_is_sorted_and_unique();
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::app_data::typed::Component;
194    use xmtp_proto::xmtp::mls::message_contents::ComponentType;
195
196    #[xmtp_common::test(unwrap_try = true)]
197    fn lookup_returns_correct_component_for_each_well_known_id() {
198        let cases = [
199            (
200                ComponentId::COMPONENT_REGISTRY,
201                ComponentType::TlsMapBytesBytes,
202            ),
203            (ComponentId::SUPER_ADMIN_LIST, ComponentType::TlsSetInboxId),
204            (ComponentId::ADMIN_LIST, ComponentType::TlsSetInboxId),
205            (
206                ComponentId::GROUP_MEMBERSHIP,
207                ComponentType::TlsMapInboxIdBytes,
208            ),
209            (ComponentId::GROUP_NAME, ComponentType::String),
210            (ComponentId::GROUP_DESCRIPTION, ComponentType::String),
211            (ComponentId::GROUP_IMAGE_URL, ComponentType::String),
212            (ComponentId::MESSAGE_DISAPPEAR_FROM_NS, ComponentType::Bytes),
213            (ComponentId::MESSAGE_DISAPPEAR_IN_NS, ComponentType::Bytes),
214            (ComponentId::APP_DATA, ComponentType::String),
215            (
216                ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
217                ComponentType::String,
218            ),
219            (ComponentId::COMMIT_LOG_SIGNER, ComponentType::Bytes),
220            (ComponentId::DM_MEMBERS, ComponentType::TlsSetInboxId),
221        ];
222        for (id, expected_type) in cases {
223            let entry =
224                lookup_component(id).unwrap_or_else(|| panic!("missing dispatch for {id:?}"));
225            assert_eq!(entry.id(), id);
226            assert_eq!(entry.component_type(), expected_type);
227        }
228    }
229
230    #[xmtp_common::test(unwrap_try = true)]
231    fn lookup_returns_none_for_unknown_id() {
232        // Application ids never dispatch to local code.
233        for id in [0xC000, 0xC123, 0xFEFF] {
234            assert!(lookup_component(ComponentId::new(id)).is_none());
235        }
236
237        // Immutable seed without a Component impl yet — bootstrap
238        // validator handles it via byte-compare, not the trait.
239        assert!(lookup_component(ComponentId::CONVERSATION_TYPE).is_none());
240        assert!(lookup_component(ComponentId::CREATOR_INBOX_ID).is_none());
241    }
242
243    #[xmtp_common::test(unwrap_try = true)]
244    fn well_known_entries_match_component_const_id() {
245        // Detect copy-paste errors: each table entry's declared id
246        // must equal its impl's `Component::ID` (which the
247        // ErasedComponent vtable surfaces via `id()`).
248        for (declared_id, erased) in WELL_KNOWN {
249            assert_eq!(
250                erased.id(),
251                *declared_id,
252                "WELL_KNOWN entry for {declared_id:?} points to an impl with id {:?}",
253                erased.id()
254            );
255        }
256    }
257
258    #[xmtp_common::test(unwrap_try = true)]
259    fn dispatch_through_erased_calls_typed_apply() {
260        // End-to-end: lookup_component returns an &dyn ErasedComponent
261        // whose apply_update_payload mirrors the typed Component::apply_update_payload.
262        let payload = b"new-name";
263        let typed_result =
264            <GroupNameComponent as Component>::apply_update_payload(payload, None).unwrap();
265        let erased = lookup_component(ComponentId::GROUP_NAME).unwrap();
266        let erased_result = erased.apply_update_payload(payload, None).unwrap();
267        assert_eq!(typed_result, erased_result);
268    }
269}