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}