Skip to main content

coven_protocol/membership/
authoring.rs

1use super::conflict::conflict_retirement_barriers;
2use super::*;
3
4impl MembershipChain {
5    pub(crate) fn resolved_with(
6        &self,
7        store_root_hash: ObjectHash,
8        resolutions: &[(
9            StoreMembershipConflictResolutionRef,
10            StoreMembershipConflictResolution,
11        )],
12    ) -> Result<ResolvedStoreMembership, MembershipError> {
13        match self.status() {
14            MembershipStatus::Resolved(resolved) if resolutions.is_empty() => Ok(resolved.clone()),
15            MembershipStatus::Conflict(conflict) => {
16                resolve_store_membership_conflict(store_root_hash, conflict, resolutions)
17            }
18            MembershipStatus::Resolved(_) => Err(MembershipError::InvalidConflictResolution),
19        }
20    }
21
22    pub fn signed_conflict_resolution(
23        &self,
24        store_root_hash: ObjectHash,
25        selection: MembershipConflictSelection,
26        replacement_membership: GrantStreamAnchor,
27        replacement_acceptance: OwnerConflictResolutionAcceptance,
28        signer: &UserKeypair,
29    ) -> Result<StoreMembershipConflictResolution, MembershipError> {
30        let MembershipStatus::Conflict(conflict) = self.status() else {
31            return Err(MembershipError::Conflict);
32        };
33        let resolver_pubkey = keys::public_key_hex(signer);
34        let (conflict_hash, heads, retired_owner_grants, records, effective_frontier) =
35            match (conflict, &selection) {
36                (
37                    MembershipConflict::ConcurrentMemberAssignments {
38                        conflict_hash,
39                        heads,
40                        effective_frontier,
41                        conflicting_grants,
42                        uncontested_grants,
43                        grants,
44                        ..
45                    },
46                    MembershipConflictSelection::MemberAssignment { grant },
47                ) => {
48                    if !conflicting_grants.contains_key(grant) {
49                        return Err(MembershipError::InvalidConflictResolution);
50                    }
51                    let retired = uncontested_grants
52                        .iter()
53                        .filter_map(|(grant, record)| {
54                            (record.member_pubkey == resolver_pubkey && record.role.is_owner())
55                                .then_some(grant.clone())
56                        })
57                        .collect::<BTreeSet<_>>();
58                    if retired.is_empty() {
59                        return Err(MembershipError::SignerIsNotOwner(resolver_pubkey));
60                    }
61                    (
62                        conflict_hash,
63                        heads,
64                        retired,
65                        grants
66                            .iter()
67                            .map(|(grant, state)| (grant.clone(), state.record().clone()))
68                            .collect(),
69                        effective_frontier.clone(),
70                    )
71                }
72                (
73                    MembershipConflict::RevocationCycle {
74                        conflict_hash,
75                        heads,
76                        involved_owner_grants,
77                        maximal_valid_branches,
78                        ..
79                    },
80                    MembershipConflictSelection::RevocationBranch {
81                        heads: selected_heads,
82                    },
83                ) => {
84                    let branch = maximal_valid_branches
85                        .iter()
86                        .find(|branch| branch.heads == *selected_heads)
87                        .ok_or(MembershipError::InvalidConflictResolution)?;
88                    let resolver_grants = branch
89                        .active_grants()
90                        .filter_map(|(grant, record)| {
91                            (record.member_pubkey == resolver_pubkey && record.role.is_owner())
92                                .then_some(grant.clone())
93                        })
94                        .collect::<BTreeSet<_>>();
95                    if resolver_grants.is_empty() {
96                        return Err(MembershipError::SignerIsNotOwner(resolver_pubkey));
97                    }
98                    let mut retired = involved_owner_grants.clone();
99                    retired.extend(resolver_grants);
100                    let records = maximal_valid_branches
101                        .iter()
102                        .flat_map(|branch| branch.grants.iter())
103                        .map(|(grant, state)| (grant.clone(), state.record().clone()))
104                        .collect();
105                    let mut frontier = maximal_valid_branches
106                        .iter()
107                        .flat_map(|branch| branch.effective_frontier.iter().cloned())
108                        .collect::<Vec<_>>();
109                    frontier.sort();
110                    frontier.dedup();
111                    (conflict_hash, heads, retired, records, frontier)
112                }
113                _ => return Err(MembershipError::InvalidConflictResolution),
114            };
115        let replacement_grant = derive_store_resolution_grant(conflict_hash, &resolver_pubkey);
116        let retirement_barriers = conflict_retirement_barriers(
117            records,
118            effective_frontier,
119            &replacement_acceptance.device_state,
120        )?;
121        Ok(Signed::sign(
122            StoreMembershipConflictResolutionBody {
123                store_root_hash,
124                conflict_hash: *conflict_hash,
125                conflicting_heads: heads.clone(),
126                retired_owner_grants,
127                retirement_barriers,
128                resolver_pubkey,
129                selection,
130                replacement_grant,
131                replacement_membership,
132                replacement_acceptance,
133            },
134            signer,
135        ))
136    }
137
138    pub fn signed_set_member_with_anchor_and_wrapped_key_in_stream(
139        &self,
140        signer: &UserKeypair,
141        stream_id: AuthorStreamId,
142        user_pubkey: String,
143        provider_account_email: Option<String>,
144        role: MemberRole,
145        membership: Option<GrantStreamAnchor>,
146        wrapped_key: WrappedStoreKeyRef,
147        created_at: String,
148    ) -> Result<MembershipEntry, MembershipError> {
149        let role = StoreMembershipRoleGrant::from_direct_assignment(role)?;
150        let grant_id = self.next_member_grant_id_in_stream(signer, stream_id, &user_pubkey)?;
151        self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
152            signer,
153            stream_id,
154            user_pubkey,
155            provider_account_email,
156            role,
157            grant_id,
158            membership,
159            wrapped_key,
160            created_at,
161        )
162    }
163
164    #[allow(clippy::too_many_arguments)]
165    fn signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
166        &self,
167        signer: &UserKeypair,
168        stream_id: AuthorStreamId,
169        user_pubkey: String,
170        provider_account_email: Option<String>,
171        role: StoreMembershipRoleGrant,
172        grant_id: MembershipGrantId,
173        membership: Option<GrantStreamAnchor>,
174        wrapped_key: WrappedStoreKeyRef,
175        created_at: String,
176    ) -> Result<MembershipEntry, MembershipError> {
177        let author = keys::public_key_hex(signer);
178        let author_grant = self
179            .active_owner_grant(&author)
180            .ok_or_else(|| MembershipError::SignerIsNotOwner(author.clone()))?;
181        let (seq, previous_hash) = self.next_stream_position(&author, &author_grant, stream_id)?;
182        let replaces = self.active_grant_ids(&user_pubkey);
183        let retirement_barriers = self.membership_retirement_barriers(&replaces, None)?;
184        if role.is_owner() != membership.is_some() {
185            return Err(MembershipError::InvalidOwnerMembershipAnchor(
186                self.entries.len(),
187            ));
188        }
189        let entry = Signed::sign(
190            MembershipEntryBody {
191                store_id: self
192                    .store_id()
193                    .expect("validated chain has a store id")
194                    .to_string(),
195                author_pubkey: author,
196                author_owner_grant: author_grant,
197                stream_id,
198                seq,
199                previous_hash,
200                dependencies: self.effective_frontier(),
201                resolution_dependencies: self.resolution_refs().to_vec(),
202                created_at,
203                change: MembershipChange::SetMember {
204                    user_pubkey: user_pubkey.clone(),
205                    provider_account_email,
206                    role,
207                    grant_id,
208                    membership,
209                    replaces,
210                    retirement_barriers,
211                    retirement_device_state: None,
212                    wrapped_key,
213                },
214                provider_admin: None,
215            },
216            signer,
217        );
218        let mut candidate = self.clone();
219        candidate.add_entry(entry.clone())?;
220        Ok(entry)
221    }
222
223    #[allow(clippy::too_many_arguments)]
224    pub fn signed_finalize_owner_promotion_in_stream(
225        &self,
226        root: &StoreRootRef,
227        promoter: &StoreDeviceRegistration,
228        candidate: &StoreDeviceRegistration,
229        acceptance: OwnerPromotionAcceptance,
230        signer: &UserKeypair,
231        wrapped_key: WrappedStoreKeyRef,
232        created_at: String,
233    ) -> Result<MembershipEntry, MembershipError> {
234        acceptance
235            .request
236            .verify(root, promoter)
237            .map_err(|_| MembershipError::InvalidOwnerPromotion)?;
238        acceptance
239            .verify(candidate)
240            .map_err(|_| MembershipError::InvalidOwnerPromotion)?;
241        let request = &acceptance.request;
242        let author = keys::public_key_hex(signer);
243        let OwnerPromotionFinalization {
244            author_stream,
245            seq: requested_seq,
246            previous_hash: requested_previous_hash,
247        } = request.finalization;
248        let (expected_seq, expected_previous_hash) =
249            self.next_stream_position(&author, &request.promoter_owner_grant, author_stream)?;
250        let Some(member) = self.active_grant(&request.member_grant) else {
251            return Err(MembershipError::InvalidOwnerPromotion);
252        };
253        let membership = &acceptance.anchors.membership;
254        let root_id = root.store_root_id.to_string();
255        if author != promoter.author_pubkey
256            || self.store_id() != Some(root_id.as_str())
257            || self.active_owner_grant(&author) != Some(request.promoter_owner_grant.clone())
258            || member.member_pubkey != request.member_pubkey
259            || member.role != StoreMembershipRoleGrant::Member
260            || self.active_grant_ids(&request.member_pubkey)
261                != BTreeSet::from([request.member_grant.clone()])
262            || expected_seq != requested_seq
263            || expected_previous_hash != requested_previous_hash
264            || self
265                .state
266                .grants
267                .contains_key(&request.intended_owner_grant)
268        {
269            return Err(MembershipError::InvalidOwnerPromotion);
270        }
271        self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
272            signer,
273            author_stream,
274            request.member_pubkey.clone(),
275            member.provider_account_email.clone(),
276            StoreMembershipRoleGrant::Owner {
277                recovery: OwnerRecoveryAnchorRef::Promotion {
278                    acceptance: Box::new(acceptance.clone()),
279                },
280            },
281            request.intended_owner_grant.clone(),
282            Some(membership.clone()),
283            wrapped_key,
284            created_at,
285        )
286    }
287
288    pub fn signed_remove_member_with_wrapped_keys_in_stream(
289        &self,
290        signer: &UserKeypair,
291        stream_id: AuthorStreamId,
292        user_pubkey: String,
293        wrapped_keys: Vec<WrappedStoreKeyRef>,
294        created_at: String,
295    ) -> Result<MembershipEntry, MembershipError> {
296        self.signed_remove_member_with_barrier_state(
297            signer,
298            stream_id,
299            user_pubkey,
300            wrapped_keys,
301            None,
302            created_at,
303        )
304    }
305
306    pub fn signed_remove_member_with_owner_barrier_state(
307        &self,
308        signer: &UserKeypair,
309        stream_id: AuthorStreamId,
310        user_pubkey: String,
311        wrapped_keys: Vec<WrappedStoreKeyRef>,
312        device_state: StoreDeviceStateRef,
313        created_at: String,
314    ) -> Result<MembershipEntry, MembershipError> {
315        self.signed_remove_member_with_barrier_state(
316            signer,
317            stream_id,
318            user_pubkey,
319            wrapped_keys,
320            Some(device_state),
321            created_at,
322        )
323    }
324
325    fn signed_remove_member_with_barrier_state(
326        &self,
327        signer: &UserKeypair,
328        stream_id: AuthorStreamId,
329        user_pubkey: String,
330        wrapped_keys: Vec<WrappedStoreKeyRef>,
331        retirement_device_state: Option<StoreDeviceStateRef>,
332        created_at: String,
333    ) -> Result<MembershipEntry, MembershipError> {
334        let removes = self.active_grant_ids(&user_pubkey);
335        if removes.is_empty() {
336            return Err(MembershipError::NotAMember(user_pubkey));
337        }
338        let retains_owner = self.state.grants.iter().any(|(grant, state)| {
339            !removes.contains(grant) && state.active().is_some_and(|record| record.role.is_owner())
340        });
341        if !retains_owner {
342            return Err(MembershipError::NoActiveOwner);
343        }
344        let author = keys::public_key_hex(signer);
345        let author_grant = self
346            .active_owner_grant(&author)
347            .ok_or_else(|| MembershipError::SignerIsNotOwner(author.clone()))?;
348        let (seq, previous_hash) = self.next_stream_position(&author, &author_grant, stream_id)?;
349        let retirement_barriers =
350            self.membership_retirement_barriers(&removes, retirement_device_state.as_ref())?;
351        let entry = Signed::sign(
352            MembershipEntryBody {
353                store_id: self
354                    .store_id()
355                    .expect("validated chain has a store id")
356                    .to_string(),
357                author_pubkey: author,
358                author_owner_grant: author_grant,
359                stream_id,
360                seq,
361                previous_hash,
362                dependencies: self.effective_frontier(),
363                resolution_dependencies: self.resolution_refs().to_vec(),
364                created_at,
365                change: MembershipChange::RemoveMember {
366                    user_pubkey,
367                    removes,
368                    retirement_barriers,
369                    retirement_device_state,
370                    wrapped_keys,
371                },
372                provider_admin: None,
373            },
374            signer,
375        );
376        let mut candidate = self.clone();
377        candidate.add_entry(entry.clone())?;
378        Ok(entry)
379    }
380
381    pub fn signed_resolution_activation_in_stream(
382        &self,
383        store_root_hash: ObjectHash,
384        signer: &UserKeypair,
385        stream_id: AuthorStreamId,
386        reference: StoreMembershipConflictResolutionRef,
387        resolution: &StoreMembershipConflictResolution,
388        created_at: String,
389    ) -> Result<MembershipEntry, MembershipError> {
390        self.ensure_resolved()?;
391        let MembershipStatus::Resolved(resolved_before) = self.status() else {
392            unreachable!("ensure_resolved accepted a conflict")
393        };
394        let author = keys::public_key_hex(signer);
395        if !resolution.verify_signature()
396            || resolution.store_root_hash != store_root_hash
397            || reference.resolver_pubkey != author
398            || !self.resolution_refs().contains(&reference)
399            || self.active_owner_grant(&author) != Some(resolution.replacement_grant.clone())
400        {
401            return Err(MembershipError::InvalidConflictResolution);
402        }
403        let author_grant = resolution.replacement_grant.clone();
404        if self
405            .raw_stream_tip(&author, &author_grant, stream_id)
406            .is_some()
407        {
408            return Err(MembershipError::ResolutionActivationRequiresFreshStream);
409        }
410        let entry = Signed::sign(
411            MembershipEntryBody {
412                store_id: self
413                    .store_id()
414                    .expect("validated chain has a store id")
415                    .to_string(),
416                author_pubkey: author,
417                author_owner_grant: author_grant,
418                stream_id,
419                seq: 1,
420                previous_hash: None,
421                dependencies: self.effective_frontier(),
422                resolution_dependencies: self.resolution_refs().to_vec(),
423                created_at,
424                change: MembershipChange::ResolutionActivation {
425                    resolution: reference,
426                },
427                provider_admin: None,
428            },
429            signer,
430        );
431        let mut candidate = self.clone();
432        candidate.add_entry(entry.clone())?;
433        let MembershipStatus::Resolved(resolved_after) = candidate.status() else {
434            return Err(MembershipError::InvalidConflictResolution);
435        };
436        if resolved_after.state_hash != resolved_before.state_hash {
437            return Err(MembershipError::InvalidConflictResolution);
438        }
439        Ok(entry)
440    }
441
442    #[cfg(any(test, feature = "test-utils"))]
443    pub fn signed_set_member_in_stream(
444        &self,
445        signer: &UserKeypair,
446        stream_id: AuthorStreamId,
447        user_pubkey: String,
448        provider_account_email: Option<String>,
449        role: MemberRole,
450        created_at: String,
451    ) -> Result<MembershipEntry, MembershipError> {
452        let role = StoreMembershipRoleGrant::from_direct_assignment(role)?;
453        let grant_id = self.next_member_grant_id_in_stream(signer, stream_id, &user_pubkey)?;
454        let dependencies = self.effective_frontier();
455        let wrapped_key = test_wrapped_key_ref(
456            &keys::public_key_hex(signer),
457            &user_pubkey,
458            membership_causal_generation(&self.entries, &dependencies),
459            b"Merge membership test wrap",
460        );
461        self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
462            signer,
463            stream_id,
464            user_pubkey,
465            provider_account_email,
466            role,
467            grant_id,
468            None,
469            wrapped_key,
470            created_at,
471        )
472    }
473
474    #[cfg(any(test, feature = "test-utils"))]
475    pub(crate) fn signed_promote_member_in_stream_for_test(
476        &self,
477        signer: &UserKeypair,
478        stream_id: AuthorStreamId,
479        user_pubkey: String,
480        created_at: String,
481    ) -> Result<MembershipEntry, MembershipError> {
482        let author_pubkey = keys::public_key_hex(signer);
483        let dependencies = self.effective_frontier();
484        let wrapped_key = test_wrapped_key_ref(
485            &author_pubkey,
486            &user_pubkey,
487            membership_causal_generation(&self.entries, &dependencies),
488            b"Merge Owner-promotion test wrap",
489        );
490        self.signed_promote_member_in_stream_with_wrapped_key_for_test(
491            signer,
492            stream_id,
493            user_pubkey,
494            wrapped_key,
495            created_at,
496        )
497    }
498
499    #[cfg(any(test, feature = "test-utils"))]
500    pub(crate) fn signed_promote_member_in_stream_with_wrapped_key_for_test(
501        &self,
502        signer: &UserKeypair,
503        stream_id: AuthorStreamId,
504        user_pubkey: String,
505        wrapped_key: WrappedStoreKeyRef,
506        created_at: String,
507    ) -> Result<MembershipEntry, MembershipError> {
508        let author_pubkey = keys::public_key_hex(signer);
509        let promoter_owner_grant = self
510            .active_owner_grant(&author_pubkey)
511            .ok_or_else(|| MembershipError::SignerIsNotOwner(author_pubkey.clone()))?;
512        let member_grants = self.active_grant_ids(&user_pubkey);
513        let Some(member_grant) = member_grants.iter().next().cloned() else {
514            return Err(MembershipError::InvalidOwnerPromotion);
515        };
516        if member_grants.len() != 1
517            || self
518                .active_grant(&member_grant)
519                .is_none_or(|record| record.role != StoreMembershipRoleGrant::Member)
520        {
521            return Err(MembershipError::InvalidOwnerPromotion);
522        }
523        let (seq, previous_hash) =
524            self.next_stream_position(&author_pubkey, &promoter_owner_grant, stream_id)?;
525        let promotion_id = OwnerPromotionId::from_generated(format!(
526            "test promotion {author_pubkey} {user_pubkey} {stream_id:?} {seq}"
527        ));
528        let store_root_hash = ObjectHash::digest(
529            self.store_id()
530                .expect("validated membership chain has a Store id")
531                .as_bytes(),
532        );
533        let intended_owner_grant = crate::store_commit::derive_owner_promotion_grant(
534            store_root_hash,
535            promotion_id,
536            &user_pubkey,
537        );
538        let membership_state_hash = match self.status() {
539            MembershipStatus::Resolved(state) => state.state_hash,
540            MembershipStatus::Conflict(_) => return Err(MembershipError::InvalidOwnerPromotion),
541        };
542        let object = |name: &str| {
543            let slot = crate::objects::ObjectSlot::logical(format!(
544                "test/owner-promotion/{promotion_id:?}/{name}.json"
545            ))
546            .expect("test Owner-promotion slot is valid");
547            ExactObjectRef::new(slot, 1, ObjectHash::digest(name.as_bytes()))
548        };
549        let registration = |name: &str| StoreDeviceRegistrationRef {
550            device_id: ObjectHash::digest(name.as_bytes())
551                .to_string()
552                .parse()
553                .expect("digest is a valid Store device id"),
554            registration_hash: ObjectHash::digest(format!("{name} registration").as_bytes()),
555            object: object(&format!("{name}-registration")),
556        };
557        let candidate_stream = AuthorStreamId::from_bytes([0xA5; 32]);
558        let activation_commit = crate::store_commit::StoreBatchCommitRef {
559            coord: crate::store_commit::StoreCommitCoord {
560                stream_id: candidate_stream,
561                sequence: 1,
562            },
563            commit_hash: ObjectHash::digest(b"test Owner-promotion activation commit"),
564            object: object("activation-commit"),
565        };
566        let membership = GrantStreamAnchor::StoreMembership {
567            first_slot: crate::objects::ObjectSlot::logical(format!(
568                "{}.json",
569                crate::store_commit::membership_head_slot_prefix(
570                    &user_pubkey,
571                    &intended_owner_grant,
572                    stream_id,
573                    1,
574                )
575            ))
576            .expect("test membership head slot is valid"),
577        };
578        let request = OwnerPromotionRequest::unsigned_for_test(OwnerPromotionRequestBody {
579            promotion_id,
580            store_root_hash,
581            promoter_registration: registration("promoter"),
582            promoter_owner_grant: promoter_owner_grant.clone(),
583            member_pubkey: user_pubkey.clone(),
584            member_grant,
585            member_registration: registration("member"),
586            intended_owner_grant: intended_owner_grant.clone(),
587            predecessor_membership: crate::circle_control::StoreMembershipStateRef::from_parts(
588                Vec::new(),
589                Vec::new(),
590                Vec::new(),
591                membership_state_hash,
592            )
593            .expect("construct test predecessor membership"),
594            predecessor_devices: StoreDeviceStateRef::from_resolved(
595                crate::store_commit::CommitFrontier(BTreeMap::new()),
596                &crate::store_commit::ResolvedStoreDeviceState {
597                    devices: BTreeMap::new(),
598                    recovery: Vec::new(),
599                    state_hash: ObjectHash::digest(b"test Owner-promotion device state"),
600                },
601            )
602            .expect("construct test predecessor device state"),
603            finalization: OwnerPromotionFinalization {
604                author_stream: stream_id,
605                seq,
606                previous_hash,
607            },
608        });
609        let acceptance =
610            OwnerPromotionAcceptance::unsigned_for_test(OwnerPromotionAcceptanceBody {
611                request: Box::new(request),
612                activation: OwnerPromotionRequestActivation {
613                    commit: activation_commit,
614                    head: crate::store_commit::StoreDeviceHeadRef {
615                        head_hash: ObjectHash::digest(b"test Owner-promotion activation head"),
616                        object: object("activation-head"),
617                    },
618                },
619                anchors: OwnerPromotionAnchors {
620                    membership: membership.clone(),
621                    recovery: GrantStreamAnchor::OwnerRecovery {
622                        first_slot: crate::objects::ObjectSlot::logical(format!(
623                            "test/owner-promotion/{promotion_id:?}/recovery/1.json"
624                        ))
625                        .expect("test recovery slot is valid"),
626                    },
627                },
628            });
629        self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
630            signer,
631            stream_id,
632            user_pubkey,
633            None,
634            StoreMembershipRoleGrant::Owner {
635                recovery: OwnerRecoveryAnchorRef::Promotion {
636                    acceptance: Box::new(acceptance),
637                },
638            },
639            intended_owner_grant,
640            Some(membership),
641            wrapped_key,
642            created_at,
643        )
644    }
645
646    #[cfg(any(test, feature = "test-utils"))]
647    pub(crate) fn add_owner_for_test(
648        &mut self,
649        signer: &UserKeypair,
650        stream_id: AuthorStreamId,
651        user_pubkey: String,
652        created_at: String,
653    ) -> Result<(), MembershipError> {
654        let member = self.signed_set_member_in_stream(
655            signer,
656            stream_id,
657            user_pubkey.clone(),
658            None,
659            MemberRole::Member,
660            format!("{created_at}: Member grant"),
661        )?;
662        self.add_entry(member)?;
663        let promotion = self.signed_promote_member_in_stream_for_test(
664            signer,
665            stream_id,
666            user_pubkey,
667            created_at,
668        )?;
669        self.add_entry(promotion)
670    }
671
672    #[cfg(any(test, feature = "test-utils"))]
673    pub fn signed_remove_member_in_stream(
674        &self,
675        signer: &UserKeypair,
676        stream_id: AuthorStreamId,
677        user_pubkey: String,
678        created_at: String,
679    ) -> Result<MembershipEntry, MembershipError> {
680        let owner = keys::public_key_hex(signer);
681        let dependencies = self.effective_frontier();
682        let generation = membership_causal_generation(&self.entries, &dependencies)
683            .checked_add(1)
684            .ok_or(MembershipError::InvalidWrappedKeys(self.entries.len()))?;
685        let wrapped_keys = self
686            .current_members()
687            .into_iter()
688            .filter(|(member, _)| member != &user_pubkey)
689            .map(|(member, _)| {
690                test_wrapped_key_ref(&owner, &member, generation, b"Merge removal test wrap")
691            })
692            .collect();
693        let removes = self.active_grant_ids(&user_pubkey);
694        let mut recovery = removes
695            .iter()
696            .filter_map(|grant| {
697                self.state
698                    .grants
699                    .get(grant)
700                    .and_then(GrantState::active)
701                    .filter(|record| record.role.is_owner())
702                    .map(|record| OwnerRecoveryCursor {
703                        owner_grant: grant.clone(),
704                        position: OwnerRecoveryPosition::At {
705                            node: OwnerRecoveryNodeRef {
706                                owner_pubkey: record.member_pubkey.clone(),
707                                owner_grant: grant.clone(),
708                                sequence: 1,
709                                node_hash: ObjectHash::digest(
710                                    format!("test recovery node {grant}").as_bytes(),
711                                ),
712                                object: ExactObjectRef::new(
713                                    crate::objects::ObjectSlot::logical(format!(
714                                        "test/recovery/{grant}/1.json"
715                                    ))
716                                    .expect("test recovery node slot is valid"),
717                                    1,
718                                    ObjectHash::digest(format!("test recovery {grant}").as_bytes()),
719                                ),
720                            },
721                        },
722                    })
723            })
724            .collect::<Vec<_>>();
725        recovery.sort();
726        let device_state = (!recovery.is_empty()).then(|| {
727            StoreDeviceStateRef::from_resolved(
728                crate::store_commit::CommitFrontier(BTreeMap::new()),
729                &crate::store_commit::ResolvedStoreDeviceState {
730                    devices: BTreeMap::new(),
731                    recovery,
732                    state_hash: ObjectHash::digest(b"test membership retirement device state"),
733                },
734            )
735            .expect("construct test membership retirement device state")
736        });
737        self.signed_remove_member_with_barrier_state(
738            signer,
739            stream_id,
740            user_pubkey,
741            wrapped_keys,
742            device_state,
743            created_at,
744        )
745    }
746}