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. The change carries
129 /// the complete pre- and post-operation values, so collection
130 /// components can validate transition properties. Default is no-op
131 /// — components with no extra invariants don't override.
132 fn validate_invariant(
133 _change: &ComponentChange<'_>,
134 _registry: &ComponentRegistry,
135 ) -> Result<(), ComponentInvariantError> {
136 Ok(())
137 }
138}
139
140/// Object-safe view of a [`Component`] impl, for runtime dispatch via
141/// `&'static dyn ErasedComponent`. Carries only the byte-shaped
142/// methods — typed reads through `decode_value` / `encode_value` /
143/// `encode_mutation` need the static `Self::Value` / `Self::Mutation`
144/// types and aren't reachable through the dyn boundary.
145///
146/// A blanket impl over `C: Component` makes every concrete Component
147/// impl auto-implement this trait.
148pub trait ErasedComponent: Send + Sync + 'static {
149 /// The component this impl handles — equivalent to `C::ID`.
150 fn id(&self) -> ComponentId;
151
152 /// The wire type — equivalent to `C::COMPONENT_TYPE`.
153 fn component_type(&self) -> ComponentType;
154
155 fn apply_update_payload(
156 &self,
157 payload: &[u8],
158 prior: Option<&[u8]>,
159 ) -> Result<Vec<u8>, ComponentTypedError>;
160
161 fn expand_to_changes(
162 &self,
163 op: &AppDataUpdateOperation,
164 prior: Option<&[u8]>,
165 ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError>;
166
167 fn validate_invariant(
168 &self,
169 change: &ComponentChange<'_>,
170 registry: &ComponentRegistry,
171 ) -> Result<(), ComponentInvariantError>;
172}
173
174impl<C: Component> ErasedComponent for C {
175 fn id(&self) -> ComponentId {
176 C::ID
177 }
178
179 fn component_type(&self) -> ComponentType {
180 C::COMPONENT_TYPE
181 }
182
183 fn apply_update_payload(
184 &self,
185 payload: &[u8],
186 prior: Option<&[u8]>,
187 ) -> Result<Vec<u8>, ComponentTypedError> {
188 C::apply_update_payload(payload, prior)
189 }
190
191 fn expand_to_changes(
192 &self,
193 op: &AppDataUpdateOperation,
194 prior: Option<&[u8]>,
195 ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
196 C::expand_to_changes(op, prior)
197 }
198
199 fn validate_invariant(
200 &self,
201 change: &ComponentChange<'_>,
202 registry: &ComponentRegistry,
203 ) -> Result<(), ComponentInvariantError> {
204 C::validate_invariant(change, registry)
205 }
206}
207
208/// A single per-element view of an incoming `AppDataUpdate` proposal.
209///
210/// `Bytes` components produce exactly one entry; collection components
211/// produce one entry per delta mutation. The `op` mirrors the
212/// `ComponentOp` field on a [`ComponentChange`] so the validator can
213/// call `validate_component_write` directly.
214///
215/// `key` is the TLS-encoded element key of a map mutation, which the
216/// self-owned policy and the DM registry exception read (PERM-027,
217/// PERM-026). It is `None` for scalars, for set mutations (whose
218/// `value` is the element), and for a whole-component `Remove`.
219///
220/// `value` is the new value bytes for an Insert or Update, and the
221/// element for a set Delete. It is `None` for a map Delete and a
222/// whole-component `Remove`.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct ExpandedComponentChange {
225 /// Whether this entry is an Insert, Update, or Delete.
226 pub op: ComponentOp,
227 /// The TLS-encoded map key, if this entry mutates a map element.
228 pub key: Option<Vec<u8>>,
229 /// The new value bytes, or the removed set element.
230 pub value: Option<Vec<u8>>,
231}
232
233/// Errors surfaced by [`Component`] trait methods.
234///
235/// Narrower than `ComponentSourceError` (which lives one layer up at
236/// the dispatch boundary in `xmtp_mls`). The dispatch wrapper converts
237/// these into the source-layer error via `From<ComponentTypedError>`.
238#[derive(Debug, thiserror::Error)]
239pub enum ComponentTypedError {
240 /// An `AppDataUpdate::Update` write was attempted against an
241 /// immutable component. Insert-once writes should be expressed as
242 /// `Insert`, not caught here.
243 #[error("component {0} is immutable and cannot be updated via AppDataUpdate")]
244 ImmutableUpdate(ComponentId),
245
246 /// The supplied mutation does not match the component type of the
247 /// component it targets (e.g. a Bytes mutation against a Set
248 /// component).
249 #[error("mutation shape does not match component {0}")]
250 MismatchedMutation(ComponentId),
251
252 /// A wire-format violation: the bytes passed to
253 /// [`Component::decode_value`] or
254 /// [`Component::apply_update_payload`] don't decode under the
255 /// component's expected encoding.
256 #[error("malformed value for component {component_id}: {reason}")]
257 MalformedValue {
258 component_id: ComponentId,
259 reason: String,
260 },
261
262 /// Failed to convert an inbox id string or byte slice into an
263 /// [`InboxId`](crate::inbox_id::InboxId).
264 #[error("invalid inbox id: {0}")]
265 InvalidInboxId(#[from] InboxIdError),
266
267 /// A TLS-codec operation on a delta or stored collection value
268 /// failed.
269 #[error("tls codec error: {0}")]
270 TlsCodec(#[from] tls_codec::Error),
271
272 /// A `TlsSet::apply_delta` call failed while synthesizing the new
273 /// full value of a Set component from an incoming delta.
274 #[error("tls set apply error: {0}")]
275 TlsSetApply(#[from] TlsSetError),
276
277 /// A `TlsMap::apply_delta` call failed while synthesizing the new
278 /// full value of a Map component from an incoming delta.
279 #[error("tls map apply error: {0}")]
280 TlsMapApply(#[from] TlsMapError),
281
282 /// The component's registered [`ComponentType`] is
283 /// [`ComponentType::Unspecified`] (a proto-default sentinel). The
284 /// type-aware fallback in
285 /// [`super::components::type_dispatch::apply_update_payload_for_type`]
286 /// has no decoder for this — the registry entry is malformed or
287 /// was written by a peer that does not know the type yet.
288 ///
289 /// [`ComponentType`]: xmtp_proto::xmtp::mls::message_contents::ComponentType
290 #[error("component {0} has no registered ComponentType (unspecified)")]
291 UnspecifiedType(ComponentId),
292
293 /// A `COMPONENT_REGISTRY` mutation violates the registry's write
294 /// invariants (invalid/reserved/hardcoded id, immutable overwrite,
295 /// or structurally invalid `ComponentMetadata`). The wire path
296 /// enforces the same invariants as [`ComponentRegistry::set`] /
297 /// [`ComponentRegistry::remove`] so a peer cannot land an entry
298 /// that a local writer could not produce — critically, one that
299 /// would poison every subsequent
300 /// [`ComponentRegistry::from_bytes`] load of the group's registry.
301 #[error("registry mutation rejected: {0}")]
302 RegistryMutation(#[from] ComponentRegistryError),
303}
304
305/// Errors surfaced by [`Component::validate_invariant`].
306///
307/// Component-local invariants — e.g. "GROUP_NAME ≤ N bytes",
308/// "removing the last super-admin mirror is forbidden". Distinct from
309/// [`ComponentPermissionError`](crate::app_data::validation::ComponentPermissionError),
310/// which is the policy-evaluation verdict.
311#[derive(Debug, thiserror::Error)]
312pub enum ComponentInvariantError {
313 /// A component-specific invariant was violated. The string is a
314 /// short human-readable diagnostic; structured error data can be
315 /// added later if a caller needs to inspect.
316 #[error("component {component_id} invariant violated: {reason}")]
317 Violation {
318 component_id: ComponentId,
319 reason: String,
320 },
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::app_data::{component_registry::ComponentOp, validation::ActorAuthority};
327
328 /// A minimal Component impl exercised only by these unit tests —
329 /// confirms the trait shape is usable without the real well-known
330 /// impls (which arrive in subsequent jj changes).
331 struct DummyBytesComponent;
332
333 impl Component for DummyBytesComponent {
334 const ID: ComponentId = ComponentId::APP_DATA;
335 const COMPONENT_TYPE: ComponentType = ComponentType::Bytes;
336 type Value = Vec<u8>;
337 type Mutation = Vec<u8>;
338
339 fn decode_value(bytes: &[u8]) -> Result<Self::Value, ComponentTypedError> {
340 Ok(bytes.to_vec())
341 }
342
343 fn encode_value(value: &Self::Value) -> Result<Vec<u8>, ComponentTypedError> {
344 Ok(value.clone())
345 }
346
347 fn encode_mutation(mutation: &Self::Mutation) -> Result<Vec<u8>, ComponentTypedError> {
348 Ok(mutation.clone())
349 }
350
351 fn apply_update_payload(
352 payload: &[u8],
353 _prior: Option<&[u8]>,
354 ) -> Result<Vec<u8>, ComponentTypedError> {
355 Ok(payload.to_vec())
356 }
357
358 fn expand_to_changes(
359 op: &AppDataUpdateOperation,
360 _prior: Option<&[u8]>,
361 ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
362 match op {
363 AppDataUpdateOperation::Update(payload) => Ok(vec![ExpandedComponentChange {
364 op: ComponentOp::Update,
365 key: None,
366 value: Some(payload.as_slice().to_vec()),
367 }]),
368 AppDataUpdateOperation::Remove => Ok(vec![ExpandedComponentChange {
369 op: ComponentOp::Delete,
370 key: None,
371 value: None,
372 }]),
373 }
374 }
375 }
376
377 #[xmtp_common::test(unwrap_try = true)]
378 fn typed_methods_round_trip() {
379 let v = b"hello".to_vec();
380 let bytes = DummyBytesComponent::encode_value(&v).unwrap();
381 let decoded = DummyBytesComponent::decode_value(&bytes).unwrap();
382 assert_eq!(decoded, v);
383 }
384
385 #[xmtp_common::test(unwrap_try = true)]
386 fn erased_component_blanket_impl() {
387 // Static dispatch through &dyn ErasedComponent works without
388 // per-call boxing because the impl is over a ZST.
389 let erased: &'static dyn ErasedComponent = &DummyBytesComponent;
390 assert_eq!(erased.id(), ComponentId::APP_DATA);
391 assert_eq!(erased.component_type(), ComponentType::Bytes);
392
393 let new = erased.apply_update_payload(b"world", None).unwrap();
394 assert_eq!(new, b"world");
395
396 let expanded = erased
397 .expand_to_changes(&AppDataUpdateOperation::Update(b"x".to_vec().into()), None)
398 .unwrap();
399 assert_eq!(expanded.len(), 1);
400 assert_eq!(expanded[0].op, ComponentOp::Update);
401 assert_eq!(expanded[0].value.as_deref(), Some(b"x".as_slice()));
402 }
403
404 #[xmtp_common::test(unwrap_try = true)]
405 fn default_validate_invariant_is_noop() {
406 let actor = ActorAuthority {
407 is_admin: false,
408 is_super_admin: true,
409 };
410 let change = ComponentChange::builder()
411 .component_id(ComponentId::APP_DATA)
412 .op(ComponentOp::Update)
413 .actor(actor)
414 .build();
415 let registry = ComponentRegistry::new();
416 <DummyBytesComponent as Component>::validate_invariant(&change, ®istry).unwrap();
417 }
418}