Skip to main content

xmtp_mls/groups/
validated_commit.rs

1#[cfg(test)]
2mod identity_tests;
3use crate::{
4    context::XmtpSharedContext,
5    identity_updates::{
6        IdentityDependencyError, IdentityRequirement, InstallationDiff, InstallationDiffError,
7        MissingProofs, get_installation_diff_local, require_association_state,
8        required_leaf_removals,
9    },
10};
11use openmls::{
12    extensions::Extensions,
13    group::{GroupContext, MlsGroup as OpenMlsGroup, QueuedProposal, StagedCommit},
14    messages::proposals::Proposal,
15    prelude::{LeafNodeIndex, Sender},
16};
17
18use super::traits::FromWith;
19use serde::Serialize;
20use std::collections::{HashMap, HashSet};
21use thiserror::Error;
22use xmtp_common::{retry::RetryableError, retryable, snippet::Snippet};
23use xmtp_db::local_commit_log::CommitType;
24use xmtp_db::{DbQuery, StorageError};
25#[cfg(doc)]
26use xmtp_id::associations::AssociationState;
27use xmtp_id::{InboxId, associations::MemberIdentifier};
28use xmtp_mls_group_state::{
29    app_data::component_source::{ComponentSourceError, read_from_app_data_dict},
30    group_metadata::{DmMembers, GroupMetadata, GroupMetadataError},
31    group_mutable_metadata::{GroupMutableMetadata, GroupMutableMetadataError},
32    libxmtp_version::{InvalidVersionFormat, LibXMTPVersion},
33};
34use xmtp_mls_validation::{
35    ForkActors,
36    commit::{
37        CommitParticipant, CommitRuleError, Inbox, MembershipValidationInfo, MetadataChanges,
38        ProposalChanges, build_inbox_with_proposer, extract_commit_participant,
39        extract_committer_and_proposers, extract_group_membership_with_failures,
40        extract_readded_installations, get_proposal_changes, inbox_id_from_credential, leaf_owners,
41        metadata_changes_between, read_committed_metadata, read_post_commit_mutable_metadata,
42        validate_app_data_update_proposals_in_commit, validate_committed_proposals,
43        validate_identity_sequence_order, validate_membership_diff, validate_one_app_data_update,
44        validate_sync_membership,
45    },
46    group_membership::{FailedInstallations, GroupMembership},
47    group_permissions::{GroupMutablePermissionsError, MembershipPolicy, PolicySet},
48};
49use xmtp_proto::xmtp::mls::message_contents::{
50    GroupMembershipChanges, GroupUpdated as GroupUpdatedProto,
51    group_updated::{Inbox as InboxProto, MetadataFieldChange as MetadataFieldChangeProto},
52};
53
54/// Commit validation failed. [`CommitRuleError`] holds rule violations;
55/// the other variants need client state (identity proofs, the database).
56#[derive(Debug, Error)]
57pub enum CommitValidationError {
58    /// Committed local state could not be read. Repair or restore before retry.
59    #[error("Committed group state is invalid: {0}")]
60    InstalledState(#[source] Box<CommitValidationError>),
61    /// Resolve this proof outside the state transaction, then reload MLS state.
62    #[error(transparent)]
63    IdentityDependency(#[from] IdentityDependencyError),
64    #[error(transparent)]
65    InstallationDiff(#[from] InstallationDiffError),
66    #[error(transparent)]
67    StorageError(#[from] StorageError),
68    /// The commit breaks a validation rule.
69    #[error(transparent)]
70    Rule(#[from] CommitRuleError),
71}
72
73impl crate::worker::NeedsDbReconnect for CommitValidationError {
74    #[deny(clippy::wildcard_enum_match_arm)]
75    fn needs_db_reconnect(&self) -> bool {
76        match self {
77            Self::InstalledState(error) => error.needs_db_reconnect(),
78            Self::IdentityDependency(error) => error.needs_db_reconnect(),
79            Self::InstallationDiff(error) => error.needs_db_reconnect(),
80            Self::StorageError(error) => error.db_needs_connection(),
81            Self::Rule(_) => false,
82        }
83    }
84}
85
86impl RetryableError for CommitValidationError {
87    fn is_retryable(&self) -> bool {
88        match self {
89            CommitValidationError::IdentityDependency(error) => retryable!(error),
90            CommitValidationError::InstallationDiff(diff_error) => retryable!(diff_error),
91            CommitValidationError::Rule(error) => retryable!(error),
92            _ => false,
93        }
94    }
95
96    fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
97        match self {
98            CommitValidationError::IdentityDependency(error) => error.rate_limit_delay(),
99            CommitValidationError::InstallationDiff(diff_error) => diff_error.rate_limit_delay(),
100            _ => None,
101        }
102    }
103}
104
105impl CommitValidationError {
106    /// Mark a local state failure so malformed wire input cannot hide corruption.
107    pub(crate) fn installed_state(error: impl Into<Self>) -> Self {
108        Self::InstalledState(Box::new(error.into()))
109    }
110
111    /// Only authenticated, deterministic invalid input can advance the prefix.
112    /// Missing state, failed proofs, unsupported versions, and storage failures
113    /// leave the head pending even when their error is not retryable.
114    pub(crate) fn is_safe_rejection(&self) -> bool {
115        match self {
116            Self::IdentityDependency(
117                IdentityDependencyError::MissingReference(_)
118                | IdentityDependencyError::InvalidSequence(_),
119            ) => true,
120            Self::Rule(error) => error.is_safe_rejection(),
121            Self::InstalledState(_)
122            | Self::IdentityDependency(_)
123            | Self::InstallationDiff(_)
124            | Self::StorageError(_) => false,
125        }
126    }
127}
128
129/// Route a rule-layer error type through [`CommitValidationError::Rule`], so
130/// `?` keeps working on the calls the validator makes.
131macro_rules! from_rule_error {
132    ($($source:ty),* $(,)?) => {
133        $(impl From<$source> for CommitValidationError {
134            fn from(error: $source) -> Self {
135                Self::Rule(CommitRuleError::from(error))
136            }
137        })*
138    };
139}
140from_rule_error!(
141    InvalidVersionFormat,
142    GroupMetadataError,
143    GroupMutableMetadataError,
144    GroupMutablePermissionsError,
145    ComponentSourceError,
146);
147
148/**
149 * A [`ValidatedCommit`] is a summary of changes coming from a MLS commit, after all of our validation rules have been applied
150 *
151 * Commit Validation Rules:
152 * 1. If the `sequence_id` for an inbox has changed, it can only increase
153 * 2. The client must create an expected diff of installations added and removed based on the difference between the current
154 *    [`GroupMembership`] and the [`GroupMembership`] found in the [`StagedCommit`]
155 * 3. Installations may only be added or removed in the commit if they were added/removed in the expected diff
156 * 4. For the committer's leaf, the update path leaf, and every Add or Update proposal leaf, clients must verify that the
157 *    `installation_id` is present in the [`AssociationState`] for the `inbox_id` presented in the credential at the
158 *    `to_sequence_id` found in the new [`GroupMembership`].
159 * 5. All proposals must come from group members (proposer permissions are validated, not committer)
160 * 6. Only Add, Update, Remove, and AppDataUpdate proposals are allowed, in a valid list
161 * 7. New installations may be missing from the commit but still be present in the expected diff.
162 * 8. Confirms metadata character limit is not exceeded
163 */
164#[derive(Debug, Clone, Serialize)]
165pub struct ValidatedCommit {
166    /// The actor who created the commit (the committer)
167    pub actor: CommitParticipant,
168    /// All unique proposers who created proposals in this commit
169    pub proposers: Vec<CommitParticipant>,
170    pub added_inboxes: Vec<Inbox>,
171    pub removed_inboxes: Vec<Inbox>,
172    pub readded_installations: HashSet<Vec<u8>>,
173    pub metadata_changes: MetadataChanges,
174    pub metadata_component_ids: Vec<u16>,
175    pub installations_changed: bool,
176    pub permissions_changed: bool,
177    pub dm_members: Option<DmMembers<String>>,
178}
179
180impl ValidatedCommit {
181    /// Test helper for commits that have not received an envelope sequence.
182    #[cfg(test)]
183    pub async fn from_staged_commit(
184        context: &impl XmtpSharedContext,
185        staged_commit: &StagedCommit,
186        committer_leaf_index: LeafNodeIndex,
187        openmls_group: &OpenMlsGroup,
188    ) -> Result<Self, CommitValidationError> {
189        loop {
190            match Self::from_staged_commit_local(
191                context,
192                &context.db(),
193                staged_commit,
194                committer_leaf_index,
195                openmls_group,
196                u64::MAX,
197            ) {
198                Err(CommitValidationError::IdentityDependency(IdentityDependencyError::Need(
199                    requirements,
200                ))) => {
201                    crate::identity_updates::resolve_identity_requirements(context, &requirements)
202                        .await?;
203                }
204                result => return result,
205            }
206        }
207    }
208
209    /// Validate with the caller's write connection and exact cached proofs.
210    /// This method cannot fetch identities or call an async signature verifier.
211    /// Every referenced identity sequence `N` must precede envelope sequence `S`.
212    /// On `Need`, roll back, resolve outside the writer, then reload MLS state.
213    pub(crate) fn from_staged_commit_local(
214        context: &impl XmtpSharedContext,
215        conn: &impl DbQuery,
216        staged_commit: &StagedCommit,
217        committer_leaf_index: LeafNodeIndex,
218        openmls_group: &OpenMlsGroup,
219        envelope_sequence: u64,
220    ) -> Result<Self, CommitValidationError> {
221        // PAUSE BEFORE PARSE: when the group's committed floor already
222        // exceeds this client's version, every migrated-state read
223        // below (dict-seeded metadata, registry loads, per-proposal
224        // dispatch) may encounter wire formats introduced after this
225        // version — and any error they raise is a non-retryable
226        // rejection, i.e. a fork against above-floor peers. Surface
227        // the version gap first so the group pauses and the commit is
228        // reprocessed after upgrade. Deliberately reads only the
229        // pre-commit dict (committed, already-validated state) — the
230        // commit that *raises* the floor is instead paused by the
231        // post-policy check at the end of this function, after its
232        // super-admin permission has been verified. See
233        // `committed_floor_exceeding` for the full rationale.
234        if let Some(min_version) =
235            xmtp_mls_group_state::app_data::protocol_floor::committed_floor_exceeding(
236                openmls_group,
237                context.version_info().pkg_semver(),
238            )
239        {
240            return Err(CommitValidationError::Rule(
241                CommitRuleError::ProtocolVersionTooLow(min_version),
242            ));
243        }
244        // implements: GMOD-001, GMOD-030
245        validate_committed_proposals(
246            staged_commit
247                .queued_proposals()
248                .map(QueuedProposal::proposal),
249            |id| read_from_app_data_dict(id, openmls_group).is_some(),
250        )?;
251        let (immutable_metadata, mutable_metadata) = read_committed_metadata(openmls_group)
252            .map_err(CommitValidationError::installed_state)?;
253
254        let group_permissions =
255            super::group_permissions::policy_set_from_dictionary(openmls_group.extensions())
256                .map_err(|error| {
257                    CommitValidationError::installed_state(CommitValidationError::Rule(
258                        CommitRuleError::GroupMutablePermissions(error),
259                    ))
260                })?;
261        let leaf_owners =
262            leaf_owners(openmls_group.members()).map_err(CommitValidationError::installed_state)?;
263        let current_group_members = leaf_owners.keys().cloned().collect();
264
265        // Reuse the committed registry for all component checks. Apply pending
266        // floor updates to this view, then check their policy before pausing.
267        let registry = super::app_data::load_component_registry(openmls_group)
268            .map_err(CommitValidationError::installed_state)?;
269        let min_version_bytes =
270                xmtp_mls_group_state::app_data::component_source::read_post_commit_component_bytes(
271                    xmtp_mls_group_state::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
272                    openmls_group,
273                    staged_commit,
274                    &registry,
275                )
276                .map_err(xmtp_mls_group_state::group_mutable_metadata::GroupMutableMetadataError::from)?;
277        let minimum_supported_protocol_version = match min_version_bytes {
278                Some(bytes) => Some(std::str::from_utf8(&bytes).map(str::to_owned).map_err(|e| {
279                    CommitValidationError::Rule(CommitRuleError::GroupMutableMetadata(
280                        GroupMutableMetadataError::MalformedComponent {
281                            component_id: Some(
282                                xmtp_mls_group_state::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
283                            ),
284                            reason: format!("invalid utf-8: {e}"),
285                        },
286                    ))
287                })?),
288                None => None,
289            };
290        // Get the committer who created the commit and all unique proposers.
291        // The committer may differ from the proposers (e.g., when one member commits
292        // proposals created by other members).
293        let (actor, proposers) = extract_committer_and_proposers(
294            staged_commit,
295            committer_leaf_index,
296            openmls_group,
297            &immutable_metadata,
298            &mutable_metadata,
299        )?;
300
301        // AppDataUpdate proposals carried by a commit (inline OR by
302        // reference, since `staged_commit.app_data_update_proposals()`
303        // iterates both) never flow through `validate_proposal()` —
304        // that path only handles standalone proposal-by-reference
305        // messages — so this is where their permission check lives.
306        validate_app_data_update_proposals_in_commit(
307            staged_commit,
308            openmls_group,
309            &immutable_metadata,
310            &mutable_metadata,
311            &registry,
312        )?;
313
314        // Get the installations actually added and removed in the commit
315        let ProposalChanges {
316            mut added_installations,
317            self_added_installations,
318            mut removed_installations,
319            mut credentials_to_verify,
320            added_inbox_proposers,
321            removed_inbox_proposers,
322        } = get_proposal_changes(
323            staged_commit,
324            openmls_group,
325            &immutable_metadata,
326            &mutable_metadata,
327        )?;
328
329        // OpenMLS lets a commit's update path replace the committer's leaf with
330        // any credential and signature key. The leaf must keep the committer's
331        // inbox, and its key is checked like any other leaf's below.
332        // implements: GMOD-043
333        if let Some(leaf_node) = staged_commit.update_path_leaf_node() {
334            let path_leaf = CommitParticipant::from_leaf_node(
335                leaf_node,
336                &immutable_metadata,
337                &mutable_metadata,
338            )?;
339            if path_leaf.inbox_id != actor.inbox_id {
340                return Err(CommitValidationError::Rule(
341                    CommitRuleError::InboxValidationFailed(path_leaf.inbox_id),
342                ));
343            }
344            credentials_to_verify.push(path_leaf);
345        }
346
347        // Get the expected diff of installations added and removed based on the difference between the current
348        // group membership and the new group membership.
349        // Also gets back the added and removed inbox ids from the expected diff
350        let expected_diff = ExpectedDiff::from_staged_commit_with_proposers(
351            conn,
352            staged_commit,
353            openmls_group,
354            envelope_sequence,
355            &added_inbox_proposers,
356            &removed_inbox_proposers,
357        )?;
358
359        let ExpectedDiff {
360            old_group_membership,
361            new_group_membership,
362            old_failed,
363            new_failed,
364            expected_installation_diff,
365            added_inboxes,
366            removed_inboxes,
367        } = expected_diff;
368
369        let installations_changed =
370            !added_installations.is_empty() || !removed_installations.is_empty();
371
372        let mut failed_installations: HashSet<Vec<u8>> = new_group_membership
373            .failed_installations
374            .iter()
375            .cloned()
376            .collect();
377
378        let required_removals = required_leaf_removals(
379            conn,
380            &old_group_membership,
381            &new_group_membership,
382            &expected_installation_diff.removed_installations,
383            &leaf_owners,
384        )?;
385
386        // Remove readded installations from the added/removed/failed lists before going through validation
387        let readded_installations = extract_readded_installations(
388            ForkActors::new(&immutable_metadata, &mutable_metadata)
389                .readd_authority(&actor.inbox_id),
390            &mut added_installations,
391            &mut removed_installations,
392            &mut failed_installations,
393        );
394        // Ensure that the expected diff matches the added/removed installations in the proposals
395        expected_diff_matches_commit(
396            &expected_installation_diff,
397            added_installations,
398            &self_added_installations,
399            removed_installations,
400            &required_removals,
401            &current_group_members,
402        )?;
403        validate_new_failed_installations(
404            conn,
405            &new_group_membership,
406            &old_failed,
407            &new_failed,
408            &leaf_owners,
409        )?;
410        credentials_to_verify.push(actor.clone());
411
412        // Verify the credentials of the following entities
413        // 1. The actor who created the commit
414        // 2. The leaf in the commit's update path
415        // 3. Every leaf in an Add or Update proposal
416        // Satisfies Rule 4
417        // implements: GMOD-012
418        for participant in credentials_to_verify {
419            let inbox_id = &participant.inbox_id;
420            let sequence_id = match new_group_membership.get(inbox_id) {
421                None => {
422                    return Err(CommitValidationError::Rule(
423                        CommitRuleError::SubjectDoesNotExist,
424                    ));
425                }
426                Some(0) if old_group_membership.get(inbox_id) == Some(&0) => {
427                    // An unchanged creation placeholder uses the authenticated
428                    // committed leaf. A later identity tip cannot change this
429                    // decision. New keys still require an exact nonzero proof.
430                    let known_leaf = openmls_group.members().any(|member| {
431                        member.signature_key == participant.installation_id
432                            && inbox_id_from_credential(&member.credential)
433                                .is_ok_and(|known_inbox| known_inbox == *inbox_id)
434                    });
435                    if !known_leaf {
436                        return Err(CommitValidationError::Rule(
437                            CommitRuleError::InboxValidationFailed(inbox_id.clone()),
438                        ));
439                    }
440                    continue;
441                }
442                Some(sequence_id) => *sequence_id,
443            };
444            let inbox_state = require_association_state(
445                conn,
446                &IdentityRequirement {
447                    inbox_id: inbox_id.clone(),
448                    sequence_id,
449                },
450            )?;
451
452            if inbox_state
453                .get(&MemberIdentifier::installation(participant.installation_id))
454                .is_none()
455            {
456                return Err(CommitValidationError::Rule(
457                    CommitRuleError::InboxValidationFailed(participant.inbox_id),
458                ));
459            }
460        }
461
462        let membership = MembershipValidationInfo {
463            actor: &actor,
464            added_inboxes: &added_inboxes,
465            removed_inboxes: &removed_inboxes,
466            dm_members: immutable_metadata.dm_members.as_ref(),
467        };
468        if !group_permissions.policies.evaluate_membership(&membership) {
469            return Err(CommitValidationError::Rule(
470                CommitRuleError::InsufficientPermissions,
471            ));
472        }
473        if let Some(min_version) = &minimum_supported_protocol_version {
474            let current_version = context.version_info().pkg_semver();
475            let min_supported_version = LibXMTPVersion::parse(min_version)?;
476            tracing::info!(
477                "Validating commit with min_supported_version: {:?}, current_version: {:?}",
478                min_supported_version,
479                current_version
480            );
481
482            if min_supported_version > *current_version {
483                return Err(CommitValidationError::Rule(
484                    CommitRuleError::ProtocolVersionTooLow(min_version.clone()),
485                ));
486            }
487        }
488        // Component policies have already checked each authenticated proposer.
489        // Build the change summary after authorization, so legacy actor-based
490        // checks do not reject a valid proposal committed by another member.
491        let post_metadata =
492            read_post_commit_mutable_metadata(openmls_group, staged_commit, &registry)?;
493        let metadata_changes =
494            metadata_changes_between(&immutable_metadata, &mutable_metadata, &post_metadata);
495        let mut proposed_component_ids: Vec<u16> = staged_commit
496            .app_data_update_proposals()
497            .map(|queued| queued.app_data_update_proposal().component_id())
498            .collect();
499        proposed_component_ids.sort_unstable();
500        proposed_component_ids.dedup();
501        let mut metadata_component_ids = Vec::new();
502        for raw_id in proposed_component_ids {
503            let id = xmtp_mls_group_state::app_data::component_id::ComponentId::from(raw_id);
504            let before = read_from_app_data_dict(id, openmls_group);
505            // The post-commit value is wiped on drop: it can be the
506            // commit-log signer's private key.
507            let after =
508                xmtp_mls_group_state::app_data::component_source::read_post_commit_component_bytes(
509                    id,
510                    openmls_group,
511                    staged_commit,
512                    &registry,
513                )
514                .map_err(GroupMutableMetadataError::from)?;
515            if before != after.as_deref().map(Vec::as_slice) {
516                metadata_component_ids.push(raw_id);
517            }
518        }
519        let permissions_changed = metadata_component_ids.contains(
520            &xmtp_mls_group_state::app_data::component_id::ComponentId::COMPONENT_REGISTRY.as_u16(),
521        );
522        Ok(Self {
523            actor,
524            proposers,
525            added_inboxes,
526            removed_inboxes,
527            readded_installations,
528            metadata_changes,
529            metadata_component_ids,
530            installations_changed,
531            permissions_changed,
532            dm_members: immutable_metadata.dm_members.clone(),
533        })
534    }
535
536    // Reuse intent kind here to represent the commit type, even if it's an external commit
537    // This is for debugging purposes only, so an approximation is fine
538    pub fn debug_commit_type(&self) -> CommitType {
539        let metadata_info = &self.metadata_changes;
540        if !self.added_inboxes.is_empty()
541            || !self.removed_inboxes.is_empty()
542            || self.installations_changed
543        {
544            CommitType::UpdateGroupMembership
545        } else if self.permissions_changed {
546            CommitType::UpdatePermission
547        } else if !metadata_info.admins_added.is_empty()
548            || !metadata_info.admins_removed.is_empty()
549            || !metadata_info.super_admins_added.is_empty()
550            || !metadata_info.super_admins_removed.is_empty()
551        {
552            CommitType::UpdateAdminList
553        } else if !metadata_info.metadata_field_changes.is_empty() {
554            CommitType::MetadataUpdate
555        } else {
556            CommitType::KeyUpdate
557        }
558    }
559
560    pub fn is_empty(&self) -> bool {
561        self.added_inboxes.is_empty()
562            && self.removed_inboxes.is_empty()
563            && self.metadata_changes.is_empty()
564    }
565
566    pub fn actor_inbox_id(&self) -> InboxId {
567        self.actor.inbox_id.clone()
568    }
569
570    pub fn actor_installation_id(&self) -> Vec<u8> {
571        self.actor.installation_id.clone()
572    }
573}
574
575impl From<ValidatedCommit> for GroupMembershipChanges {
576    fn from(_commit: ValidatedCommit) -> Self {
577        // TODO: Use new GroupMembershipChanges
578
579        GroupMembershipChanges {
580            members_added: vec![],
581            members_removed: vec![],
582            installations_added: vec![],
583            installations_removed: vec![],
584        }
585    }
586}
587
588/// Membership changes derived from the exact old and proposed identity proofs.
589struct ExpectedDiff {
590    /// The membership before this commit. The commit cannot change it.
591    old_group_membership: GroupMembership,
592    /// Proposed inbox sequences. They cannot rewrite the old proof requirements.
593    new_group_membership: GroupMembership,
594    old_failed: FailedInstallations,
595    new_failed: FailedInstallations,
596    /// Installation changes authorized by those exact identity snapshots.
597    expected_installation_diff: InstallationDiff,
598    added_inboxes: Vec<Inbox>,
599    removed_inboxes: Vec<Inbox>,
600}
601
602impl ExpectedDiff {
603    /// Derive installation changes from cached proofs on the caller's connection.
604    /// Missing exact proofs return `Need`; this method does not fetch identities.
605    #[allow(clippy::too_many_arguments)]
606    pub(super) fn from_staged_commit_with_proposers(
607        conn: &impl DbQuery,
608        staged_commit: &StagedCommit,
609        openmls_group: &OpenMlsGroup,
610        envelope_sequence: u64,
611        added_inbox_proposers: &HashMap<String, CommitParticipant>,
612        removed_inbox_proposers: &HashMap<String, CommitParticipant>,
613    ) -> Result<Self, CommitValidationError> {
614        let extensions = openmls_group.extensions();
615        let (immutable_metadata, mutable_metadata) = read_committed_metadata(openmls_group)
616            .map_err(CommitValidationError::installed_state)?;
617
618        let expected_diff = Self::extract_expected_diff_with_proposers(
619            conn,
620            staged_commit,
621            envelope_sequence,
622            extensions,
623            &immutable_metadata,
624            &mutable_metadata,
625            added_inbox_proposers,
626            removed_inbox_proposers,
627        )?;
628
629        Ok(expected_diff)
630    }
631
632    /// Generates an expected diff with proposer attribution for each inbox change.
633    /// This is used when validating commits with proposals from multiple members.
634    #[allow(clippy::too_many_arguments)]
635    fn extract_expected_diff_with_proposers(
636        conn: &impl DbQuery,
637        staged_commit: &StagedCommit,
638        envelope_sequence: u64,
639        existing_group_extensions: &Extensions<GroupContext>,
640        immutable_metadata: &GroupMetadata,
641        mutable_metadata: &GroupMutableMetadata,
642        added_inbox_proposers: &HashMap<String, CommitParticipant>,
643        removed_inbox_proposers: &HashMap<String, CommitParticipant>,
644    ) -> Result<ExpectedDiff, CommitValidationError> {
645        let (old_group_membership, old_failed) =
646            extract_group_membership_with_failures(existing_group_extensions)
647                .map_err(CommitValidationError::installed_state)?;
648        let (new_group_membership, new_failed) =
649            extract_group_membership_with_failures(staged_commit.group_context().extensions())?;
650        validate_identity_sequence_order(&new_group_membership, envelope_sequence)?;
651        let membership_diff = old_group_membership.diff(&new_group_membership);
652
653        validate_membership_diff(
654            &old_group_membership,
655            &new_group_membership,
656            &membership_diff,
657        )?;
658        validate_sync_membership(immutable_metadata, &new_group_membership)?;
659
660        let added_inboxes = membership_diff
661            .added_inboxes
662            .iter()
663            .map(|inbox_id| {
664                let proposer = added_inbox_proposers
665                    .get(inbox_id.as_str())
666                    .cloned()
667                    .ok_or(CommitValidationError::Rule(
668                        CommitRuleError::ProposerNotFound,
669                    ))?;
670                Ok(build_inbox_with_proposer(
671                    inbox_id,
672                    immutable_metadata,
673                    mutable_metadata,
674                    proposer,
675                ))
676            })
677            .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
678
679        let removed_inboxes = membership_diff
680            .removed_inboxes
681            .iter()
682            .map(|inbox_id| {
683                let proposer = removed_inbox_proposers
684                    .get(inbox_id.as_str())
685                    .cloned()
686                    .ok_or(CommitValidationError::Rule(
687                        CommitRuleError::ProposerNotFound,
688                    ))?;
689                Ok(build_inbox_with_proposer(
690                    inbox_id,
691                    immutable_metadata,
692                    mutable_metadata,
693                    proposer,
694                ))
695            })
696            .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
697
698        let expected_installation_diff = get_installation_diff_local(
699            conn,
700            &old_group_membership,
701            &new_group_membership,
702            &membership_diff,
703        )?;
704
705        Ok(ExpectedDiff {
706            old_group_membership,
707            new_group_membership,
708            old_failed,
709            new_failed,
710            expected_installation_diff,
711            added_inboxes,
712            removed_inboxes,
713        })
714    }
715}
716
717/// Newly listed failures must belong to the entry's inbox or one of its old leaves.
718// implements: GMOD-049
719fn validate_new_failed_installations(
720    conn: &impl DbQuery,
721    membership: &GroupMembership,
722    before: &FailedInstallations,
723    after: &FailedInstallations,
724    leaf_owners: &HashMap<Vec<u8>, String>,
725) -> Result<(), CommitValidationError> {
726    let mut by_inbox: HashMap<&str, Vec<&[u8]>> = HashMap::new();
727    for (inbox, key) in after.unproven_new_failures(before, leaf_owners) {
728        by_inbox.entry(inbox).or_default().push(key);
729    }
730    let mut missing = MissingProofs::default();
731    for (inbox, keys) in by_inbox {
732        let sequence_id = membership.get(inbox).copied().unwrap_or(0);
733        if sequence_id == 0 {
734            return Err(CommitRuleError::InboxValidationFailed(inbox.to_string()).into());
735        }
736        let Some(state) = missing.read(
737            conn,
738            &IdentityRequirement {
739                inbox_id: inbox.to_string(),
740                sequence_id,
741            },
742        )?
743        else {
744            continue;
745        };
746        let installations: HashSet<_> = state.installation_ids().into_iter().collect();
747        if keys.iter().any(|key| !installations.contains(*key)) {
748            return Err(CommitRuleError::InboxValidationFailed(inbox.to_string()).into());
749        }
750    }
751    missing.check()?;
752    Ok(())
753}
754
755/// Compare the list of installations added and removed in the commit to the expected diff based on the changes
756/// to the inbox state.
757/// Satisfies Rule 3 and Rule 7
758// implements: GMOD-010, GMOD-046
759fn expected_diff_matches_commit(
760    expected_diff: &InstallationDiff,
761    added_installations: HashSet<Vec<u8>>,
762    self_added_installations: &HashSet<Vec<u8>>,
763    removed_installations: HashSet<Vec<u8>>,
764    required_removals: &HashSet<Vec<u8>>,
765    existing_installation_ids: &HashSet<Vec<u8>>,
766) -> Result<(), CommitValidationError> {
767    // Check and make sure that any added installations are either:
768    // 1. In the expected diff
769    // 2. Already a member of the group (for example, the group creator is already a member on the first commit)
770    // 3. Proposed by a member of the inbox the leaf's credential names
771
772    let unknown_adds = added_installations
773        .into_iter()
774        .filter(|installation_id| {
775            !expected_diff
776                .added_installations
777                .contains_key(installation_id)
778                && !existing_installation_ids.contains(installation_id)
779                // The identity check below still needs the key in its inbox's state.
780                && !self_added_installations.contains(installation_id)
781        })
782        .collect::<Vec<Vec<u8>>>();
783    if !unknown_adds.is_empty() {
784        return Err(CommitValidationError::Rule(
785            CommitRuleError::UnexpectedInstallationAdded(unknown_adds),
786        ));
787    }
788
789    if &removed_installations != required_removals {
790        let unexpected: Vec<_> = removed_installations
791            .symmetric_difference(required_removals)
792            .cloned()
793            .collect();
794
795        return Err(CommitValidationError::Rule(
796            CommitRuleError::UnexpectedInstallationsRemoved(unexpected),
797        ));
798    }
799
800    Ok(())
801}
802
803/// Validates a single proposal by checking if the proposer has the required permissions.
804/// Returns Ok(()) if the proposal is valid, or an error if validation fails.
805///
806/// This function should be called when receiving proposals to ensure they are valid
807/// before they are stored and later committed.
808pub fn validate_proposal(
809    proposal: &QueuedProposal,
810    openmls_group: &OpenMlsGroup,
811    policy_set: &PolicySet,
812    immutable_metadata: &GroupMetadata,
813    mutable_metadata: &GroupMutableMetadata,
814) -> Result<(), CommitValidationError> {
815    // Extract the proposer from the proposal
816    let proposer = match proposal.sender() {
817        Sender::Member(leaf_index) => extract_commit_participant(
818            leaf_index,
819            openmls_group,
820            immutable_metadata,
821            mutable_metadata,
822        )?,
823        Sender::External(_) | Sender::NewMemberCommit | Sender::NewMemberProposal => {
824            // External and new member proposals are not supported
825            return Err(CommitValidationError::Rule(CommitRuleError::ActorNotMember));
826        }
827    };
828
829    let unsupported_error = || {
830        CommitValidationError::Rule(CommitRuleError::UnsupportedProposalType(
831            proposal.proposal().proposal_type(),
832        ))
833    };
834
835    // Validate based on proposal type
836    match proposal.proposal() {
837        Proposal::Add(add_proposal) => {
838            // Check if the proposer has permission to add members
839            let added_inbox_id =
840                inbox_id_from_credential(add_proposal.key_package().leaf_node().credential())?;
841            let inbox = Inbox {
842                inbox_id: added_inbox_id.clone(),
843                is_creator: false,
844                is_admin: false,
845                is_super_admin: false,
846                proposer: Some(proposer.clone()),
847            };
848            if !policy_set.add_member_policy.evaluate(&proposer, &inbox) {
849                // DM bypass: allow adding the other DM participant even if policy denies
850                let is_dm_add = immutable_metadata.dm_members.as_ref().is_some_and(|dm| {
851                    (added_inbox_id == dm.member_one_inbox_id.as_ref()
852                        || added_inbox_id == dm.member_two_inbox_id.as_ref())
853                        && added_inbox_id != proposer.inbox_id
854                });
855                if !is_dm_add {
856                    tracing::warn!(
857                        proposer_inbox_id = %proposer.inbox_id,
858                        "Proposal rejected: proposer does not have permission to add members"
859                    );
860                    return Err(CommitValidationError::Rule(
861                        CommitRuleError::InsufficientPermissions,
862                    ));
863                }
864            }
865        }
866        Proposal::Remove(remove_proposal) => {
867            // Check if the proposer has permission to remove members
868            // Get the inbox_id of the member being removed
869            let removed_member = openmls_group.member_at(remove_proposal.removed()).ok_or(
870                CommitValidationError::Rule(CommitRuleError::SubjectDoesNotExist),
871            )?;
872            let removed_inbox_id = inbox_id_from_credential(&removed_member.credential)?;
873            let removed_is_admin = mutable_metadata.admin_list.contains(&removed_inbox_id);
874            let removed_is_super_admin = mutable_metadata.is_super_admin(&removed_inbox_id);
875
876            // Super admins cannot be removed
877            if removed_is_super_admin {
878                tracing::warn!(
879                    proposer_inbox_id = %proposer.inbox_id,
880                    removed_inbox_id = %removed_inbox_id,
881                    "Proposal rejected: cannot remove super admin"
882                );
883                return Err(CommitValidationError::Rule(
884                    CommitRuleError::InsufficientPermissions,
885                ));
886            }
887
888            let removed_inbox = Inbox {
889                inbox_id: removed_inbox_id.clone(),
890                is_creator: immutable_metadata.creator_inbox_id == removed_inbox_id,
891                is_admin: removed_is_admin,
892                is_super_admin: removed_is_super_admin,
893                proposer: Some(proposer.clone()),
894            };
895
896            if !policy_set
897                .remove_member_policy
898                .evaluate(&proposer, &removed_inbox)
899            {
900                tracing::warn!(
901                    proposer_inbox_id = %proposer.inbox_id,
902                    removed_inbox_id = %removed_inbox_id,
903                    "Proposal rejected: proposer does not have permission to remove members"
904                );
905                return Err(CommitValidationError::Rule(
906                    CommitRuleError::InsufficientPermissions,
907                ));
908            }
909        }
910        Proposal::GroupContextExtensions(_) => return Err(unsupported_error()),
911        Proposal::Update(update_proposal) => {
912            // Update proposals are allowed for the member themselves, but the new leaf node's
913            // credential must match the proposer's identity to prevent identity swaps.
914            let new_inbox_id = inbox_id_from_credential(update_proposal.leaf_node().credential())?;
915            if new_inbox_id != proposer.inbox_id {
916                tracing::warn!(
917                    proposer_inbox_id = %proposer.inbox_id,
918                    proposer_installation_id = %proposer.installation_id.as_slice().snippet(),
919                    leaf_index = ?proposal.sender(),
920                    new_inbox_id = %new_inbox_id,
921                    new_installation_id = %update_proposal.leaf_node().signature_key().as_slice().snippet(),
922                    "Update proposal rejected: new leaf node credential does not match proposer"
923                );
924                return Err(CommitValidationError::Rule(CommitRuleError::ActorNotMember));
925            }
926        }
927        Proposal::PreSharedKey(_) => {
928            return Err(unsupported_error());
929        }
930        Proposal::ReInit(_) => {
931            return Err(unsupported_error());
932        }
933        Proposal::ExternalInit(_) => {
934            return Err(unsupported_error());
935        }
936        Proposal::Custom(_) => {
937            return Err(unsupported_error());
938        }
939        Proposal::AppDataUpdate(app_data) => {
940            use super::app_data::load_component_registry;
941            use xmtp_mls_group_state::app_data::{
942                component_id::ComponentId, validation::ActorAuthority,
943            };
944
945            let registry = load_component_registry(openmls_group)?;
946
947            // Delegate to the shared helper so the commit-time path
948            // (`validate_app_data_update_proposals_in_commit`) and this
949            // standalone-proposal-by-reference path can't drift apart.
950            validate_one_app_data_update(
951                ComponentId::from(app_data.component_id()),
952                app_data.operation(),
953                ActorAuthority::from(&proposer),
954                &proposer.inbox_id,
955                &registry,
956                openmls_group,
957                immutable_metadata.dm_members.as_ref(),
958            )?;
959        }
960        Proposal::AppEphemeral(_) => {
961            return Err(unsupported_error());
962        }
963        Proposal::SelfRemove => {
964            return Err(unsupported_error());
965        }
966    }
967
968    Ok(())
969}
970
971// Implement the generic conversion: the TARGET (GroupUpdatedProto) declares what params it needs.
972// Here it's `BuildOpts`, but it could be `&dyn Policy`, `&[u8]`, etc.
973impl FromWith<ValidatedCommit> for GroupUpdatedProto {
974    /// Extra parameter is a list of inbox IDs who requested self-removal (pending removals).
975    type Params = Vec<String>;
976
977    fn from_with(commit: ValidatedCommit, pending_removals: &Self::Params) -> Self {
978        use std::collections::HashSet;
979
980        // Convert the pending removals list into a set for fast lookup
981        let pending_set: HashSet<&str> = pending_removals.iter().map(String::as_str).collect();
982
983        // Partition removed inboxes:
984        //  - left_inboxes: those present in pending_removals
985        //  - removed_inboxes: all others
986        let (left_inboxes, removed_inboxes): (Vec<Inbox>, Vec<Inbox>) = commit
987            .removed_inboxes
988            .into_iter()
989            .partition(|inb| pending_set.contains(inb.inbox_id.as_str()));
990
991        GroupUpdatedProto {
992            initiated_by_inbox_id: commit.actor.inbox_id.clone(),
993            added_inboxes: commit.added_inboxes.iter().map(InboxProto::from).collect(),
994            removed_inboxes: removed_inboxes.iter().map(InboxProto::from).collect(),
995            metadata_field_changes: commit
996                .metadata_changes
997                .metadata_field_changes
998                .iter()
999                .map(MetadataFieldChangeProto::from)
1000                .collect(),
1001            left_inboxes: left_inboxes.iter().map(InboxProto::from).collect(),
1002            added_admin_inboxes: commit
1003                .metadata_changes
1004                .admins_added
1005                .iter()
1006                .map(InboxProto::from)
1007                .collect(),
1008            removed_admin_inboxes: commit
1009                .metadata_changes
1010                .admins_removed
1011                .iter()
1012                .map(InboxProto::from)
1013                .collect(),
1014            added_super_admin_inboxes: commit
1015                .metadata_changes
1016                .super_admins_added
1017                .iter()
1018                .map(InboxProto::from)
1019                .collect(),
1020            removed_super_admin_inboxes: commit
1021                .metadata_changes
1022                .super_admins_removed
1023                .iter()
1024                .map(InboxProto::from)
1025                .collect(),
1026        }
1027    }
1028}
1029
1030#[cfg(test)]
1031mod committed_proposal_outcomes {
1032    use super::*;
1033    use crate::groups::app_data::accumulate_app_data_updates;
1034    use crate::prelude::*;
1035    use openmls::messages::proposals::{
1036        AppDataUpdateOperation, AppDataUpdateProposal, ProposalType,
1037    };
1038    use openmls_traits::OpenMlsProvider;
1039    use xmtp_mls_group_state::app_data::component_id::ComponentId;
1040
1041    /// verifies: GMOD-030
1042    ///
1043    /// A staged commit whose `AppDataUpdate` proposals mix an Update and a
1044    /// Remove, or repeat a Remove, for one component is a terminal rejection.
1045    /// The commits carry no `GroupContextExtensions` proposal, so OpenMLS's
1046    /// own list check returns early at the first Remove and stages them.
1047    #[xmtp_common::test(unwrap_try = true)]
1048    async fn committed_proposal_outcomes_reject_conflicting_component_operations() {
1049        use crate::tester;
1050        tester!(alix, disable_workers);
1051        let conversation = alix.create_group(None, None)?;
1052        conversation
1053            .update_group_description("described".to_string())
1054            .await?;
1055        let provider = alix.context.mls_provider();
1056        let signer = &alix.context.identity().installation_keys;
1057        let mut group = OpenMlsGroup::load(
1058            alix.context.mls_storage(),
1059            &conversation.group_id.to_openmls(),
1060        )??;
1061        let description = ComponentId::GROUP_DESCRIPTION;
1062        assert!(read_from_app_data_dict(description, &group).is_some());
1063
1064        // One operation is proposed by reference and one is inline: two
1065        // identical Removes proposed by reference would share a reference.
1066        let update = || AppDataUpdateOperation::Update(b"changed".to_vec().into());
1067        for (by_reference, inline) in [
1068            (
1069                update(),
1070                AppDataUpdateProposal::remove(description.as_u16()),
1071            ),
1072            (
1073                AppDataUpdateOperation::Remove,
1074                AppDataUpdateProposal::update(description.as_u16(), b"changed".to_vec()),
1075            ),
1076            (
1077                AppDataUpdateOperation::Remove,
1078                AppDataUpdateProposal::remove(description.as_u16()),
1079            ),
1080        ] {
1081            group.propose_app_data_update(&provider, signer, description.as_u16(), by_reference)?;
1082            let inline = Proposal::AppDataUpdate(Box::new(inline));
1083            let pending: Vec<_> = group
1084                .pending_proposals()
1085                .map(QueuedProposal::proposal)
1086                .chain([&inline])
1087                .filter_map(|proposal| match proposal {
1088                    Proposal::AppDataUpdate(p) => Some((p.component_id(), p.operation().clone())),
1089                    _ => None,
1090                })
1091                .collect();
1092            let updates =
1093                accumulate_app_data_updates(&group, pending.iter().map(|(id, op)| (*id, op)))?;
1094            let mut stage = crate::groups::mls_ext::commit_without_group_info(
1095                &mut group,
1096                provider.storage(),
1097                |builder| builder.consume_proposal_store(true).add_proposal(inline),
1098            )?;
1099            stage.with_app_data_dictionary_updates(updates);
1100            stage
1101                .build(provider.rand(), provider.crypto(), signer, |_| true)?
1102                .stage_commit(&provider)?;
1103            let staged = group.pending_commit()?;
1104            let kinds: Vec<_> = staged
1105                .queued_proposals()
1106                .map(|queued| queued.proposal().proposal_type())
1107                .collect();
1108            assert_eq!(kinds, [ProposalType::AppDataUpdate; 2]);
1109
1110            let result = ValidatedCommit::from_staged_commit_local(
1111                &alix.context,
1112                &alix.context.db(),
1113                staged,
1114                group.own_leaf_index(),
1115                &group,
1116                u64::MAX,
1117            );
1118            let Err(error) = result else {
1119                panic!("conflicting component operations were accepted");
1120            };
1121            assert!(
1122                matches!(
1123                    &error,
1124                    CommitValidationError::Rule(CommitRuleError::InvalidAppDataUpdateList(id))
1125                        if *id == description
1126                ),
1127                "{error:?}"
1128            );
1129            assert!(error.is_safe_rejection());
1130
1131            group.clear_pending_commit(provider.storage())?;
1132            group.clear_pending_proposals(provider.storage())?;
1133        }
1134    }
1135
1136    /// verifies: GMOD-001
1137    ///
1138    /// A `GroupContextExtensions` proposal is held both standalone and inside
1139    /// a commit.
1140    #[xmtp_common::test(unwrap_try = true)]
1141    // A test drives an OpenMLS convenience commit; its GroupInfo never leaves the test.
1142    #[allow(clippy::disallowed_methods)]
1143    async fn committed_proposal_outcomes_hold_gce_proposal_and_commit() {
1144        use crate::tester;
1145        tester!(alix, disable_workers);
1146        let conversation = alix.create_group(None, None)?;
1147        let provider = alix.context.mls_provider();
1148        let mut group = OpenMlsGroup::load(
1149            alix.context.mls_storage(),
1150            &conversation.group_id.to_openmls(),
1151        )??;
1152        let signer = &alix.context.identity().installation_keys;
1153        group.propose_group_context_extensions(&provider, group.extensions().clone(), signer)?;
1154        let (immutable, mutable) = read_committed_metadata(&group)?;
1155        let proposal = group.pending_proposals().next()?;
1156        assert!(matches!(
1157            validate_proposal(
1158                proposal,
1159                &group,
1160                &PolicySet::default(),
1161                &immutable,
1162                &mutable
1163            ),
1164            Err(CommitValidationError::Rule(
1165                CommitRuleError::UnsupportedProposalType(ProposalType::GroupContextExtensions)
1166            ))
1167        ));
1168        group.commit_to_pending_proposals(&provider, signer)?;
1169        let staged = group.pending_commit()?;
1170        assert!(matches!(
1171            ValidatedCommit::from_staged_commit_local(
1172                &alix.context,
1173                &alix.context.db(),
1174                staged,
1175                group.own_leaf_index(),
1176                &group,
1177                u64::MAX,
1178            ),
1179            Err(CommitValidationError::Rule(
1180                CommitRuleError::UnsupportedProposalType(ProposalType::GroupContextExtensions)
1181            ))
1182        ));
1183    }
1184}
1185
1186#[cfg(all(test, not(target_arch = "wasm32")))]
1187mod membership_log_tests {
1188    use std::sync::Arc;
1189
1190    use xmtp_logging::{Level, LogRecord, LogSinkTarget, SinkError, test_logging::LogCapture};
1191    use xmtp_mls_validation::{
1192        commit::{CommitParticipant, Inbox, MembershipValidationInfo},
1193        group_permissions::{MembershipPolicies, PolicySet},
1194    };
1195
1196    #[derive(Default)]
1197    struct Capture(parking_lot::Mutex<Vec<LogRecord>>);
1198
1199    impl LogSinkTarget for Capture {
1200        fn on_record(&self, record: LogRecord) -> Result<(), SinkError> {
1201            self.0.lock().push(record);
1202            Ok(())
1203        }
1204    }
1205
1206    #[rstest::rstest]
1207    #[case::add_committer(false, false)]
1208    #[case::add_proposer(false, true)]
1209    #[case::remove_committer(true, false)]
1210    #[case::remove_proposer(true, true)]
1211    #[xmtp_common::test(unwrap_try = true)]
1212    fn membership_rejection_log_omits_full_ids(#[case] remove: bool, #[case] has_proposer: bool) {
1213        let actor_id = (32_u8..64).collect::<Vec<_>>();
1214        let proposer_id = (64_u8..96).collect::<Vec<_>>();
1215        let actor = CommitParticipant {
1216            inbox_id: "sentinel-actor-inbox".to_string(),
1217            installation_id: actor_id.clone(),
1218            is_creator: false,
1219            is_admin: true,
1220            is_super_admin: false,
1221        };
1222        let proposer = CommitParticipant {
1223            inbox_id: "sentinel-proposer-inbox".to_string(),
1224            installation_id: proposer_id.clone(),
1225            is_creator: true,
1226            is_admin: false,
1227            is_super_admin: true,
1228        };
1229        let changes = [Inbox {
1230            inbox_id: "sentinel-subject-inbox".to_string(),
1231            is_creator: false,
1232            is_admin: true,
1233            is_super_admin: false,
1234            proposer: has_proposer.then_some(proposer),
1235        }];
1236        let policies = PolicySet {
1237            add_member_policy: MembershipPolicies::deny(),
1238            remove_member_policy: MembershipPolicies::deny(),
1239            ..PolicySet::default()
1240        };
1241        let membership = MembershipValidationInfo {
1242            actor: &actor,
1243            added_inboxes: if remove { &[] } else { &changes },
1244            removed_inboxes: if remove { &changes } else { &[] },
1245            dm_members: None,
1246        };
1247        let sink = Arc::new(Capture::default());
1248        let capture = LogCapture::with_sink(Level::Info, Some(sink.clone()));
1249        let accepted = tracing::dispatcher::with_default(&capture.dispatch(), || {
1250            policies.evaluate_membership(&membership)
1251        });
1252        assert!(!accepted);
1253        let json = capture.output();
1254        let records = sink.0.lock();
1255        assert_eq!(records.len(), 1);
1256        for sensitive in [
1257            hex::encode(&actor_id),
1258            hex::encode(&proposer_id),
1259            format!("{actor_id:?}"),
1260            format!("{proposer_id:?}"),
1261            actor.inbox_id.clone(),
1262            "sentinel-proposer-inbox".to_string(),
1263            changes[0].inbox_id.clone(),
1264        ] {
1265            assert!(
1266                !json.contains(&sensitive),
1267                "sensitive value reached JSON log"
1268            );
1269            assert!(
1270                !records[0].message.contains(&sensitive),
1271                "sensitive value reached sink message"
1272            );
1273            assert!(
1274                records[0]
1275                    .fields
1276                    .values()
1277                    .all(|value| !value.contains(&sensitive)),
1278                "sensitive value reached sink fields"
1279            );
1280        }
1281        let json_record: serde_json::Value = serde_json::from_str(json.trim()).unwrap();
1282        assert_eq!(json_record["message"], "Membership policy rejected change");
1283        assert_eq!(records[0].message, "Membership policy rejected change");
1284        for (field, value) in [
1285            ("has_proposer", has_proposer),
1286            ("actor_is_creator", has_proposer),
1287            ("actor_is_admin", !has_proposer),
1288            ("actor_is_super_admin", has_proposer),
1289            ("subject_is_creator", false),
1290            ("subject_is_admin", true),
1291            ("subject_is_super_admin", false),
1292        ] {
1293            assert_eq!(json_record[field], value);
1294            assert_eq!(records[0].fields.get(field), Some(&value.to_string()));
1295        }
1296        assert_eq!(actor.installation_id, actor_id);
1297        if let Some(proposer) = &changes[0].proposer {
1298            assert_eq!(proposer.installation_id, proposer_id);
1299        }
1300    }
1301}