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