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}