Skip to main content

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, &registry).unwrap();
407    }
408}