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