Skip to main content

coven_protocol/circle_roster/
chain.rs

1use super::reduction::*;
2use super::*;
3
4fn causal_owner_barriers(
5    owner_barriers: &BTreeMap<MembershipGrantId, CircleOwnerGrantBarrier>,
6) -> BTreeMap<MembershipGrantId, OwnerGrantBarrier<CircleRosterCoord>> {
7    owner_barriers
8        .iter()
9        .map(|(grant, barrier)| {
10            (
11                grant.clone(),
12                OwnerGrantBarrier::from_observed(barrier.observed_streams.iter().cloned()),
13            )
14        })
15        .collect()
16}
17
18#[derive(Debug, Clone)]
19pub struct CircleRosterChain {
20    pub(super) entries: Vec<CircleRosterEntry>,
21    pub(super) reduced: Option<causal_grants::ReducedGrants<CircleRosterCoord, CircleRole>>,
22    pub(super) status: CircleRosterStatus,
23    pub(super) head_refs: Vec<CircleRosterHeadRef>,
24    pub(super) resolution_checkpoint: Option<CircleRosterResolutionCheckpoint>,
25}
26
27#[derive(Debug, Clone)]
28pub(super) struct CircleRosterResolutionCheckpoint {
29    raw_heads: Vec<CircleRosterCoord>,
30    effective_frontier: Vec<CircleRosterCoord>,
31    grants: BTreeMap<MembershipGrantId, GrantState<CircleGrantRecord, CircleGrantRetirement>>,
32    included: BTreeSet<CircleRosterCoord>,
33    resolutions: Vec<CircleRosterConflictResolutionRef>,
34}
35
36impl CircleRosterChain {
37    pub fn from_entries(entries: Vec<CircleRosterEntry>) -> Result<Self, CircleRosterError> {
38        Self::from_entries_and_head_refs(entries, Vec::new())
39    }
40
41    pub fn from_entries_with_heads(
42        entries: Vec<CircleRosterEntry>,
43        heads: Vec<ExactCircleRosterHead>,
44    ) -> Result<Self, CircleRosterError> {
45        let head_refs = Self::validate_exact_heads(&entries, &heads)?;
46        Self::from_entries_and_head_refs(entries, head_refs)
47    }
48
49    pub fn with_exact_successor(
50        &self,
51        entry: CircleRosterEntry,
52        head: ExactCircleRosterHead,
53    ) -> Result<Self, CircleRosterError> {
54        if head.head().entry_coord() != entry.coord() {
55            return Err(CircleRosterError::MissingConflictHeads);
56        }
57        let stream = entry.coord().stream_key();
58        let mut entries = self.entries.clone();
59        entries.push(entry);
60        let mut head_refs = self.head_refs.clone();
61        head_refs.retain(|reference| reference.coord.stream_key() != stream);
62        head_refs.push(head.reference().clone());
63        head_refs.sort_by_key(|reference| reference.coord.stream_key());
64        Self::from_entries_head_refs_and_checkpoint(
65            entries,
66            head_refs,
67            self.resolution_checkpoint.clone(),
68        )
69    }
70
71    pub fn resolved_with_successor(
72        &self,
73        entry: CircleRosterEntry,
74    ) -> Result<ResolvedCircleRoster, CircleRosterError> {
75        let mut entries = self.entries.clone();
76        entries.push(entry);
77        Self::from_entries_head_refs_and_checkpoint(
78            entries,
79            self.head_refs.clone(),
80            self.resolution_checkpoint.clone(),
81        )?
82        .try_resolved()
83    }
84
85    pub fn validate_exact_heads(
86        entries: &[CircleRosterEntry],
87        heads: &[ExactCircleRosterHead],
88    ) -> Result<Vec<CircleRosterHeadRef>, CircleRosterError> {
89        let founder = entries.first().ok_or(CircleRosterError::Empty)?;
90        if heads.iter().any(|bound| {
91            let head = bound.head();
92            let reference = bound.reference();
93            head.store_root_hash != founder.store_root_hash
94                || head.circle_id != founder.circle_id
95                || head.entry_coord() != reference.coord
96                || entries
97                    .iter()
98                    .find(|entry| entry.coord() == reference.coord)
99                    .is_none_or(|entry| head.resolutions != entry.resolution_dependencies)
100        }) {
101            return Err(CircleRosterError::MissingConflictHeads);
102        }
103        Ok(heads.iter().map(|head| head.reference().clone()).collect())
104    }
105
106    fn from_entries_and_head_refs(
107        entries: Vec<CircleRosterEntry>,
108        head_refs: Vec<CircleRosterHeadRef>,
109    ) -> Result<Self, CircleRosterError> {
110        Self::from_entries_head_refs_and_checkpoint(entries, head_refs, None)
111    }
112
113    fn from_entries_head_refs_and_checkpoint(
114        entries: Vec<CircleRosterEntry>,
115        head_refs: Vec<CircleRosterHeadRef>,
116        resolution_checkpoint: Option<CircleRosterResolutionCheckpoint>,
117    ) -> Result<Self, CircleRosterError> {
118        let founder = entries.first().ok_or(CircleRosterError::Empty)?;
119        let expected_store = founder.store_root_hash;
120        let expected_circle = founder.circle_id;
121        for (index, entry) in entries.iter().enumerate() {
122            if !entry.verify() {
123                return Err(CircleRosterError::InvalidEntry(index));
124            }
125            if entry.store_root_hash != expected_store || entry.circle_id != expected_circle {
126                return Err(CircleRosterError::ContextMismatch { index });
127            }
128            if matches!(
129                entry.change,
130                CircleRosterChange::ResolutionActivation { .. }
131            ) && resolution_checkpoint.as_ref().is_none_or(|checkpoint| {
132                let already_checkpointed = checkpoint.included.contains(&entry.coord())
133                    || checkpoint.raw_heads.contains(&entry.coord());
134                !already_checkpointed
135                    && (entry.dependencies != checkpoint.effective_frontier
136                        || entry.resolution_dependencies != checkpoint.resolutions)
137            }) {
138                return Err(CircleRosterError::InvalidEntry(index));
139            }
140        }
141        let checkpoint_heads = resolution_checkpoint
142            .as_ref()
143            .map_or_else(Vec::new, |checkpoint| checkpoint.raw_heads.clone());
144        let normalized = causal_grants::entries_beyond_checkpoint(&entries, &checkpoint_heads)
145            .map(|entry| CausalEntry {
146                coord: entry.coord(),
147                previous_hash: entry.previous_hash,
148                dependencies: entry
149                    .dependencies
150                    .iter()
151                    .cloned()
152                    .map(|coord| (coord.stream_key(), coord))
153                    .collect(),
154                change: match &entry.change {
155                    CircleRosterChange::Founder {
156                        member_pubkey,
157                        grant_id,
158                    } => CausalChange::Founder {
159                        member_pubkey: member_pubkey.clone(),
160                        grant_id: grant_id.clone(),
161                        assignment: CircleRole::Owner,
162                    },
163                    CircleRosterChange::SetMember {
164                        member_pubkey,
165                        role,
166                        grant_id,
167                        replaces,
168                        owner_barriers,
169                    } => CausalChange::SetMember {
170                        member_pubkey: member_pubkey.clone(),
171                        assignment: *role,
172                        grant_id: grant_id.clone(),
173                        replaces: replaces.clone(),
174                        owner_barriers: causal_owner_barriers(owner_barriers),
175                    },
176                    CircleRosterChange::RemoveMember {
177                        member_pubkey,
178                        removes,
179                        owner_barriers,
180                    } => CausalChange::RemoveMember {
181                        member_pubkey: member_pubkey.clone(),
182                        removes: removes.clone(),
183                        owner_barriers: causal_owner_barriers(owner_barriers),
184                    },
185                    CircleRosterChange::ResolutionActivation { .. } => {
186                        CausalChange::ResolutionActivation
187                    }
188                },
189            })
190            .collect::<Vec<_>>();
191        let reduction = match &resolution_checkpoint {
192            Some(checkpoint) => {
193                let seeds = causal_grants::map_checkpoint_grants(
194                    &checkpoint.grants,
195                    |record| causal_grants::CausalSeedGrant {
196                        member_pubkey: record.member_pubkey.clone(),
197                        assignment: record.role,
198                    },
199                    || (),
200                );
201                causal_grants::reduce_from_checkpoint(
202                    &normalized,
203                    &checkpoint.raw_heads,
204                    &checkpoint.effective_frontier,
205                    &seeds,
206                    &checkpoint.included,
207                )?
208            }
209            None => causal_grants::reduce(&normalized)?,
210        };
211        let founder_entry = entries
212            .iter()
213            .find(|entry| matches!(entry.change, CircleRosterChange::Founder { .. }))
214            .expect("shared reducer requires one founder");
215        if founder_entry.circle_id
216            != CircleId::founder(
217                founder_entry.store_root_hash,
218                &founder_entry.author_pubkey,
219                &founder_entry.author_owner_grant,
220            )
221        {
222            return Err(CircleRosterError::InvalidFounderIdentity);
223        }
224        let (reduced, status) = match reduction {
225            CausalGrantStatus::Resolved(reduced) => {
226                let resolved = resolved_circle_roster(
227                    &reduced,
228                    resolution_checkpoint
229                        .as_ref()
230                        .map(|checkpoint| &checkpoint.grants),
231                )?;
232                (Some(reduced), CircleRosterStatus::Resolved(resolved))
233            }
234            CausalGrantStatus::Conflict(CausalGrantConflict::ConcurrentMemberAssignments {
235                raw_heads,
236                effective_frontier,
237                member_pubkey,
238                conflicting_grants,
239                uncontested_grants,
240                reduced,
241            }) => {
242                let heads = exact_circle_head_refs(&head_refs, &raw_heads)?;
243                let conflict_hash = circle_assignment_conflict_hash(
244                    expected_store,
245                    expected_circle,
246                    &heads,
247                    &member_pubkey,
248                    &conflicting_grants,
249                );
250                (
251                    Some(reduced),
252                    CircleRosterStatus::Conflict(
253                        CircleRosterConflict::ConcurrentMemberAssignments {
254                            conflict_hash,
255                            heads,
256                            effective_frontier,
257                            member_pubkey,
258                            conflicting_grants: map_circle_grants(
259                                conflicting_grants,
260                                resolution_checkpoint
261                                    .as_ref()
262                                    .map(|checkpoint| &checkpoint.grants),
263                            )?,
264                            uncontested_grants: map_circle_grants(
265                                uncontested_grants,
266                                resolution_checkpoint
267                                    .as_ref()
268                                    .map(|checkpoint| &checkpoint.grants),
269                            )?,
270                        },
271                    ),
272                )
273            }
274            CausalGrantStatus::Conflict(CausalGrantConflict::RevocationCycle {
275                raw_heads,
276                cyclic_sources,
277                involved_owner_grants,
278                maximal_valid_branches,
279            }) => {
280                let heads = exact_circle_head_refs(&head_refs, &raw_heads)?;
281                let branches = maximal_valid_branches
282                    .into_iter()
283                    .map(|branch| -> Result<CircleRosterBranch, CircleRosterError> {
284                        let resolved = resolved_circle_roster(
285                            &branch.reduced,
286                            resolution_checkpoint
287                                .as_ref()
288                                .map(|checkpoint| &checkpoint.grants),
289                        )?;
290                        Ok(CircleRosterBranch {
291                            heads: exact_circle_head_refs(&head_refs, &branch.raw_heads)?,
292                            effective_frontier: branch.effective_frontier,
293                            grants: resolved.grants,
294                            state_hash: resolved.state_hash,
295                        })
296                    })
297                    .collect::<Result<Vec<_>, _>>()?;
298                let conflict_hash = circle_revocation_conflict_hash(
299                    expected_store,
300                    expected_circle,
301                    &heads,
302                    &cyclic_sources,
303                    &involved_owner_grants,
304                );
305                (
306                    None,
307                    CircleRosterStatus::Conflict(CircleRosterConflict::RevocationCycle {
308                        conflict_hash,
309                        heads,
310                        cyclic_sources,
311                        involved_owner_grants,
312                        maximal_valid_branches: branches,
313                    }),
314                )
315            }
316        };
317        Ok(Self {
318            entries,
319            reduced,
320            status,
321            head_refs,
322            resolution_checkpoint,
323        })
324    }
325
326    pub fn entries(&self) -> &[CircleRosterEntry] {
327        &self.entries
328    }
329
330    pub fn status(&self) -> &CircleRosterStatus {
331        &self.status
332    }
333
334    pub fn resolution_refs(&self) -> &[CircleRosterConflictResolutionRef] {
335        self.resolution_checkpoint
336            .as_ref()
337            .map_or(&[], |checkpoint| checkpoint.resolutions.as_slice())
338    }
339
340    pub fn resolution_checkpoint_covers(&self, coord: &CircleRosterCoord) -> bool {
341        self.resolution_checkpoint
342            .as_ref()
343            .is_some_and(|checkpoint| {
344                checkpoint.included.contains(coord) || checkpoint.raw_heads.contains(coord)
345            })
346    }
347
348    pub fn replay_resolved_history_to_heads(
349        &self,
350        entries: Vec<CircleRosterEntry>,
351        heads: Vec<CircleRosterHeadRef>,
352    ) -> Result<Self, CircleRosterError> {
353        let checkpoint = self
354            .resolution_checkpoint
355            .clone()
356            .ok_or(CircleRosterError::InvalidConflictResolution)?;
357        if heads.iter().any(|head| {
358            entries
359                .iter()
360                .find(|entry| entry.coord() == head.coord)
361                .is_none()
362        }) {
363            return Err(CircleRosterError::MissingConflictHeads);
364        }
365        Self::from_entries_head_refs_and_checkpoint(entries, heads, Some(checkpoint))
366    }
367
368    pub fn replay_merged_resolved_histories_to_heads(
369        chains: &[&CircleRosterChain],
370        entries: Vec<CircleRosterEntry>,
371        heads: Vec<CircleRosterHeadRef>,
372    ) -> Result<Self, CircleRosterError> {
373        let mut raw_by_stream = BTreeMap::new();
374        let mut effective_by_stream = BTreeMap::new();
375        let mut grants = BTreeMap::new();
376        let mut included = BTreeSet::new();
377        let mut resolutions = BTreeSet::new();
378        for chain in chains {
379            let checkpoint = chain
380                .resolution_checkpoint
381                .as_ref()
382                .ok_or(CircleRosterError::InvalidConflictResolution)?;
383            if !causal_grants::merge_checkpoint_frontier(&mut raw_by_stream, &checkpoint.raw_heads)
384                || !causal_grants::merge_checkpoint_frontier(
385                    &mut effective_by_stream,
386                    &checkpoint.effective_frontier,
387                )
388                || !causal_grants::merge_checkpoint_evidence(
389                    &mut grants,
390                    &mut included,
391                    &checkpoint.grants,
392                    &checkpoint.included,
393                )
394            {
395                return Err(CircleRosterError::InvalidConflictResolution);
396            }
397            resolutions.extend(checkpoint.resolutions.iter().cloned());
398        }
399        let checkpoint = CircleRosterResolutionCheckpoint {
400            raw_heads: raw_by_stream.into_values().collect(),
401            effective_frontier: effective_by_stream.into_values().collect(),
402            grants,
403            included,
404            resolutions: resolutions.into_iter().collect(),
405        };
406        let base = chains
407            .first()
408            .ok_or(CircleRosterError::InvalidConflictResolution)?;
409        let mut merged = (*base).clone();
410        merged.resolution_checkpoint = Some(checkpoint);
411        merged.replay_resolved_history_to_heads(entries, heads)
412    }
413
414    pub fn checkpoint_current_resolved_state(&mut self) -> Result<(), CircleRosterError> {
415        self.try_resolved()?;
416        let resolutions = self
417            .resolution_checkpoint
418            .as_ref()
419            .map_or_else(Vec::new, |checkpoint| checkpoint.resolutions.clone());
420        let checkpoint_grants = self
421            .resolution_checkpoint
422            .as_ref()
423            .map(|checkpoint| &checkpoint.grants);
424        let reduced = self
425            .reduced
426            .as_ref()
427            .ok_or(CircleRosterError::InvalidConflictResolution)?;
428        let grants = reduced
429            .grants
430            .iter()
431            .map(|(grant, state)| -> Result<_, CircleRosterError> {
432                Ok((
433                    grant.clone(),
434                    map_circle_grant_state(grant, state, checkpoint_grants)?,
435                ))
436            })
437            .collect::<Result<_, _>>()?;
438        self.resolution_checkpoint = Some(CircleRosterResolutionCheckpoint {
439            raw_heads: self.author_heads(),
440            effective_frontier: self.effective_frontier(),
441            grants,
442            included: reduced.included.clone(),
443            resolutions,
444        });
445        Ok(())
446    }
447
448    pub fn resolved(&self) -> ResolvedCircleRoster {
449        self.try_resolved()
450            .expect("caller must inspect Circle roster status before consuming resolved state")
451    }
452
453    pub fn try_resolved(&self) -> Result<ResolvedCircleRoster, CircleRosterError> {
454        match &self.status {
455            CircleRosterStatus::Resolved(resolved) => Ok(resolved.clone()),
456            CircleRosterStatus::Conflict(_) => Err(CircleRosterError::Conflict),
457        }
458    }
459
460    pub(crate) fn resolved_with(
461        &self,
462        resolutions: &[CircleRosterConflictResolution],
463    ) -> Result<ResolvedCircleRoster, CircleRosterError> {
464        match &self.status {
465            CircleRosterStatus::Resolved(resolved) if resolutions.is_empty() => {
466                Ok(resolved.clone())
467            }
468            CircleRosterStatus::Conflict(conflict) => resolve_circle_roster_conflict(
469                self.entries[0].store_root_hash,
470                self.entries[0].circle_id,
471                conflict,
472                resolutions,
473            ),
474            CircleRosterStatus::Resolved(_) => Err(CircleRosterError::InvalidConflictResolution),
475        }
476    }
477
478    pub fn apply_resolutions(
479        &mut self,
480        resolutions: &[CircleRosterConflictResolution],
481    ) -> Result<(), CircleRosterError> {
482        let (raw_heads, effective_frontier) = match self.status() {
483            CircleRosterStatus::Conflict(CircleRosterConflict::RevocationCycle {
484                heads,
485                maximal_valid_branches,
486                ..
487            }) => (
488                heads
489                    .iter()
490                    .map(|reference| reference.coord.clone())
491                    .collect(),
492                causal_grants::selected_branch_frontier(resolutions, |resolution| {
493                    maximal_valid_branches
494                        .iter()
495                        .find(|branch| branch.heads == resolution.resolver_branch_heads)
496                        .map(|branch| branch.effective_frontier.as_slice())
497                        .ok_or(CircleRosterError::InvalidConflictResolution)
498                })?,
499            ),
500            _ => return Err(CircleRosterError::InvalidConflictResolution),
501        };
502        let resolved = self.resolved_with(resolutions)?;
503        let grants = resolved.grants.clone();
504        let included = causal_grants::history_closure(&self.entries, &effective_frontier);
505        let checkpoint = CircleRosterResolutionCheckpoint {
506            raw_heads,
507            effective_frontier: effective_frontier.clone(),
508            grants: grants.clone(),
509            included: included.clone(),
510            resolutions: causal_grants::checkpoint_resolution_refs(
511                self.resolution_checkpoint
512                    .as_ref()
513                    .map(|checkpoint| checkpoint.resolutions.as_slice()),
514                resolutions
515                    .iter()
516                    .map(CircleRosterConflictResolution::resolution_ref),
517            ),
518        };
519        self.reduced = Some(causal_grants::ReducedGrants {
520            grants: causal_grants::map_checkpoint_grants(
521                &grants,
522                |record| causal_grants::GrantRecord {
523                    member_pubkey: record.member_pubkey.clone(),
524                    assignment: record.role,
525                    creation: causal_grants::CausalGrantCreation::Checkpoint,
526                },
527                || causal_grants::CausalGrantRetirement::Checkpoint,
528            ),
529            included: included.clone(),
530        });
531        self.status = CircleRosterStatus::Resolved(resolved);
532        self.resolution_checkpoint = Some(checkpoint);
533        Ok(())
534    }
535
536    pub fn author_heads(&self) -> Vec<CircleRosterCoord> {
537        causal_grants::stream_frontier(self.entries.iter().map(CircleRosterEntry::coord))
538    }
539
540    pub fn effective_frontier(&self) -> Vec<CircleRosterCoord> {
541        let Some(reduced) = &self.reduced else {
542            return Vec::new();
543        };
544        causal_grants::stream_frontier(
545            self.entries
546                .iter()
547                .map(CircleRosterEntry::coord)
548                .filter(|coord| reduced.includes_coord(coord)),
549        )
550    }
551
552    fn active_grants(&self, member_pubkey: &str) -> BTreeSet<MembershipGrantId> {
553        let reduced = self
554            .reduced
555            .as_ref()
556            .expect("resolved roster has reduced grants");
557        reduced
558            .grants
559            .iter()
560            .filter_map(|(grant, state)| {
561                state
562                    .active()
563                    .is_some_and(|record| record.member_pubkey == member_pubkey)
564                    .then_some(grant.clone())
565            })
566            .collect()
567    }
568
569    fn active_owner_grant(&self, member_pubkey: &str) -> Option<MembershipGrantId> {
570        self.active_grants(member_pubkey).into_iter().find(|grant| {
571            self.reduced
572                .as_ref()
573                .expect("resolved roster has reduced grants")
574                .active_grant(grant)
575                .is_some_and(|record| record.assignment == CircleRole::Owner)
576        })
577    }
578
579    pub fn reusable_author_streams(
580        &self,
581        author_pubkey: &str,
582        device_id: &str,
583        grant: &MembershipGrantId,
584    ) -> BTreeSet<AuthorStreamId> {
585        self.effective_frontier()
586            .into_iter()
587            .filter(|effective_tip| {
588                effective_tip.author_pubkey == author_pubkey
589                    && effective_tip.device_id == device_id
590                    && effective_tip.author_owner_grant == *grant
591                    && self
592                        .entries
593                        .iter()
594                        .map(CircleRosterEntry::coord)
595                        .filter(|coord| coord.stream_key() == effective_tip.stream_key())
596                        .max_by_key(|coord| coord.seq)
597                        .as_ref()
598                        == Some(effective_tip)
599            })
600            .map(|coord| coord.stream_id)
601            .collect()
602    }
603
604    fn owner_barriers(
605        &self,
606        grants: &BTreeSet<MembershipGrantId>,
607        dependencies: &[CircleRosterCoord],
608    ) -> BTreeMap<MembershipGrantId, CircleOwnerGrantBarrier> {
609        grants
610            .iter()
611            .filter(|grant| {
612                self.reduced
613                    .as_ref()
614                    .expect("resolved roster has reduced grants")
615                    .active_grant(grant)
616                    .is_some_and(|record| record.assignment == CircleRole::Owner)
617            })
618            .map(|grant| {
619                let observed_streams = dependencies
620                    .iter()
621                    .filter(|coord| coord.author_owner_grant == *grant)
622                    .cloned()
623                    .collect();
624                (grant.clone(), CircleOwnerGrantBarrier { observed_streams })
625            })
626            .collect()
627    }
628
629    pub(super) fn next_position(
630        &self,
631        stream: &CircleAuthorStreamKey,
632    ) -> Result<(u64, Option<ObjectHash>), CircleRosterError> {
633        let raw_tip = self
634            .entries
635            .iter()
636            .map(CircleRosterEntry::coord)
637            .filter(|coord| coord.stream_key() == *stream)
638            .max_by_key(|coord| coord.seq);
639        let effective_tip = self
640            .effective_frontier()
641            .into_iter()
642            .find(|coord| coord.stream_key() == *stream);
643        if raw_tip.is_some()
644            && !self
645                .reusable_author_streams(
646                    &stream.author_pubkey,
647                    &stream.device_id,
648                    &stream.author_owner_grant,
649                )
650                .contains(&stream.stream_id)
651        {
652            return Err(CircleRosterError::PrunedAuthorStream);
653        }
654        match effective_tip {
655            Some(tip) => Ok((
656                tip.seq
657                    .checked_add(1)
658                    .ok_or(CircleRosterError::SequenceExhausted { current: tip.seq })?,
659                Some(tip.entry_hash),
660            )),
661            None => Ok((1, None)),
662        }
663    }
664
665    pub fn signed_set_member(
666        &self,
667        device_id: &str,
668        stream_id: AuthorStreamId,
669        member_pubkey: String,
670        role: CircleRole,
671        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
672    ) -> Result<CircleRosterEntry, CircleRosterError> {
673        self.signed_change(device_id, stream_id, member_pubkey, Some(role), signer)
674    }
675
676    pub fn signed_remove_member(
677        &self,
678        device_id: &str,
679        stream_id: AuthorStreamId,
680        member_pubkey: String,
681        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
682    ) -> Result<CircleRosterEntry, CircleRosterError> {
683        if self.active_grants(&member_pubkey).is_empty() {
684            return Err(CircleRosterError::NotAMember(member_pubkey));
685        }
686        self.signed_change(device_id, stream_id, member_pubkey, None, signer)
687    }
688
689    fn signed_change(
690        &self,
691        device_id: &str,
692        stream_id: AuthorStreamId,
693        member_pubkey: String,
694        role: Option<CircleRole>,
695        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
696    ) -> Result<CircleRosterEntry, CircleRosterError> {
697        if matches!(self.status, CircleRosterStatus::Conflict(_)) {
698            return Err(CircleRosterError::Conflict);
699        }
700        let author_pubkey = keys::public_key_hex(signer);
701        let author_owner_grant = self
702            .active_owner_grant(&author_pubkey)
703            .ok_or_else(|| CircleRosterError::SignerIsNotOwner(author_pubkey.clone()))?;
704        let stream = CircleAuthorStreamKey {
705            author_pubkey: author_pubkey.clone(),
706            device_id: device_id.to_string(),
707            stream_id,
708            author_owner_grant: author_owner_grant.clone(),
709        };
710        let (seq, previous_hash) = self.next_position(&stream)?;
711        let dependencies = self.effective_frontier();
712        let replaced = self.active_grants(&member_pubkey);
713        let owner_barriers = self.owner_barriers(&replaced, &dependencies);
714        let change = match role {
715            Some(role) => CircleRosterChange::SetMember {
716                member_pubkey: member_pubkey.clone(),
717                role,
718                grant_id: MembershipGrantId(ObjectHash::digest(
719                    format!(
720                        "coven.circle-roster-grant.v1\0{}\0{}\0{}\0{}\0{}\0{}\0{}",
721                        self.entries[0].circle_id,
722                        author_pubkey,
723                        device_id,
724                        stream_id,
725                        author_owner_grant,
726                        seq,
727                        member_pubkey
728                    )
729                    .as_bytes(),
730                )),
731                replaces: replaced,
732                owner_barriers,
733            },
734            None => CircleRosterChange::RemoveMember {
735                member_pubkey,
736                removes: replaced,
737                owner_barriers,
738            },
739        };
740        let entry = Signed::sign(
741            CircleRosterEntryBody {
742                store_root_hash: self.entries[0].store_root_hash,
743                circle_id: self.entries[0].circle_id,
744                author_pubkey,
745                device_id: device_id.to_string(),
746                stream_id,
747                author_owner_grant,
748                seq,
749                previous_hash,
750                dependencies,
751                resolution_dependencies: self.resolution_refs().to_vec(),
752                change,
753            },
754            signer,
755        );
756        let mut candidate_history = self.entries.clone();
757        candidate_history.push(entry.clone());
758        Self::from_entries_head_refs_and_checkpoint(
759            candidate_history,
760            self.head_refs.clone(),
761            self.resolution_checkpoint.clone(),
762        )?;
763        Ok(entry)
764    }
765
766    #[cfg(any(test, feature = "test-utils"))]
767    pub fn signed_cycle_resolution(
768        &self,
769        resolver_branch_heads: Vec<CircleRosterHeadRef>,
770        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
771    ) -> Result<CircleRosterConflictResolution, CircleRosterError> {
772        let CircleRosterStatus::Conflict(CircleRosterConflict::RevocationCycle {
773            conflict_hash,
774            heads,
775            involved_owner_grants,
776            maximal_valid_branches,
777            ..
778        }) = self.status()
779        else {
780            return Err(CircleRosterError::Conflict);
781        };
782        let resolver_pubkey = keys::public_key_hex(signer);
783        let branch = maximal_valid_branches
784            .iter()
785            .find(|branch| branch.heads == resolver_branch_heads)
786            .ok_or(CircleRosterError::InvalidConflictResolution)?;
787        if !causal_grants::active_grants(&branch.grants).any(|(_, record)| {
788            record.member_pubkey == resolver_pubkey && record.role == CircleRole::Owner
789        }) {
790            return Err(CircleRosterError::SignerIsNotOwner(resolver_pubkey));
791        }
792        let replacement_grant = derive_circle_resolution_grant(conflict_hash, &resolver_pubkey);
793        let mut retired_owner_grants = involved_owner_grants.clone();
794        retired_owner_grants.extend(causal_grants::active_grants(&branch.grants).filter_map(
795            |(grant, record)| {
796                (record.member_pubkey == resolver_pubkey && record.role == CircleRole::Owner)
797                    .then_some(grant.clone())
798            },
799        ));
800        Ok(Signed::sign(
801            super::CircleRosterConflictResolutionBody {
802                store_root_hash: self.entries[0].store_root_hash,
803                circle_id: self.entries[0].circle_id,
804                conflict_hash: *conflict_hash,
805                conflicting_heads: heads.clone(),
806                retired_owner_grants,
807                resolver_pubkey,
808                resolver_branch_heads,
809                replacement_grant,
810            },
811            signer,
812        ))
813    }
814}