Skip to main content

coven_protocol/circle_control/
transition.rs

1use super::drafts::*;
2use super::*;
3
4impl CircleTransitionDraft {
5    #[allow(clippy::too_many_arguments)]
6    pub fn founder(
7        store_root_hash: ObjectHash,
8        candidate_family: crate::store_commit::CandidateFamilyId,
9        device_id: &str,
10        name: &str,
11        metadata_stamp: &str,
12        store_membership: StoreMembershipStateRef,
13        membership_authority: MembershipGrantCreationAuthority,
14        mut store_members: Vec<(String, MemberRole)>,
15        ids: &dyn coven_foundation::id_provider::IdProvider,
16        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
17    ) -> Result<Self, CircleTransitionError> {
18        let author_pubkey = keys::public_key_hex(signer);
19        store_members.sort_by(|left, right| left.0.cmp(&right.0));
20        store_members.dedup_by(|left, right| left.0 == right.0);
21        if !store_members
22            .iter()
23            .any(|(pubkey, role)| pubkey == &author_pubkey && role.can_write())
24        {
25            return Err(CircleTransitionError::AuthorNotStoreWriter);
26        }
27        let owner_grant =
28            MembershipGrantId(generated_id_digest(ids, OWNER_GRANT_ID_GENERATION_DOMAIN));
29        let author_stream_id = AuthorStreamId::from_digest(generated_id_digest(
30            ids,
31            b"coven.circle-transition-draft-stream.v1\0",
32        ));
33        let circle_id = CircleId::founder(store_root_hash, &author_pubkey, &owner_grant);
34        let epoch_id = CircleEpochId::generate(ids);
35        let keyring = MasterKeyring::generate();
36        let encryption = EncryptionService::from(keyring.clone());
37        let key_fingerprint = encryption.seal_key_fingerprint();
38        let entry = CircleRosterEntry::founder(
39            store_root_hash,
40            circle_id,
41            device_id,
42            author_stream_id,
43            owner_grant.clone(),
44            signer,
45        );
46        let roster_objects = FounderRosterObjects {
47            resolved: CircleRosterChain::from_entries(vec![entry.clone()])?.resolved(),
48            entry,
49        };
50        let roster_state = MergeCircleRosterStateRef {
51            heads: Vec::new(),
52            resolutions: Vec::new(),
53            state_hash: roster_objects.resolved.state_hash,
54        };
55        let metadata = CircleMetadata::founder(
56            store_root_hash,
57            circle_id,
58            epoch_id,
59            name,
60            metadata_stamp,
61            device_id,
62            author_stream_id,
63            owner_grant.clone(),
64            roster_state.clone(),
65            key_fingerprint,
66            signer,
67        )?;
68        let metadata_state = MergeCircleMetadataStateRef {
69            heads: Vec::new(),
70            selected: metadata.coord(),
71            state_hash: metadata.metadata_hash(),
72        };
73        let roster = roster_objects.resolved.clone();
74        let access = CircleAccessDraft::prepare(
75            store_root_hash,
76            candidate_family,
77            circle_id,
78            epoch_id,
79            &keyring.to_serialized(),
80            key_fingerprint,
81            &roster_state,
82            &roster.members(),
83            &store_membership,
84            &store_members,
85            &std::collections::BTreeMap::new(),
86            ids,
87            signer,
88        )?;
89        let common = ActiveCircleEpochCore {
90            epoch_id,
91            key_fingerprint,
92            owners: vec![author_pubkey.clone()],
93            access_root: access.access_root(),
94            origin: CircleEpochOrigin::Founder,
95        };
96        let value = CircleControlValue {
97            order: MergeCircleControlOrder {
98                device_id: device_id.to_string(),
99                stream_id: author_stream_id,
100                author_owner_grant: owner_grant.clone(),
101                seq: 1,
102                previous_control_hash: None,
103                dependencies: Vec::new(),
104            },
105            state: CircleControlState::ActiveEpoch(MergeActiveCircleEpoch {
106                common,
107                metadata: metadata_state,
108                roster: roster_state.clone(),
109                store_membership,
110                covered_control_heads: Vec::new(),
111            }),
112            author_authority: MergeCircleOwnerAuthorityRef::Roster {
113                roster: roster_state,
114                grant_id: owner_grant.clone(),
115                created_at: roster_objects.entry.coord(),
116            },
117            membership_authority,
118        };
119        let control_value = CircleControlBody {
120            store_root_hash,
121            circle_id,
122            value,
123            author_pubkey: author_pubkey.clone(),
124        };
125        let control_value = Signed::sign(control_value, signer);
126        let control = PreparedCircleControl {
127            coord: control_value.coord(),
128            bytes: serde_json::to_vec(&control_value)
129                .expect("circle control serialization cannot fail"),
130            value: control_value,
131        };
132        let policy_objects = CircleTransitionDraftPolicy {
133            roster: CircleRosterDraftPolicy::Founder {
134                entry: roster_objects.entry,
135            },
136            metadata_successor: true,
137        };
138        let access = access.finish(&control)?;
139        Ok(Self {
140            circle_id,
141            epoch_id,
142            keyring: keyring.to_serialized(),
143            roster,
144            policy: policy_objects,
145            metadata,
146            close_intent: None,
147            close_finalization: None,
148            close_cancellation: None,
149            access,
150            control,
151        })
152    }
153
154    #[allow(clippy::too_many_arguments)]
155    pub fn add_member(
156        candidate_family: crate::store_commit::CandidateFamilyId,
157        device_id: &str,
158        store_membership: StoreMembershipStateRef,
159        membership_authority: MembershipGrantCreationAuthority,
160        store_members: Vec<(String, MemberRole)>,
161        current_control: &PreparedCircleControl,
162        current_roster: &CircleMaterializedRoster,
163        current_roster_chain: CircleRosterChain,
164        current_metadata: &CircleMetadata,
165        keyring: &str,
166        roster_stream: AuthorStreamId,
167        member_pubkey: String,
168        role: crate::circle::CircleRole,
169        bootstrap: CircleBootstrapRef,
170        ids: &dyn coven_foundation::id_provider::IdProvider,
171        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
172    ) -> Result<Self, CircleTransitionError> {
173        if current_roster_chain.try_resolved()? != *current_roster {
174            return Err(CircleTransitionError::InvalidCurrentState);
175        }
176        let context = circle_successor_context(
177            store_members,
178            current_control,
179            current_roster,
180            current_metadata,
181            keyring,
182            signer,
183        )?;
184        let CircleSuccessorContext {
185            store_members,
186            author_pubkey,
187            epoch: active_epoch,
188            grant_id,
189            author_authority,
190            key_fingerprint,
191        } = context;
192        if !store_members
193            .iter()
194            .any(|(pubkey, _)| pubkey == &member_pubkey)
195        {
196            return Err(CircleTransitionError::MemberNotInStore(member_pubkey));
197        }
198        let entry = current_roster_chain.signed_set_member(
199            device_id,
200            roster_stream,
201            member_pubkey.clone(),
202            role,
203            signer,
204        )?;
205        let roster = current_roster_chain.resolved_with_successor(entry.clone())?;
206        let roster_state = MergeCircleRosterStateRef {
207            heads: active_epoch.roster.heads.clone(),
208            resolutions: active_epoch.roster.resolutions.clone(),
209            state_hash: roster.state_hash,
210        };
211        let store_root_hash = current_control.value.store_root_hash;
212        let circle_id = current_control.value.circle_id;
213        let epoch_id = current_control.value.epoch_id();
214        let mut control_value = CircleControlBody {
215            store_root_hash,
216            circle_id,
217            value: CircleControlValue {
218                order: MergeCircleControlOrder {
219                    device_id: device_id.to_string(),
220                    stream_id: roster_stream,
221                    author_owner_grant: grant_id.clone(),
222                    seq: current_control
223                        .value
224                        .ordinal()
225                        .checked_add(1)
226                        .ok_or(CircleTransitionError::SequenceOverflow)?,
227                    previous_control_hash: Some(current_control.coord.control_hash()),
228                    dependencies: vec![current_control.coord.clone()],
229                },
230                state: CircleControlState::ActiveEpoch(MergeActiveCircleEpoch {
231                    common: active_epoch.common.clone(),
232                    metadata: active_epoch.metadata.clone(),
233                    roster: roster_state.clone(),
234                    store_membership,
235                    covered_control_heads: active_epoch.covered_control_heads.clone(),
236                }),
237                author_authority,
238                membership_authority,
239            },
240            author_pubkey,
241        };
242        let bootstraps =
243            std::collections::BTreeMap::from([(member_pubkey.clone(), bootstrap.clone())]);
244        let access = CircleAccessDraft::prepare(
245            store_root_hash,
246            candidate_family,
247            circle_id,
248            epoch_id,
249            keyring,
250            key_fingerprint,
251            &roster_state,
252            &roster.members(),
253            &control_value
254                .value
255                .state
256                .active_epoch()
257                .expect("member addition constructs an active epoch")
258                .store_membership,
259            &store_members,
260            &bootstraps,
261            ids,
262            signer,
263        )?;
264        control_value
265            .value
266            .state
267            .active_epoch_mut()
268            .expect("member addition constructs an active epoch")
269            .common
270            .access_root = access.access_root();
271        let control_value = Signed::sign(control_value, signer);
272        let control = PreparedCircleControl {
273            coord: control_value.coord(),
274            bytes: serde_json::to_vec(&control_value)
275                .expect("circle control serialization cannot fail"),
276            value: control_value,
277        };
278        let access = access.finish(&control)?;
279        Ok(Self {
280            circle_id,
281            epoch_id,
282            keyring: keyring.to_string(),
283            roster,
284            policy: CircleTransitionDraftPolicy {
285                roster: CircleRosterDraftPolicy::Successor {
286                    predecessor: current_roster_chain,
287                    entry,
288                },
289                metadata_successor: false,
290            },
291            metadata: current_metadata.clone(),
292            close_intent: None,
293            close_finalization: None,
294            close_cancellation: None,
295            access,
296            control,
297        })
298    }
299
300    #[allow(clippy::too_many_arguments)]
301    pub fn rename(
302        candidate_family: crate::store_commit::CandidateFamilyId,
303        device_id: &str,
304        name: &str,
305        metadata_stamp: &str,
306        store_membership: StoreMembershipStateRef,
307        membership_authority: MembershipGrantCreationAuthority,
308        store_members: Vec<(String, MemberRole)>,
309        current_control: &PreparedCircleControl,
310        current_roster: &CircleMaterializedRoster,
311        current_metadata: &CircleMetadata,
312        keyring: &str,
313        ids: &dyn coven_foundation::id_provider::IdProvider,
314        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
315    ) -> Result<Self, CircleTransitionError> {
316        if name.trim().is_empty() {
317            return Err(CircleTransitionError::EmptyName);
318        }
319        let context = circle_successor_context(
320            store_members,
321            current_control,
322            current_roster,
323            current_metadata,
324            keyring,
325            signer,
326        )?;
327        let CircleSuccessorContext {
328            store_members,
329            author_pubkey,
330            epoch: active_epoch,
331            grant_id,
332            author_authority,
333            key_fingerprint,
334        } = context;
335        let store_root_hash = current_control.value.store_root_hash;
336        let circle_id = current_control.value.circle_id;
337        let epoch_id = current_control.value.epoch_id();
338        let roster_state = current_control.value.roster_state_ref();
339
340        let own_head = active_epoch.metadata.heads.iter().find(|head| {
341            head.coord.author_pubkey == author_pubkey
342                && head.coord.device_id == device_id
343                && head.coord.author_owner_grant == grant_id
344        });
345        let author_stream_id = own_head.map_or_else(
346            || {
347                AuthorStreamId::from_digest(generated_id_digest(
348                    ids,
349                    b"coven.circle-transition-draft-stream.v1\0",
350                ))
351            },
352            |head| head.coord.stream_id,
353        );
354        let metadata_seq = match own_head {
355            Some(head) => head
356                .coord
357                .seq
358                .checked_add(1)
359                .ok_or(CircleTransitionError::SequenceOverflow)?,
360            None => 1,
361        };
362        let metadata_previous = own_head.map(|head| head.coord.metadata_hash);
363        let metadata_dependencies = active_epoch
364            .metadata
365            .heads
366            .iter()
367            .map(|head| head.coord.clone())
368            .collect::<Vec<_>>();
369        let metadata_state = active_epoch.metadata.clone();
370        let mut control_value = CircleControlValue {
371            order: MergeCircleControlOrder {
372                device_id: device_id.to_string(),
373                stream_id: author_stream_id,
374                author_owner_grant: grant_id.clone(),
375                seq: current_control
376                    .value
377                    .ordinal()
378                    .checked_add(1)
379                    .ok_or(CircleTransitionError::SequenceOverflow)?,
380                previous_control_hash: Some(current_control.coord.control_hash()),
381                dependencies: vec![current_control.coord.clone()],
382            },
383            state: CircleControlState::ActiveEpoch(MergeActiveCircleEpoch {
384                common: active_epoch.common.clone(),
385                metadata: active_epoch.metadata.clone(),
386                roster: active_epoch.roster.clone(),
387                store_membership,
388                covered_control_heads: active_epoch.covered_control_heads.clone(),
389            }),
390            author_authority,
391            membership_authority,
392        };
393        let author_owner_grant = grant_id.clone();
394
395        let metadata = Signed::sign(
396            CircleMetadataBody {
397                store_root_hash,
398                circle_id,
399                epoch_id,
400                name: name.to_string(),
401                seq: metadata_seq,
402                previous_hash: metadata_previous,
403                dependencies: metadata_dependencies,
404                metadata_stamp: metadata_stamp.to_string(),
405                author_pubkey: author_pubkey.clone(),
406                device_id: device_id.to_string(),
407                stream_id: author_stream_id,
408                author_owner_grant,
409                author_roster: roster_state.clone(),
410                key_fingerprint,
411            },
412            signer,
413        );
414
415        let mut metadata_state = metadata_state;
416        let selected = [current_metadata, &metadata]
417            .into_iter()
418            .max_by_key(|entry| {
419                (
420                    entry.metadata_stamp.as_str(),
421                    entry.author_pubkey.as_str(),
422                    entry.device_id.as_str(),
423                    entry.metadata_hash(),
424                )
425            })
426            .expect("current and successor metadata are non-empty");
427        metadata_state.selected = selected.coord();
428        metadata_state.state_hash = selected.metadata_hash();
429
430        let access = CircleAccessDraft::prepare(
431            store_root_hash,
432            candidate_family,
433            circle_id,
434            epoch_id,
435            keyring,
436            key_fingerprint,
437            &roster_state,
438            &current_roster.members(),
439            &control_value
440                .state
441                .active_epoch()
442                .expect("rename constructs an active epoch")
443                .store_membership,
444            &store_members,
445            &std::collections::BTreeMap::new(),
446            ids,
447            signer,
448        )?;
449        let active_epoch = control_value
450            .state
451            .active_epoch_mut()
452            .expect("rename constructs an active epoch");
453        active_epoch.common.access_root = access.access_root();
454        active_epoch.metadata = metadata_state;
455        let control_value = CircleControlBody {
456            store_root_hash,
457            circle_id,
458            value: control_value,
459            author_pubkey,
460        };
461        let control_value = Signed::sign(control_value, signer);
462        let control = PreparedCircleControl {
463            coord: control_value.coord(),
464            bytes: serde_json::to_vec(&control_value)
465                .expect("circle control serialization cannot fail"),
466            value: control_value,
467        };
468        let policy_objects = CircleTransitionDraftPolicy {
469            roster: CircleRosterDraftPolicy::Inherited,
470            metadata_successor: true,
471        };
472        let access = access.finish(&control)?;
473        Ok(Self {
474            circle_id,
475            epoch_id,
476            keyring: keyring.to_string(),
477            roster: current_roster.clone(),
478            policy: policy_objects,
479            metadata,
480            close_intent: None,
481            close_finalization: None,
482            close_cancellation: None,
483            access,
484            control,
485        })
486    }
487
488    /// Build a successor of the chosen conflicting branch that causally covers
489    /// every branch, collapsing a retained `ControlConflict` to a single
490    /// resolved control. The successor inherits the chosen branch's epoch, key
491    /// generation, and roster contents verbatim — it changes no membership, keys,
492    /// or deletion intent. It merges the control, metadata, and roster head
493    /// frontiers across every branch (the union of covered heads), so a device
494    /// that authored a losing branch continues its own author streams instead of
495    /// re-allocating their head slots. The name is not inherited from the chosen
496    /// branch but re-derived as the deterministic metadata selection over the
497    /// merged frontier: the metadata layer resolves its own conflict — the
498    /// canonical maximum across every covered head — independent of which control
499    /// branch the Owner chose. `losing_branches` are the retained branches other
500    /// than `chosen`; preparation adds the chosen branch head, so the resolved
501    /// control's causal dependencies and predecessor together name every branch.
502    #[allow(clippy::too_many_arguments)]
503    pub fn resolve(
504        candidate_family: crate::store_commit::CandidateFamilyId,
505        device_id: &str,
506        store_membership: StoreMembershipStateRef,
507        membership_authority: MembershipGrantCreationAuthority,
508        store_members: Vec<(String, MemberRole)>,
509        chosen_control: &PreparedCircleControl,
510        chosen_roster: &CircleMaterializedRoster,
511        chosen_metadata: &CircleMetadata,
512        keyring: &str,
513        losing_branches: Vec<ResolvedConflictBranch>,
514        ids: &dyn coven_foundation::id_provider::IdProvider,
515        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
516    ) -> Result<Self, CircleTransitionError> {
517        let context = circle_successor_context(
518            store_members,
519            chosen_control,
520            chosen_roster,
521            chosen_metadata,
522            keyring,
523            signer,
524        )?;
525        let CircleSuccessorContext {
526            store_members,
527            author_pubkey,
528            epoch: active_epoch,
529            grant_id: _,
530            author_authority,
531            key_fingerprint,
532        } = context;
533        let store_root_hash = chosen_control.value.store_root_hash;
534        let circle_id = chosen_control.value.circle_id;
535        let epoch_id = chosen_control.value.epoch_id();
536        let roster_state = chosen_control.value.roster_state_ref();
537
538        // Merge the control, metadata, and roster head frontiers across every
539        // branch: the chosen branch's frontiers extended by each losing branch's,
540        // one head per author stream at its deepest position. Every branch's
541        // heads become covered, so no author-stream head is re-allocated once the
542        // conflict collapses. Preparation adds the chosen branch head and derives
543        // the predecessor and dependencies from the control frontier, so the
544        // resolved control directly names every branch.
545        let mut covered_control_heads = active_epoch.covered_control_heads.clone();
546        let mut metadata = active_epoch.metadata.clone();
547        let mut roster = active_epoch.roster.clone();
548        for branch in &losing_branches {
549            merge_frontier_head(
550                &mut covered_control_heads,
551                branch.control_head.clone(),
552                |head| head.coord.stream_key(),
553                |head| head.coord.seq,
554            );
555            for head in &branch.metadata_heads {
556                merge_frontier_head(
557                    &mut metadata.heads,
558                    head.clone(),
559                    |head| head.coord.stream_key(),
560                    |head| head.coord.seq,
561                );
562            }
563            for head in &branch.roster_heads {
564                merge_frontier_head(
565                    &mut roster.heads,
566                    head.clone(),
567                    |head| head.coord.stream_key(),
568                    |head| head.coord.seq,
569                );
570            }
571        }
572        covered_control_heads.sort_by_key(|head| head.coord.stream_key());
573        metadata.heads.sort_by_key(|head| head.coord.stream_key());
574        roster.heads.sort_by_key(|head| head.coord.stream_key());
575
576        // The name is the deterministic metadata selection across the merged
577        // frontier. Each branch's selected metadata is already the canonical
578        // maximum over its own covered history, so the maximum across the branch
579        // selections is the canonical selection over their union.
580        let selected_metadata = std::iter::once(chosen_metadata)
581            .chain(
582                losing_branches
583                    .iter()
584                    .map(|branch| &branch.selected_metadata),
585            )
586            .max_by_key(|entry| {
587                (
588                    entry.metadata_stamp.clone(),
589                    entry.author_pubkey.clone(),
590                    entry.device_id.clone(),
591                    entry.metadata_hash(),
592                )
593            })
594            .expect("a resolution names at least the chosen branch's metadata")
595            .clone();
596        metadata.selected = selected_metadata.coord();
597        metadata.state_hash = selected_metadata.metadata_hash();
598
599        let mut control_value = CircleControlBody {
600            store_root_hash,
601            circle_id,
602            value: CircleControlValue {
603                order: MergeCircleControlOrder {
604                    device_id: device_id.to_string(),
605                    stream_id: active_epoch
606                        .covered_control_heads
607                        .iter()
608                        .find(|head| head.coord.stream_key().author_pubkey == author_pubkey)
609                        .map_or_else(
610                            || {
611                                AuthorStreamId::from_digest(generated_id_digest(
612                                    ids,
613                                    b"coven.circle-transition-draft-stream.v1\0",
614                                ))
615                            },
616                            |head| head.coord.stream_id,
617                        ),
618                    author_owner_grant: chosen_metadata.author_owner_grant.clone(),
619                    seq: chosen_control
620                        .value
621                        .ordinal()
622                        .checked_add(1)
623                        .ok_or(CircleTransitionError::SequenceOverflow)?,
624                    previous_control_hash: Some(chosen_control.coord.control_hash()),
625                    dependencies: vec![chosen_control.coord.clone()],
626                },
627                state: CircleControlState::ActiveEpoch(MergeActiveCircleEpoch {
628                    common: active_epoch.common.clone(),
629                    metadata,
630                    roster,
631                    store_membership,
632                    covered_control_heads,
633                }),
634                author_authority,
635                membership_authority,
636            },
637            author_pubkey,
638        };
639        let access = CircleAccessDraft::prepare(
640            store_root_hash,
641            candidate_family,
642            circle_id,
643            epoch_id,
644            keyring,
645            key_fingerprint,
646            &roster_state,
647            &chosen_roster.members(),
648            &control_value
649                .value
650                .state
651                .active_epoch()
652                .expect("control resolution constructs an active epoch")
653                .store_membership,
654            &store_members,
655            &std::collections::BTreeMap::new(),
656            ids,
657            signer,
658        )?;
659        control_value
660            .value
661            .state
662            .active_epoch_mut()
663            .expect("control resolution constructs an active epoch")
664            .common
665            .access_root = access.access_root();
666        let control_value = Signed::sign(control_value, signer);
667        let control = PreparedCircleControl {
668            coord: control_value.coord(),
669            bytes: serde_json::to_vec(&control_value)
670                .expect("circle control serialization cannot fail"),
671            value: control_value,
672        };
673        let access = access.finish(&control)?;
674        Ok(Self {
675            circle_id,
676            epoch_id,
677            keyring: keyring.to_string(),
678            roster: chosen_roster.clone(),
679            policy: CircleTransitionDraftPolicy {
680                roster: CircleRosterDraftPolicy::Inherited,
681                metadata_successor: false,
682            },
683            metadata: selected_metadata,
684            close_intent: None,
685            close_finalization: None,
686            close_cancellation: None,
687            access,
688            control,
689        })
690    }
691
692    /// Build the terminal deletion: a successor of the current control whose
693    /// state is `Deleted`, freezing the epoch spine for historical verification
694    /// and reclamation. It publishes no roster successor, metadata successor,
695    /// access material, or bootstraps — its control inherits the predecessor's
696    /// access root.
697    #[allow(clippy::too_many_arguments)]
698    pub fn delete(
699        device_id: &str,
700        store_membership: StoreMembershipStateRef,
701        membership_authority: MembershipGrantCreationAuthority,
702        store_members: Vec<(String, MemberRole)>,
703        current_control: &PreparedCircleControl,
704        current_roster: &CircleMaterializedRoster,
705        current_metadata: &CircleMetadata,
706        keyring: &str,
707        ids: &dyn coven_foundation::id_provider::IdProvider,
708        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
709    ) -> Result<Self, CircleTransitionError> {
710        let context = circle_delete_successor_context(
711            store_members,
712            current_control,
713            current_roster,
714            current_metadata,
715            keyring,
716            signer,
717        )?;
718        let CircleSuccessorContext {
719            store_members: _,
720            author_pubkey,
721            epoch,
722            grant_id,
723            author_authority,
724            key_fingerprint: _,
725        } = context;
726        let store_root_hash = current_control.value.store_root_hash;
727        let circle_id = current_control.value.circle_id;
728        let epoch_id = current_control.value.epoch_id();
729        let frozen_epoch = MergeActiveCircleEpoch {
730            common: epoch.common.clone(),
731            metadata: epoch.metadata.clone(),
732            roster: epoch.roster.clone(),
733            store_membership,
734            covered_control_heads: epoch.covered_control_heads.clone(),
735        };
736        let stream_id = epoch
737            .covered_control_heads
738            .iter()
739            .find(|head| head.coord.stream_key().author_pubkey == author_pubkey)
740            .map_or_else(
741                || {
742                    AuthorStreamId::from_digest(generated_id_digest(
743                        ids,
744                        b"coven.circle-transition-draft-stream.v1\0",
745                    ))
746                },
747                |head| head.coord.stream_id,
748            );
749        let control_value = CircleControlBody {
750            store_root_hash,
751            circle_id,
752            value: CircleControlValue {
753                order: MergeCircleControlOrder {
754                    device_id: device_id.to_string(),
755                    stream_id,
756                    author_owner_grant: grant_id,
757                    seq: current_control
758                        .value
759                        .ordinal()
760                        .checked_add(1)
761                        .ok_or(CircleTransitionError::SequenceOverflow)?,
762                    previous_control_hash: Some(current_control.coord.control_hash()),
763                    dependencies: vec![current_control.coord.clone()],
764                },
765                state: CircleControlState::Deleted(DeletedCircle { frozen_epoch }),
766                author_authority,
767                membership_authority,
768            },
769            author_pubkey,
770        };
771        let control_value = Signed::sign(control_value, signer);
772        let control = PreparedCircleControl {
773            coord: control_value.coord(),
774            bytes: serde_json::to_vec(&control_value)
775                .expect("circle control serialization cannot fail"),
776            value: control_value,
777        };
778        Ok(Self {
779            circle_id,
780            epoch_id,
781            keyring: keyring.to_string(),
782            roster: current_roster.clone(),
783            policy: CircleTransitionDraftPolicy {
784                roster: CircleRosterDraftPolicy::Inherited,
785                metadata_successor: false,
786            },
787            metadata: current_metadata.clone(),
788            close_intent: None,
789            close_finalization: None,
790            close_cancellation: None,
791            access: Vec::new(),
792            control,
793        })
794    }
795}