Skip to main content

xmtp_mls/groups/app_data/
mod.rs

1//! Group state stored in the OpenMLS AppData dictionary.
2//!
3//! This module is the bridge between the per-field intent handlers in
4//! `mls_sync` and the OpenMLS app data dictionary. The existing per-field
5//! helpers (`update_group_name`, `update_admin_list_action`, …) keep their
6//! signatures and route through the appropriate sub-module here. The
7//! component codec lives in `xmtp_mls_group_state::app_data::component_source`;
8//! its helpers transform bytes and do not change a group by themselves.
9//!
10//! `membership_upkeep` adds the registry reconciliation and removed-member
11//! cleanup that ride along with every commit this client builds.
12
13pub(crate) mod membership_upkeep;
14pub mod migration;
15pub(crate) mod sender_intents;
16
17use std::collections::{BTreeMap, HashMap, HashSet, hash_map::Entry};
18
19use openmls::{
20    component::ComponentData,
21    extensions::AppDataDictionary,
22    framing::{MlsMessageOut, ProcessedMessage, ProtocolMessage},
23    group::{
24        AppDataUpdates, MlsGroup as OpenMlsGroup, ProcessMessageError, ProposalError,
25        QueuedProposal, ResolveAppDataCommitError,
26    },
27    messages::proposals::{AppDataUpdateOperation, Proposal},
28    // `CommitMessageBundle` lives in `prelude` because the natural path
29    // (`openmls::group::commit_builder`) is private to the openmls crate.
30    // Re-importing through prelude is the only public path.
31    prelude::{CommitMessageBundle, ProcessedMessageContent},
32    storage::OpenMlsProvider,
33};
34use xmtp_mls_group_state::app_data::{
35    component_id::ComponentId, component_registry::ComponentRegistry,
36};
37
38use crate::groups::GroupError;
39use xmtp_configuration::ApplicationComponentDefinition;
40use xmtp_cryptography::secret::{Zeroize, Zeroizing};
41use xmtp_mls_group_state::app_data::component_source::{
42    ComponentSourceError, apply_app_data_update_payload, read_from_app_data_dict,
43};
44use xmtp_mls_group_state::app_data::protocol_floor::committed_floor_exceeding;
45use xmtp_mls_group_state::inbox_id::InboxId;
46use xmtp_mls_group_state::libxmtp_version::LibXMTPVersion;
47use xmtp_mls_validation::commit::{
48    AppDataPostStates, AppDataUpdateInCommit, AppDataUpdateList, CommitRuleError,
49    app_data_update_proposer_leaf, extract_commit_participant, membership_inbox_ids,
50    read_committed_metadata, validate_app_data_update_sequence, validate_post_dictionary_size,
51    validate_post_registry,
52};
53
54#[cfg(any(test, feature = "test-utils"))]
55tokio::task_local! {
56    /// Test-only override returned by [`load_component_registry`].
57    /// Stored as a tokio task-local (rather than a thread-local) so the
58    /// scope survives task migration across worker threads under
59    /// `multi_thread` runtimes.
60    pub static TEST_REGISTRY_OVERRIDE: ComponentRegistry;
61}
62
63/// Error returned by [`process_message_with_app_data`].
64///
65/// Wraps both an OpenMLS [`ProcessMessageError`] (for the underlying
66/// `process_message` failure modes) and a [`ComponentSourceError`] (for
67/// failures that happen while we decode an incoming `AppDataUpdate`
68/// payload). Splitting them keeps "the message was bad in OpenMLS terms"
69/// distinct from "we couldn't decode an AppData payload" so callers can
70/// log / retry / surface them differently.
71#[derive(Debug, thiserror::Error)]
72pub enum ProcessMessageWithAppDataError<StorageError: std::error::Error> {
73    /// Standard OpenMLS processing failure (decryption, validation, …).
74    #[error(transparent)]
75    OpenMls(#[from] ProcessMessageError<StorageError>),
76    /// Failed to decode an incoming `AppDataUpdate` payload via
77    /// [`apply_app_data_update_payload`]. Almost always indicates a
78    /// malformed proposal from a peer (or a wire-format mismatch with a
79    /// future version we don't understand yet).
80    ///
81    /// **Not retryable.** Decode failures are deterministic over the
82    /// exact bytes on the wire, so retrying the same message will fail
83    /// the same way. `GroupMessageProcessingError::is_retryable` and
84    /// `commit_result` treat this as a terminal wire-format violation
85    /// (mapped to `CommitResult::Invalid`).
86    #[error("failed to decode incoming AppDataUpdate payload: {0}")]
87    AppDataDecode(#[from] ComponentSourceError),
88    /// The group's committed `MIN_SUPPORTED_PROTOCOL_VERSION` floor
89    /// exceeds this client's version. Surfaced *before* any
90    /// `AppDataUpdate` payload is dispatched, so a client below the
91    /// floor (most commonly after an app downgrade — pausing normally
92    /// happens at the floor-bump commit itself, but a downgraded
93    /// client never processed one) pauses the group instead of
94    /// rejecting a commit it cannot interpret. Rejecting here is what
95    /// forks a group: peers above the floor accept the commit and
96    /// advance without us.
97    ///
98    /// Converted to `CommitRuleError::ProtocolVersionTooLow` at
99    /// the `mls_sync` boundary so the existing pause machinery
100    /// (`set_group_paused`, held cursor, reprocess-on-upgrade) applies
101    /// unchanged.
102    #[error(
103        "group's minimum supported protocol version {min_version} exceeds this client's version {own_version}"
104    )]
105    ProtocolVersionTooLow {
106        min_version: String,
107        own_version: String,
108    },
109    /// Staging an app-data commit failed after we interpreted its
110    /// proposals (`OpenMlsGroup::resolve_app_data_commit`). Carries the
111    /// same staging failure modes a commit without AppDataUpdate
112    /// proposals would surface from `process_message` directly.
113    #[error("failed to stage app-data commit: {0}")]
114    ResolveAppDataCommit(#[from] ResolveAppDataCommitError),
115}
116
117/// Walk a stream of `(ComponentId, &AppDataUpdateOperation)` tuples and
118/// produce the resulting [`AppDataUpdates`] the commit builder / message
119/// processor wants.
120///
121/// Returns `Ok(None)` when the iterator yields no proposals.
122pub(crate) fn accumulate_app_data_updates<'a, I>(
123    mls_group: &OpenMlsGroup,
124    proposals: I,
125) -> Result<Option<AppDataUpdates>, ComponentSourceError>
126where
127    I: IntoIterator<Item = (openmls::component::ComponentId, &'a AppDataUpdateOperation)>,
128{
129    let mut batch = AppDataBatch::new(mls_group)?;
130    for (id, operation) in proposals {
131        batch.apply(id, operation)?;
132    }
133    Ok(batch.updates())
134}
135
136/// The dictionary a sequence of `AppDataUpdate` proposals produces, built
137/// one proposal at a time.
138///
139/// Per-component state (`Some(bytes)` for an Update, `None` for a Remove)
140/// lets two proposals for the same component chain: the second sees the
141/// first's effect as its old value. Every payload decodes against the
142/// pre-commit registry, as on the receive side, so a registry change in
143/// the same commit does not change how its other updates are read.
144///
145/// A state can be the commit-log signer's private key, so each is wiped on
146/// drop until [`Self::updates`] hands it to OpenMLS.
147pub(crate) struct AppDataBatch<'a> {
148    group: &'a OpenMlsGroup,
149    registry: ComponentRegistry,
150    states: BTreeMap<openmls::component::ComponentId, Option<Zeroizing<Vec<u8>>>>,
151}
152
153impl<'a> AppDataBatch<'a> {
154    pub(crate) fn new(group: &'a OpenMlsGroup) -> Result<Self, ComponentSourceError> {
155        Ok(Self {
156            group,
157            registry: load_component_registry(group)?,
158            states: BTreeMap::new(),
159        })
160    }
161
162    /// A batch holding the group's pending proposals, in the order a
163    /// commit that consumes the proposal store applies them.
164    pub(crate) fn pending(group: &'a OpenMlsGroup) -> Result<Self, ComponentSourceError> {
165        let mut batch = Self::new(group)?;
166        for queued in group.pending_proposals() {
167            if let Proposal::AppDataUpdate(proposal) = queued.proposal() {
168                batch.apply(proposal.component_id(), proposal.operation())?;
169            }
170        }
171        Ok(batch)
172    }
173
174    pub(crate) fn apply(
175        &mut self,
176        id: openmls::component::ComponentId,
177        operation: &AppDataUpdateOperation,
178    ) -> Result<(), ComponentSourceError> {
179        let value = match operation {
180            AppDataUpdateOperation::Update(payload) => {
181                let xmtp_id = ComponentId::from(id);
182                Some(
183                    apply_app_data_update_payload(
184                        xmtp_id,
185                        payload.as_slice(),
186                        self.state(xmtp_id),
187                        &self.registry,
188                    )
189                    .inspect_err(|e| {
190                        tracing::warn!(
191                            component_id = %xmtp_id,
192                            error = %e,
193                            "Failed to apply AppDataUpdate payload"
194                        );
195                    })?,
196                )
197            }
198            // A Remove never consults the component's payload rules, so
199            // component-level Remove rejections (e.g. the whole-registry
200            // Remove ban) are enforced by commit validation, which both
201            // commit-processing paths run before applying.
202            AppDataUpdateOperation::Remove => None,
203        };
204        // The replaced state is wiped on drop.
205        self.states.insert(id, value);
206        Ok(())
207    }
208
209    /// The component's value after the proposals applied so far.
210    pub(crate) fn state(&self, id: ComponentId) -> Option<&[u8]> {
211        match self.states.get(&id.as_u16()) {
212            Some(value) => value.as_deref().map(Vec::as_slice),
213            None => read_from_app_data_dict(id, self.group),
214        }
215    }
216
217    /// Reject `post`, the states of proposals after the ones applied so far,
218    /// when receivers would reject the commit's dictionary total.
219    // implements: META-077
220    pub(crate) fn fits(&self, post: &AppDataPostStates) -> Result<(), CommitRuleError> {
221        let states = &self.states;
222        let earlier = states.iter().map(|(id, s)| (ComponentId::from(*id), s));
223        let earlier = earlier.filter(|(id, _)| !post.contains_key(id));
224        let committed = self.group.extensions().app_data_dictionary();
225        let post = earlier.chain(post.iter().map(|(id, s)| (*id, s)));
226        validate_post_dictionary_size(committed.map(|e| e.dictionary()), post)
227    }
228
229    /// The [`AppDataUpdates`] for the commit; `None` when no proposal was
230    /// applied.
231    pub(crate) fn updates(self) -> Option<AppDataUpdates> {
232        if self.states.is_empty() {
233            return None;
234        }
235        let mut updater = self.group.app_data_dictionary_updater();
236        for (id, value) in self.states {
237            match value {
238                // The bytes move to OpenMLS without a copy.
239                Some(mut bytes) => updater.set(ComponentData::from_parts(
240                    id,
241                    std::mem::take(&mut *bytes).into(),
242                )),
243                None => updater.remove(&id),
244            }
245        }
246        updater.changes()
247    }
248}
249
250/// Most pending non-membership `AppDataUpdate`s one commit references:
251/// about 34 KiB of references.
252const MAX_APP_DATA_PROPOSALS: usize = 1_000;
253
254/// The pending proposals one commit can carry, in store order, and the
255/// batch they produce.
256pub(crate) struct Committable<'a> {
257    pub(crate) batch: AppDataBatch<'a>,
258    pub(crate) proposals: Vec<&'a QueuedProposal>,
259}
260
261impl<'a> Committable<'a> {
262    /// The pending proposals a commit can carry: each reference once, as a
263    /// commit covers it, and each `AppDataUpdate` only if every receiver
264    /// accepts it after the proposals kept before it. Receivers check the
265    /// list rule ([`AppDataUpdateList`]), the proposer's policy against the
266    /// chained value and the post-commit membership
267    /// ([`validate_app_data_update_sequence`]), and the registry and total the
268    /// commit leaves ([`validate_post_registry`], [`validate_post_dictionary_size`]).
269    ///
270    /// Receivers judge a standalone proposal alone, against the committed
271    /// dictionary. Proposals accepted one by one can still make an invalid
272    /// commit together, such as two Inserts of one key, an Update and a
273    /// Remove of one component, or two super-admin removals that leave
274    /// none. Every member rejects a commit that carries them.
275    ///
276    /// Each kept prefix is a commit receivers accept, so a proposal is
277    /// judged without looking ahead: a later proposal that would repair it
278    /// may itself be omitted, and the intent's own operations follow.
279    ///
280    /// No client proposes a leaf Update, and receivers check its credential
281    /// and that it keeps the replaced leaf's signature key, so a commit never
282    /// carries one: the commit builder would refuse a leaf it cannot use.
283    ///
284    /// The post-commit membership comes from the kept proposals, so the
285    /// filter runs again over the kept ones until that membership stops
286    /// changing. Each further pass keeps fewer, so this ends.
287    // implements: GMOD-047
288    pub(crate) fn pending(group: &'a OpenMlsGroup) -> Result<Self, GroupError> {
289        let mut seen = HashSet::new();
290        let mut budget = MAX_APP_DATA_PROPOSALS;
291        let mut proposals: Vec<_> = group
292            .pending_proposals()
293            .filter(|queued| seen.insert(queued.proposal_reference_ref()))
294            // implements: GMOD-047
295            // Membership proposals depend on each other; keep them all.
296            // Keep the oldest other AppDataUpdates; the rest expire with the epoch.
297            .filter(|queued| match queued.proposal() {
298                Proposal::AppDataUpdate(update)
299                    if update.component_id() != ComponentId::GROUP_MEMBERSHIP.as_u16() =>
300                {
301                    let keep = budget > 0;
302                    budget = budget.saturating_sub(1);
303                    keep
304                }
305                _ => true,
306            })
307            .collect();
308        let mut membership = membership_inbox_ids(read_from_app_data_dict(
309            ComponentId::GROUP_MEMBERSHIP,
310            group,
311        ));
312        loop {
313            let committable = Self::keep(group, proposals, membership.as_ref())?;
314            let kept = membership_inbox_ids(committable.batch.state(ComponentId::GROUP_MEMBERSHIP));
315            if kept == membership {
316                return Ok(committable);
317            }
318            membership = kept;
319            proposals = committable.proposals;
320        }
321    }
322
323    /// Keep each of `proposals` a receiver accepts after the ones kept
324    /// before it, with `membership` after the commit.
325    fn keep(
326        group: &'a OpenMlsGroup,
327        proposals: Vec<&'a QueuedProposal>,
328        membership: Option<&HashSet<InboxId>>,
329    ) -> Result<Self, GroupError> {
330        let (immutable, mutable) = read_committed_metadata(group)?;
331        let mut batch = AppDataBatch::new(group)?;
332        let mut list = AppDataUpdateList::default();
333        let mut proposers = HashMap::new();
334        let mut kept = Vec::with_capacity(proposals.len());
335        for queued in proposals {
336            if matches!(queued.proposal(), Proposal::Update(_)) {
337                continue;
338            }
339            if let Proposal::AppDataUpdate(update) = queued.proposal() {
340                let id = ComponentId::from(update.component_id());
341                let operation = update.operation();
342                if !list.admits(id, operation, || {
343                    read_from_app_data_dict(id, group).is_some()
344                }) {
345                    continue;
346                }
347                let Ok(leaf) = app_data_update_proposer_leaf(queued.sender()) else {
348                    continue;
349                };
350                let proposer = match proposers.entry(*leaf) {
351                    Entry::Occupied(entry) => entry.into_mut(),
352                    Entry::Vacant(entry) => {
353                        match extract_commit_participant(leaf, group, &immutable, &mutable) {
354                            Ok(proposer) => entry.insert(proposer),
355                            Err(_) => continue,
356                        }
357                    }
358                };
359                let update = AppDataUpdateInCommit {
360                    component_id: id,
361                    operation,
362                    actor: (&*proposer).into(),
363                    proposer_inbox_id: &proposer.inbox_id,
364                };
365                let Ok(post) = validate_app_data_update_sequence(
366                    [update],
367                    |id| batch.state(id),
368                    &batch.registry,
369                    immutable.dm_members.as_ref(),
370                    membership,
371                ) else {
372                    continue;
373                };
374                if validate_post_registry(&post).is_err() || batch.fits(&post).is_err() {
375                    continue;
376                }
377                list.push(id, operation);
378                batch
379                    .states
380                    .extend(post.into_iter().map(|(id, state)| (id.as_u16(), state)));
381            }
382            kept.push(queued);
383        }
384        Ok(Self {
385            batch,
386            proposals: kept,
387        })
388    }
389}
390
391/// AppDataUpdate-aware wrapper around [`OpenMlsGroup::process_message`].
392///
393/// `OpenMlsGroup::process_message` returns a commit covering
394/// `AppDataUpdate` proposals as
395/// [`ProcessedMessageContent::UnresolvedAppDataCommit`] — the application
396/// is required to interpret the proposals, compute the resulting
397/// [`AppDataUpdates`], and resume staging. This wrapper does that dance:
398///
399/// 1. `process_message` as usual.
400/// 2. On an unresolved app-data commit, hand its (already
401///    reference-resolved) `AppDataUpdate` proposals to
402///    [`accumulate_app_data_updates`] to compute the resulting
403///    [`AppDataUpdates`].
404/// 3. Call `resolve_app_data_commit` with those updates, staging the
405///    commit and yielding a regular `StagedCommitMessage`.
406///
407/// Callers replace `mls_group.process_message(provider, message)` with
408/// `process_message_with_app_data(mls_group, provider, message)` and get
409/// back the same `ProcessedMessage` they used to; the
410/// `UnresolvedAppDataCommit` variant never escapes this function.
411/// `own` is the client's parsed pkg_version (threaded from the caller's
412/// context rather than read from a constant so cross-version tests can
413/// override it).
414pub(crate) fn process_message_with_app_data<Provider: OpenMlsProvider>(
415    mls_group: &mut OpenMlsGroup,
416    provider: &Provider,
417    message: impl Into<ProtocolMessage>,
418    own: &LibXMTPVersion,
419) -> Result<ProcessedMessage, ProcessMessageWithAppDataError<Provider::StorageError>> {
420    let processed = mls_group.process_message(provider, message)?;
421
422    // PAUSE BEFORE PARSE: every commit on a below-floor group must pause
423    // (held cursor, `set_group_paused`), never process — above-floor
424    // peers accept it and advance, so rejecting instead of pausing forks
425    // the group. Checked here, *after* `process_message` authenticated
426    // the message, so the pause decision is never driven by
427    // unauthenticated framing bits a sender could spoof to freeze
428    // application-message processing on a below-floor group. For an
429    // `UnresolvedAppDataCommit` this runs before the proposals are
430    // interpreted below — the commit's app-data payloads may use wire
431    // formats introduced after this version. It reads ONLY the
432    // pre-commit dict — committed, already-validated state — and must
433    // never consider the commit's own proposals: a same-commit floor
434    // bump has not passed the super-admin policy check yet, and pausing
435    // on unvalidated input would let any member freeze the group for
436    // everyone. Application messages are unaffected; standalone
437    // proposals get the same floor-first hold in `mls_sync`'s
438    // `ProposalMessage` arm, which also keeps a below-floor client from
439    // ever advancing past a stored-by-peers proposal that a later commit
440    // references. (Staging a plain commit inside `process_message`
441    // interprets no app-data payloads; nothing is merged until
442    // `merge_staged_commit`.)
443    let is_commit = matches!(
444        processed.content(),
445        ProcessedMessageContent::StagedCommitMessage(_)
446            | ProcessedMessageContent::UnresolvedAppDataCommit(_)
447    );
448    if is_commit && let Some(min_version) = committed_floor_exceeding(mls_group, own) {
449        return Err(ProcessMessageWithAppDataError::ProtocolVersionTooLow {
450            min_version,
451            own_version: own.to_string(),
452        });
453    }
454
455    let unresolved = match processed.content() {
456        ProcessedMessageContent::UnresolvedAppDataCommit(unresolved) => unresolved,
457        _ => return Ok(processed),
458    };
459
460    // Collect owned (id, operation) tuples so the iterator doesn't keep
461    // `processed` borrowed — `resolve_app_data_commit` consumes it below.
462    // Proposals committed by reference are already resolved from the
463    // proposal store by `process_message`.
464    let collected: Vec<(openmls::component::ComponentId, AppDataUpdateOperation)> = unresolved
465        .app_data_update_proposals()
466        .map(|p| (p.component_id(), p.operation().clone()))
467        .collect();
468    let iter = collected.iter().map(|(id, op)| (*id, op));
469    let app_data_updates = accumulate_app_data_updates(mls_group, iter)?;
470
471    Ok(mls_group.resolve_app_data_commit(provider, processed, app_data_updates)?)
472}
473
474/// Stage a standalone `AppDataUpdate(Update)` proposal AND a follow-up
475/// commit that references it from the OpenMLS proposal store.
476///
477/// This is the shape XIP §1.5.2 / §3.4 prescribes for post-migration
478/// metadata updates: separate proposal and commit MLS messages, so the
479/// commit message carries only a `ProposalRef` (hash) rather than the
480/// AppDataUpdate payload bytes. Smaller commits, smaller proposal-
481/// processing hot paths, identical end state.
482///
483/// Returns `(proposal_msg, commit_bundle)`. The caller MUST publish
484/// `proposal_msg` and `commit_bundle.commit()` together in one
485/// `payloads_to_publish` batch (proposal first) so receivers see the
486/// proposal in the same network round trip before processing the
487/// commit that references it.
488///
489/// Call this inside `generate_prepared_commit` and an outer state transaction.
490/// Store the exact attempt and staged commit in that transaction.
491pub(crate) fn stage_app_data_propose_and_commit<Provider: OpenMlsProvider>(
492    mls_group: &mut OpenMlsGroup,
493    provider: &Provider,
494    signer: &impl openmls_traits::signatures::Signer,
495    catalogue: &[ApplicationComponentDefinition],
496    component_id: ComponentId,
497    payload: Vec<u8>,
498) -> Result<(MlsMessageOut, CommitMessageBundle), GroupAppDataError<Provider::StorageError>> {
499    let (mut proposals, bundle) = stage_app_data_proposals_and_commit(
500        mls_group,
501        provider,
502        signer,
503        catalogue,
504        vec![(component_id, AppDataUpdateOperation::Update(payload.into()))],
505    )?;
506    Ok((proposals.remove(0), bundle))
507}
508
509/// Propose every `updates` operation and stage one commit of them, using
510/// the same pre-commit state. The commit also carries this client's
511/// membership upkeep for `catalogue` inline.
512pub(crate) fn stage_app_data_proposals_and_commit<Provider: OpenMlsProvider>(
513    mls_group: &mut OpenMlsGroup,
514    provider: &Provider,
515    signer: &impl openmls_traits::signatures::Signer,
516    catalogue: &[ApplicationComponentDefinition],
517    updates: Vec<(ComponentId, AppDataUpdateOperation)>,
518) -> Result<(Vec<MlsMessageOut>, CommitMessageBundle), GroupAppDataError<Provider::StorageError>> {
519    // Lazy-batching: we deliberately do NOT block on pre-existing
520    // pending proposals. This helper queues a new `AppDataUpdate` then
521    // commits via `consume_proposal_store(true)`, sweeping whatever
522    // else is in the store — concurrent `AppDataUpdate`s (accumulated
523    // into the dict by step 2), membership `Add` / `Remove`, etc. — all
524    // into one commit, less what `omit_uncommittable_proposals` omitted.
525    // That's the design: minimize commit count, let the producers of
526    // those proposals decide if they need to force their own commit
527    // (because they want to send a message right now or grant access
528    // immediately). MLS guarantees consistent state
529    // convergence on the wire regardless of which commit body carries
530    // which proposal; the sender's intent ledger may carry less
531    // information than the on-wire commit, but the producer of each
532    // folded-in proposal already accepted that outcome by leaving it
533    // pending instead of issuing its own commit.
534    let mut proposals = Vec::with_capacity(updates.len());
535    for (component_id, operation) in updates {
536        let (proposal, _) = mls_group
537            .propose_app_data_update(provider, signer, component_id.as_u16(), operation)
538            .map_err(GroupAppDataError::Propose)?;
539        proposals.push(proposal);
540    }
541
542    // Step 2: registry reconciliation and cleanup after any swept removal,
543    // with the dict updates of every `AppDataUpdate` in the store (earlier
544    // intents' queued proposals included) followed by the inline upkeep, so
545    // the final dict bytes match what `process_message_with_app_data`
546    // produces on the receive side. If OpenMLS ever changes
547    // `consume_proposal_store(true)`'s sweep or `pending_proposals()`
548    // ordering, receivers reject the commit with `WrongConfirmationTag`; the
549    // AppDataUpdate E2E tests in `groups/tests/test_proposals.rs` catch that.
550    let (upkeep, app_data_updates) = membership_upkeep::membership_upkeep(mls_group, catalogue)
551        .map_err(|e| GroupAppDataError::Upkeep(Box::new(e)))?;
552
553    // Step 3: build a commit that consumes the proposal store. The
554    // just-queued proposals are encoded as `ProposalRef`s; only the upkeep
555    // is inline.
556    let mut stage = crate::groups::mls_ext::commit_without_group_info(
557        mls_group,
558        provider.storage(),
559        |builder| builder.consume_proposal_store(true).add_proposals(upkeep),
560    )?;
561    stage.with_app_data_dictionary_updates(app_data_updates);
562
563    let bundle = stage
564        .build(provider.rand(), provider.crypto(), signer, |_| true)?
565        .stage_commit(provider)?;
566
567    Ok((proposals, bundle))
568}
569
570/// Errors surfaced by [`stage_app_data_propose_and_commit`].
571///
572/// Wrapped into `GroupError` via the `#[from]` impl on
573/// `GroupError::AppDataCommit` so the structured source is preserved at
574/// the call site (no string-flattening). The `pub(crate)` visibility
575/// matches the helper itself; the variant is only re-exported through
576/// the public `GroupError` enum.
577#[derive(Debug, thiserror::Error)]
578pub enum GroupAppDataError<StorageError: std::error::Error> {
579    /// `propose_app_data_update(…)` failed when staging the standalone
580    /// proposal that precedes the commit.
581    #[error("propose error: {0}")]
582    Propose(#[from] ProposalError<StorageError>),
583    /// `commit_builder().load_psks(…).build(…)` failed.
584    #[error("commit create error: {0}")]
585    CreateCommit(#[from] openmls::group::CreateCommitError),
586    /// `stage_commit(provider)` failed (storage / signature / staging error).
587    #[error("commit stage error: {0}")]
588    StageCommit(#[from] openmls::group::CommitBuilderStageError<StorageError>),
589    /// `apply_app_data_update_payload` failed while pre-computing the new
590    /// dict value the commit builder hands to OpenMLS. The most common
591    /// cause is a mismatch between the sender's idea of the current dict
592    /// state and the receiver's, which would surface as a confirmation
593    /// tag mismatch on the wire if it ever escaped.
594    #[error("apply payload error: {0}")]
595    ApplyPayload(#[from] xmtp_mls_group_state::app_data::component_source::ComponentSourceError),
596    /// Computing the removed-member cleanup for a swept removal failed, or
597    /// the commit would grow the dictionary past its total.
598    #[error("membership upkeep error: {0}")]
599    Upkeep(#[source] Box<crate::groups::GroupError>),
600}
601
602// Specialize to the concrete SqlKeyStoreError because that's the only
603// storage instantiation used (see `GroupError::AppDataCommit` at
604// error.rs). It also lets us delegate to `RetryableError<Mls>` impls
605// already defined in `xmtp_db::errors` for the inner OpenMLS error
606// types — sibling pattern to `GroupError::Proposal(e) => e.is_retryable()`
607// — so SQLite-busy storage faults retry instead of permanently failing
608// the intent.
609impl xmtp_common::RetryableError for GroupAppDataError<xmtp_db::sql_key_store::SqlKeyStoreError> {
610    fn is_retryable(&self) -> bool {
611        match self {
612            // Delegate to the inner OpenMLS error's retryability so
613            // SQLite-busy storage faults during propose / stage retry
614            // rather than permanently fail the intent. The matching
615            // upstream impls live in `xmtp_db::errors`
616            // (`RetryableError<Mls>` for `ProposalError` /
617            // `CommitBuilderStageError`).
618            Self::Propose(e) => xmtp_common::retryable!(e),
619            Self::StageCommit(e) => xmtp_common::retryable!(e),
620            Self::Upkeep(e) => xmtp_common::retryable!(e),
621            // Deterministic shape / staging-precondition failures —
622            // CreateCommit is upstream-`false`, and ApplyPayload is a
623            // sender-side encode failure that won't get better on
624            // retry.
625            Self::CreateCommit(_) | Self::ApplyPayload(_) => false,
626        }
627    }
628}
629
630/// The dictionary a commit built now starts from: the committed one with
631/// the updates of the pending proposals it can carry applied (see
632/// [`Committable::pending`]).
633pub(crate) fn pending_dictionary(
634    mls_group: &OpenMlsGroup,
635) -> Result<PendingDictionary, GroupError> {
636    let mut dictionary = PendingDictionary(
637        mls_group
638            .extensions()
639            .app_data_dictionary()
640            .map(|extension| extension.dictionary().clone())
641            .unwrap_or_default(),
642    );
643    for (id, value) in Committable::pending(mls_group)?
644        .batch
645        .updates()
646        .into_iter()
647        .flatten()
648    {
649        let replaced = match value {
650            Some(value) => dictionary.0.insert(id, value),
651            None => dictionary.0.remove(&id),
652        };
653        wipe_component_value(replaced);
654    }
655    Ok(dictionary)
656}
657
658/// A copy of a group's AppData dictionary. Its `GROUP_ROOT_KEY` entry is the
659/// group root, and in groups older clients created its `COMMIT_LOG_SIGNER`
660/// entry is a private signing key, so every entry is wiped on drop.
661pub(crate) struct PendingDictionary(AppDataDictionary);
662
663impl std::ops::Deref for PendingDictionary {
664    type Target = AppDataDictionary;
665
666    fn deref(&self) -> &AppDataDictionary {
667        &self.0
668    }
669}
670
671impl Drop for PendingDictionary {
672    fn drop(&mut self) {
673        let ids: Vec<_> = self.0.entries().map(|entry| entry.id()).collect();
674        for id in ids {
675            wipe_component_value(self.0.remove(&id));
676        }
677    }
678}
679
680/// Wipe a component value taken out of an OpenMLS dictionary. The value
681/// converts into its own buffer without a copy.
682fn wipe_component_value(value: Option<impl Into<Vec<u8>>>) {
683    if let Some(value) = value {
684        value.into().zeroize();
685    }
686}
687
688/// Read the committed component registry from the dictionary.
689/// Missing registry entries reject component writes by default.
690pub(crate) fn load_component_registry(
691    mls_group: &OpenMlsGroup,
692) -> Result<ComponentRegistry, ComponentSourceError> {
693    load_component_registry_from_extensions(mls_group.extensions())
694}
695
696/// Read the component registry without loading an OpenMLS group.
697pub(crate) fn load_component_registry_from_extensions(
698    extensions: &openmls::extensions::Extensions<openmls::group::GroupContext>,
699) -> Result<ComponentRegistry, ComponentSourceError> {
700    // The committed dictionary is the source of truth for the registry.
701    if let Some(ext) = extensions.app_data_dictionary()
702        && let Some(bytes) = ext
703            .dictionary()
704            .get(&ComponentId::COMPONENT_REGISTRY.as_u16())
705    {
706        return ComponentRegistry::from_bytes(bytes)
707            .map_err(|e| ComponentSourceError::MalformedComponentValue {
708                component_id: ComponentId::COMPONENT_REGISTRY,
709                reason: format!("registry decode: {e}"),
710            })
711            .inspect(|reg| {
712                // Tolerated (preserved-but-invisible) entries mean the
713                // dict was written by a newer protocol version or
714                // carries a historical invalid entry. Writes to those
715                // components fall to deny-by-default; everything else
716                // validates normally. Loud so poisoned-registry
717                // incidents are diagnosable from logs.
718                let unrecognized: Vec<_> = reg.unrecognized_ids().collect();
719                if !unrecognized.is_empty() {
720                    tracing::warn!(
721                        ?unrecognized,
722                        "component registry contains unrecognized entries; \
723                         treating them as unregistered (deny-by-default)"
724                    );
725                }
726            });
727    }
728
729    // Pre-migration or test override.
730    #[cfg(any(test, feature = "test-utils"))]
731    if let Ok(reg) = TEST_REGISTRY_OVERRIDE.try_with(|r| r.clone()) {
732        return Ok(reg);
733    }
734    Ok(ComponentRegistry::new())
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use openmls::extensions::{
741        AppDataDictionary, AppDataDictionaryExtension, Extension, Extensions,
742    };
743
744    fn extensions_with_dict(
745        entries: &[(u16, Vec<u8>)],
746    ) -> Extensions<openmls::group::GroupContext> {
747        let mut dict = AppDataDictionary::new();
748        for (id, bytes) in entries {
749            let _ = dict.insert(*id, bytes.clone());
750        }
751        Extensions::from_vec(vec![Extension::AppDataDictionary(
752            AppDataDictionaryExtension::new(dict),
753        )])
754        .expect("AppDataDictionary is a valid GroupContext extension")
755    }
756
757    fn empty_extensions() -> Extensions<openmls::group::GroupContext> {
758        Extensions::from_vec(vec![]).expect("empty extensions are always valid")
759    }
760
761    // ========================================================================
762    // load_component_registry_from_extensions
763    // ========================================================================
764    //
765    // These pin the contract that the migration-marker
766    // (registry entry presence) and the registry loader
767    // (`load_component_registry_from_extensions`, parseability) agree on
768    // exactly one shape of disagreement: malformed bytes surface as a
769    // hard `MalformedComponentValue` error rather than silently
770    // collapsing to an empty registry. An empty registry on a "migrated"
771    // group would cause downstream readers (mutable_metadata, validators)
772    // to silently lose every dict-backed component, so this invariant is
773    // load-bearing.
774
775    #[xmtp_common::test(unwrap_try = true)]
776    fn load_registry_no_dict_returns_empty() {
777        let reg = load_component_registry_from_extensions(&empty_extensions()).unwrap();
778        assert!(reg.is_empty());
779    }
780
781    #[xmtp_common::test(unwrap_try = true)]
782    fn load_registry_dict_without_entry_returns_empty() {
783        // Dict present but no COMPONENT_REGISTRY entry => pre-bootstrap.
784        // An entry under some *other* component id must not be confused
785        // for the registry payload.
786        let exts =
787            extensions_with_dict(&[(ComponentId::GROUP_NAME.as_u16(), b"Group Name".to_vec())]);
788        let reg = load_component_registry_from_extensions(&exts).unwrap();
789        assert!(reg.is_empty());
790    }
791
792    #[xmtp_common::test(unwrap_try = true)]
793    fn load_registry_with_valid_bytes_round_trips() {
794        let original = ComponentRegistry::new();
795        let bytes = original.to_bytes().expect("empty registry serializes");
796        let exts = extensions_with_dict(&[(ComponentId::COMPONENT_REGISTRY.as_u16(), bytes)]);
797        let loaded = load_component_registry_from_extensions(&exts).unwrap();
798        assert_eq!(loaded, original);
799    }
800
801    #[xmtp_common::test(unwrap_try = true)]
802    fn load_registry_with_malformed_bytes_surfaces_error() {
803        // Pin the "fail loud, never return empty" invariant: a
804        // malformed `COMPONENT_REGISTRY` value must surface as
805        // `MalformedComponentValue` so downstream readers don't carry
806        // on with a phantom empty registry against an
807        // dictionary with a registry entry.
808        let exts = extensions_with_dict(&[(
809            ComponentId::COMPONENT_REGISTRY.as_u16(),
810            vec![0xff, 0xff, 0xff],
811        )]);
812        let err = load_component_registry_from_extensions(&exts).unwrap_err();
813        assert!(
814            matches!(
815                err,
816                ComponentSourceError::MalformedComponentValue { component_id, .. }
817                    if component_id == ComponentId::COMPONENT_REGISTRY
818            ),
819            "expected MalformedComponentValue for COMPONENT_REGISTRY, got: {err:?}"
820        );
821    }
822}