xmtp_mls_common/app_data/typed.rs
1//! Typed dispatch for AppData components.
2//!
3//! Defines the [`Component`] trait that pairs a static [`ComponentId`]
4//! with a value type, encoding, and per-component validation hooks.
5//! Each well-known component (GROUP_NAME, ADMIN_LIST, etc.) has one
6//! `Component` impl living under `app_data::components::*`. Together
7//! with the static dispatch table in `app_data::registry_table`, the
8//! trait is the single source of truth for what a [`ComponentId`]
9//! means at the byte level.
10//!
11//! ## Type erasure
12//!
13//! [`Component`] has only static methods (no `&self`), so `dyn Component`
14//! cannot be formed. The companion [`ErasedComponent`] trait is the
15//! object-safe shape used for runtime dispatch. A blanket impl over any
16//! `C: Component + Send + Sync + 'static` lets each Component impl be a
17//! zero-sized type — `&'static dyn ErasedComponent` is a single
18//! pointer with no per-call boxing.
19//!
20//! ## Bytes-out, value-out boundary
21//!
22//! The trait deliberately splits "byte-in / byte-out" methods (which
23//! survive type erasure) from "value-in / value-out" methods (which
24//! require the static `Self::Value` / `Self::Mutation` types).
25//! Validators and the steady-state apply path use the byte-shaped
26//! methods through `&dyn ErasedComponent`; reads against a known
27//! component impl use `decode_value` directly with the static type
28//! through the [`MlsGroupAppData`](super) facade in `xmtp_mls`.
29
30use openmls::messages::proposals::AppDataUpdateOperation;
31use xmtp_proto::xmtp::mls::message_contents::ComponentType;
32
33use crate::{
34 app_data::{
35 component_id::ComponentId,
36 component_registry::{ComponentOp, ComponentRegistry, ComponentRegistryError},
37 validation::ComponentChange,
38 },
39 inbox_id::InboxIdError,
40 tls_map::TlsMapError,
41 tls_set::TlsSetError,
42};
43
44/// A typed view of a well-known AppData component.
45///
46/// One impl per [`ComponentId`] — pairs the wire identifier with the
47/// component's logical type, decode/encode round-trip, mutation
48/// serialization, in-place apply behavior for incoming Update payloads,
49/// and (optionally) component-local invariant checks.
50///
51/// The registry's permission check (`validate_component_write` in
52/// `app_data::validation`) is cross-cutting and runs outside this
53/// trait. [`Component::validate_invariant`] is for component-LOCAL
54/// invariants (e.g. "GROUP_NAME ≤ 1 KiB", "ADMIN_LIST always has at
55/// least one super-admin mirror") that the policy evaluator can't
56/// express.
57pub trait Component: Send + Sync + 'static {
58 /// Stable wire identifier. Const-known so registries can build
59 /// static lookup tables.
60 const ID: ComponentId;
61
62 /// Logical wire type, written into the registry's
63 /// [`ComponentMetadata.component_type`] slot at bootstrap.
64 ///
65 /// [`ComponentMetadata.component_type`]: xmtp_proto::xmtp::mls::message_contents::ComponentMetadata
66 const COMPONENT_TYPE: ComponentType;
67
68 /// Decoded full-state value (e.g. `String`, `TlsSet<InboxId>`,
69 /// `TlsMap<InboxId, VLBytes>`).
70 type Value;
71
72 /// Decoded payload that goes inside an
73 /// `AppDataUpdateOperation::Update`.
74 ///
75 /// For Bytes/String components this is the same as `Value` (a
76 /// full-value replacement). For collection components this is
77 /// the wire-level **delta** (`TlsSetDelta<K>` /
78 /// `TlsMapDelta<K, V>`) carrying one *or more* mutations — every
79 /// mutation in the delta lands as one atomic change at the
80 /// receiver. Single-mutation callers build a one-element delta
81 /// via the fluent builder (`TlsSetDelta::new().insert(x)`).
82 ///
83 /// Batching matters for components like `GROUP_MEMBERSHIP` where
84 /// all installation changes for an inbox (additions, removals,
85 /// new installations) must travel in a single proposal so the
86 /// receiver applies them atomically.
87 type Mutation;
88
89 /// Decode the component's stored bytes (as found in the AppData
90 /// dictionary) into the typed value.
91 fn decode_value(bytes: &[u8]) -> Result<Self::Value, ComponentTypedError>;
92
93 /// Encode a typed value back to the bytes that go in the AppData
94 /// dictionary slot. The inverse of [`Component::decode_value`].
95 fn encode_value(value: &Self::Value) -> Result<Vec<u8>, ComponentTypedError>;
96
97 /// Encode a [`Self::Mutation`] as the bytes that go inside an
98 /// `AppDataUpdateOperation::Update` payload.
99 ///
100 /// Bytes/String components pass through; collection components
101 /// serialize the delta (which may carry multiple mutations — see
102 /// the [`Self::Mutation`] doc on batching).
103 fn encode_mutation(mutation: &Self::Mutation) -> Result<Vec<u8>, ComponentTypedError>;
104
105 /// Apply an `AppDataUpdateOperation::Update` payload against the
106 /// component's prior bytes (or `None` if first write) and return
107 /// the new full bytes.
108 ///
109 /// Bytes/String components ignore `prior` and pass through;
110 /// collection components decode `payload` as a delta and apply it
111 /// to the decoded prior set/map.
112 fn apply_update_payload(
113 payload: &[u8],
114 prior: Option<&[u8]>,
115 ) -> Result<Vec<u8>, ComponentTypedError>;
116
117 /// Expand an `AppDataUpdate` proposal (Update or Remove) into the
118 /// per-element changes the validator's policy loop iterates over.
119 ///
120 /// Bytes components produce exactly one entry; collection
121 /// components produce one entry per delta mutation.
122 fn expand_to_changes(
123 op: &AppDataUpdateOperation,
124 prior: Option<&[u8]>,
125 ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError>;
126
127 /// Optional component-local invariant check that runs *after*
128 /// `validate_component_write`'s policy verdict. Default is no-op
129 /// — components with no extra invariants don't override.
130 fn validate_invariant(
131 _change: &ComponentChange<'_>,
132 _registry: &ComponentRegistry,
133 ) -> Result<(), ComponentInvariantError> {
134 Ok(())
135 }
136}
137
138/// Object-safe view of a [`Component`] impl, for runtime dispatch via
139/// `&'static dyn ErasedComponent`. Carries only the byte-shaped
140/// methods — typed reads through `decode_value` / `encode_value` /
141/// `encode_mutation` need the static `Self::Value` / `Self::Mutation`
142/// types and aren't reachable through the dyn boundary.
143///
144/// A blanket impl over `C: Component` makes every concrete Component
145/// impl auto-implement this trait.
146pub trait ErasedComponent: Send + Sync + 'static {
147 /// The component this impl handles — equivalent to `C::ID`.
148 fn id(&self) -> ComponentId;
149
150 /// The wire type — equivalent to `C::COMPONENT_TYPE`.
151 fn component_type(&self) -> ComponentType;
152
153 fn apply_update_payload(
154 &self,
155 payload: &[u8],
156 prior: Option<&[u8]>,
157 ) -> Result<Vec<u8>, ComponentTypedError>;
158
159 fn expand_to_changes(
160 &self,
161 op: &AppDataUpdateOperation,
162 prior: Option<&[u8]>,
163 ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError>;
164
165 fn validate_invariant(
166 &self,
167 change: &ComponentChange<'_>,
168 registry: &ComponentRegistry,
169 ) -> Result<(), ComponentInvariantError>;
170}
171
172impl<C: Component> ErasedComponent for C {
173 fn id(&self) -> ComponentId {
174 C::ID
175 }
176
177 fn component_type(&self) -> ComponentType {
178 C::COMPONENT_TYPE
179 }
180
181 fn apply_update_payload(
182 &self,
183 payload: &[u8],
184 prior: Option<&[u8]>,
185 ) -> Result<Vec<u8>, ComponentTypedError> {
186 C::apply_update_payload(payload, prior)
187 }
188
189 fn expand_to_changes(
190 &self,
191 op: &AppDataUpdateOperation,
192 prior: Option<&[u8]>,
193 ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
194 C::expand_to_changes(op, prior)
195 }
196
197 fn validate_invariant(
198 &self,
199 change: &ComponentChange<'_>,
200 registry: &ComponentRegistry,
201 ) -> Result<(), ComponentInvariantError> {
202 C::validate_invariant(change, registry)
203 }
204}
205
206/// A single per-element view of an incoming `AppDataUpdate` proposal.
207///
208/// `Bytes` components produce exactly one entry; collection components
209/// produce one entry per delta mutation. The `op` mirrors the
210/// `ComponentOp` field on a [`ComponentChange`] so the validator can
211/// call `validate_component_write` directly.
212///
213/// `value` is `None` for `Delete` ops on collection components when
214/// the receiver removes by key (e.g. unresolvable `RemoveByHash`); for
215/// every other case it is `Some` with the new value bytes.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct ExpandedComponentChange {
218 /// Whether this entry is an Insert, Update, or Delete.
219 pub op: ComponentOp,
220 /// The new value bytes for Insert/Update, or `None` for Delete
221 /// when the value is not available.
222 pub value: Option<Vec<u8>>,
223}
224
225/// Errors surfaced by [`Component`] trait methods.
226///
227/// Narrower than `ComponentSourceError` (which lives one layer up at
228/// the dispatch boundary in `xmtp_mls`). The dispatch wrapper converts
229/// these into the source-layer error via `From<ComponentTypedError>`.
230#[derive(Debug, thiserror::Error)]
231pub enum ComponentTypedError {
232 /// An `AppDataUpdate::Update` write was attempted against an
233 /// immutable component. Insert-once writes should be expressed as
234 /// `Insert`, not caught here.
235 #[error("component {0} is immutable and cannot be updated via AppDataUpdate")]
236 ImmutableUpdate(ComponentId),
237
238 /// The supplied mutation does not match the component type of the
239 /// component it targets (e.g. a Bytes mutation against a Set
240 /// component).
241 #[error("mutation shape does not match component {0}")]
242 MismatchedMutation(ComponentId),
243
244 /// A wire-format violation: the bytes passed to
245 /// [`Component::decode_value`] or
246 /// [`Component::apply_update_payload`] don't decode under the
247 /// component's expected encoding.
248 #[error("malformed value for component {component_id}: {reason}")]
249 MalformedValue {
250 component_id: ComponentId,
251 reason: String,
252 },
253
254 /// Failed to convert an inbox id string or byte slice into an
255 /// [`InboxId`](crate::inbox_id::InboxId).
256 #[error("invalid inbox id: {0}")]
257 InvalidInboxId(#[from] InboxIdError),
258
259 /// A TLS-codec operation on a delta or stored collection value
260 /// failed.
261 #[error("tls codec error: {0}")]
262 TlsCodec(#[from] tls_codec::Error),
263
264 /// A `TlsSet::apply_delta` call failed while synthesizing the new
265 /// full value of a Set component from an incoming delta.
266 #[error("tls set apply error: {0}")]
267 TlsSetApply(#[from] TlsSetError),
268
269 /// A `TlsMap::apply_delta` call failed while synthesizing the new
270 /// full value of a Map component from an incoming delta.
271 #[error("tls map apply error: {0}")]
272 TlsMapApply(#[from] TlsMapError),
273
274 /// The component's registered [`ComponentType`] is
275 /// [`ComponentType::Unspecified`] (a proto-default sentinel). The
276 /// type-aware fallback in
277 /// [`super::components::type_dispatch::apply_update_payload_for_type`]
278 /// has no decoder for this — the registry entry is malformed or
279 /// was written by a peer that does not know the type yet.
280 ///
281 /// [`ComponentType`]: xmtp_proto::xmtp::mls::message_contents::ComponentType
282 #[error("component {0} has no registered ComponentType (unspecified)")]
283 UnspecifiedType(ComponentId),
284
285 /// A `COMPONENT_REGISTRY` mutation violates the registry's write
286 /// invariants (invalid/reserved/hardcoded id, immutable overwrite,
287 /// or structurally invalid `ComponentMetadata`). The wire path
288 /// enforces the same invariants as [`ComponentRegistry::set`] /
289 /// [`ComponentRegistry::remove`] so a peer cannot land an entry
290 /// that a local writer could not produce — critically, one that
291 /// would poison every subsequent
292 /// [`ComponentRegistry::from_bytes`] load of the group's registry.
293 #[error("registry mutation rejected: {0}")]
294 RegistryMutation(#[from] ComponentRegistryError),
295}
296
297/// Errors surfaced by [`Component::validate_invariant`].
298///
299/// Component-local invariants — e.g. "GROUP_NAME ≤ N bytes",
300/// "removing the last super-admin mirror is forbidden". Distinct from
301/// [`ComponentPermissionError`](crate::app_data::validation::ComponentPermissionError),
302/// which is the policy-evaluation verdict.
303#[derive(Debug, thiserror::Error)]
304pub enum ComponentInvariantError {
305 /// A component-specific invariant was violated. The string is a
306 /// short human-readable diagnostic; structured error data can be
307 /// added later if a caller needs to inspect.
308 #[error("component {component_id} invariant violated: {reason}")]
309 Violation {
310 component_id: ComponentId,
311 reason: String,
312 },
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::app_data::{component_registry::ComponentOp, validation::ActorAuthority};
319
320 /// A minimal Component impl exercised only by these unit tests —
321 /// confirms the trait shape is usable without the real well-known
322 /// impls (which arrive in subsequent jj changes).
323 struct DummyBytesComponent;
324
325 impl Component for DummyBytesComponent {
326 const ID: ComponentId = ComponentId::APP_DATA;
327 const COMPONENT_TYPE: ComponentType = ComponentType::Bytes;
328 type Value = Vec<u8>;
329 type Mutation = Vec<u8>;
330
331 fn decode_value(bytes: &[u8]) -> Result<Self::Value, ComponentTypedError> {
332 Ok(bytes.to_vec())
333 }
334
335 fn encode_value(value: &Self::Value) -> Result<Vec<u8>, ComponentTypedError> {
336 Ok(value.clone())
337 }
338
339 fn encode_mutation(mutation: &Self::Mutation) -> Result<Vec<u8>, ComponentTypedError> {
340 Ok(mutation.clone())
341 }
342
343 fn apply_update_payload(
344 payload: &[u8],
345 _prior: Option<&[u8]>,
346 ) -> Result<Vec<u8>, ComponentTypedError> {
347 Ok(payload.to_vec())
348 }
349
350 fn expand_to_changes(
351 op: &AppDataUpdateOperation,
352 _prior: Option<&[u8]>,
353 ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
354 match op {
355 AppDataUpdateOperation::Update(payload) => Ok(vec![ExpandedComponentChange {
356 op: ComponentOp::Update,
357 value: Some(payload.as_slice().to_vec()),
358 }]),
359 AppDataUpdateOperation::Remove => Ok(vec![ExpandedComponentChange {
360 op: ComponentOp::Delete,
361 value: None,
362 }]),
363 }
364 }
365 }
366
367 #[xmtp_common::test(unwrap_try = true)]
368 fn typed_methods_round_trip() {
369 let v = b"hello".to_vec();
370 let bytes = DummyBytesComponent::encode_value(&v).unwrap();
371 let decoded = DummyBytesComponent::decode_value(&bytes).unwrap();
372 assert_eq!(decoded, v);
373 }
374
375 #[xmtp_common::test(unwrap_try = true)]
376 fn erased_component_blanket_impl() {
377 // Static dispatch through &dyn ErasedComponent works without
378 // per-call boxing because the impl is over a ZST.
379 let erased: &'static dyn ErasedComponent = &DummyBytesComponent;
380 assert_eq!(erased.id(), ComponentId::APP_DATA);
381 assert_eq!(erased.component_type(), ComponentType::Bytes);
382
383 let new = erased.apply_update_payload(b"world", None).unwrap();
384 assert_eq!(new, b"world");
385
386 let expanded = erased
387 .expand_to_changes(&AppDataUpdateOperation::Update(b"x".to_vec().into()), None)
388 .unwrap();
389 assert_eq!(expanded.len(), 1);
390 assert_eq!(expanded[0].op, ComponentOp::Update);
391 assert_eq!(expanded[0].value.as_deref(), Some(b"x".as_slice()));
392 }
393
394 #[xmtp_common::test(unwrap_try = true)]
395 fn default_validate_invariant_is_noop() {
396 let actor = ActorAuthority {
397 is_admin: false,
398 is_super_admin: true,
399 };
400 let change = ComponentChange::builder()
401 .component_id(ComponentId::APP_DATA)
402 .op(ComponentOp::Update)
403 .actor(actor)
404 .build();
405 let registry = ComponentRegistry::new();
406 <DummyBytesComponent as Component>::validate_invariant(&change, ®istry).unwrap();
407 }
408}