Skip to main content

coven_core/sync/
circle_control.rs

1//! Circle metadata, access records, controls, and creation objects.
2
3use hmac::{Hmac, Mac};
4use serde::{Deserialize, Serialize};
5use sha2::Sha256;
6
7use super::causal_grants::AuthorStreamId;
8use super::circle::CircleEpochCloseId;
9use super::circle::{generated_id_digest, AccessLeafId, CircleEpochId, CircleId};
10use super::circle_roster::{
11    CircleAuthorStreamKey, CircleGrantCreationAuthority, CircleMaterializedRoster,
12    CircleRosterChain, CircleRosterEntry, CircleRosterError, CircleRosterHead, CircleRosterHeadRef,
13    CircleRosterStateRef, MergeCircleRosterStateRef, ResolvedCircleRoster,
14};
15use super::membership::{MemberRole, MembershipGrantCreationAuthority, MembershipGrantId};
16use super::membership::{MembershipHeadRef, StoreMembershipConflictResolutionRef};
17use super::storage::ExactObjectRef;
18use super::store_commit::{
19    CommitFrontier, ObjectHash, OwnerRecoveryCursor, SnapshotImageRef, StoreBatchCommitRef,
20    StoreDeviceRegistration, StoreDeviceRegistrationRef, StoreDeviceStateRef, SuccessorLink,
21    STORE_PROTOCOL_VERSION,
22};
23use crate::encryption::{EncryptionService, KeyFingerprint, MasterKeyring};
24use crate::keys::{self, UserKeypair};
25use crate::storage::cloud::ObjectSlot;
26
27const RECIPIENT_SLOT_DOMAIN: &[u8] = b"coven.circle-recipient-slot.v1\0";
28const METADATA_DOMAIN: &str = "coven.circle-metadata.v1";
29const METADATA_HEAD_DOMAIN: &str = "coven.circle-metadata-head.v1";
30const ACCESS_DOMAIN: &str = "coven.circle-access-leaf.v1";
31const CONTROL_DOMAIN: &str = "coven.circle-control.v1";
32const ENVELOPE_DOMAIN: &str = "coven.circle-access-envelope.v1";
33const OWNER_GRANT_ID_GENERATION_DOMAIN: &[u8] = b"coven.circle-owner-grant-id-generation.v1\0";
34
35/// Exact coordinate of one signed circle control entry.
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct CircleControlCoord {
39    pub device_id: String,
40    pub stream_id: AuthorStreamId,
41    pub author_pubkey: String,
42    pub author_owner_grant: MembershipGrantId,
43    pub seq: u64,
44    pub control_hash: ObjectHash,
45}
46
47impl CircleControlCoord {
48    pub fn control_hash(&self) -> ObjectHash {
49        self.control_hash
50    }
51
52    pub fn validate(&self) -> Result<(), CircleControlCoordError> {
53        if self.device_id.is_empty() || self.author_pubkey.is_empty() || self.seq == 0 {
54            Err(CircleControlCoordError)
55        } else {
56            Ok(())
57        }
58    }
59
60    pub fn stream_key(&self) -> CircleAuthorStreamKey {
61        CircleAuthorStreamKey {
62            author_pubkey: self.author_pubkey.clone(),
63            device_id: self.device_id.clone(),
64            stream_id: self.stream_id,
65            author_owner_grant: self.author_owner_grant.clone(),
66        }
67    }
68
69    /// A well-formed coordinate that names no real control, for API dispatch tests
70    /// that only need a value to send through the command channel.
71    #[cfg(any(test, feature = "test-utils"))]
72    pub fn placeholder(seed: u8) -> Self {
73        let hash = ObjectHash::digest(&[seed]);
74        Self {
75            device_id: format!("device-{seed}"),
76            stream_id: AuthorStreamId::from_digest(hash),
77            author_pubkey: format!("pubkey-{seed}"),
78            author_owner_grant: MembershipGrantId(hash),
79            seq: 1,
80            control_hash: hash,
81        }
82    }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
86#[error("circle control coordinate has an empty device/author or zero sequence/generation")]
87pub struct CircleControlCoordError;
88
89/// The exact Store membership state whose identities require access dispositions.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct StoreMembershipStateRef {
93    pub heads: Vec<MembershipHeadRef>,
94    pub resolutions: Vec<StoreMembershipConflictResolutionRef>,
95    pub recovery: Vec<OwnerRecoveryCursor>,
96    pub state_hash: ObjectHash,
97}
98
99impl StoreMembershipStateRef {
100    pub fn from_parts(
101        mut heads: Vec<MembershipHeadRef>,
102        mut resolutions: Vec<StoreMembershipConflictResolutionRef>,
103        recovery: Vec<OwnerRecoveryCursor>,
104        membership_state_hash: ObjectHash,
105    ) -> Result<Self, super::store_commit::StoreProtocolError> {
106        heads.sort();
107        resolutions.sort();
108        let recovery = super::store_commit::canonical_recovery_cursors(recovery)?;
109        Ok(Self {
110            heads,
111            resolutions,
112            state_hash: membership_state_ref_hash(membership_state_hash, &recovery),
113            recovery,
114        })
115    }
116
117    pub fn state_hash(&self) -> ObjectHash {
118        self.state_hash
119    }
120
121    pub fn recovery(&self) -> &[OwnerRecoveryCursor] {
122        &self.recovery
123    }
124
125    pub(crate) fn validate_shape(&self) -> Result<(), super::store_commit::StoreProtocolError> {
126        super::store_commit::validate_recovery_cursors(self.recovery())?;
127        if self.heads.windows(2).any(|pair| pair[0] >= pair[1])
128            || self.resolutions.windows(2).any(|pair| pair[0] >= pair[1])
129        {
130            return Err(super::store_commit::StoreProtocolError::Malformed(
131                "Store membership state reference is not canonical".to_string(),
132            ));
133        }
134        Ok(())
135    }
136}
137
138fn membership_state_ref_hash(
139    membership_state_hash: ObjectHash,
140    recovery: &[OwnerRecoveryCursor],
141) -> ObjectHash {
142    ObjectHash::digest(
143        &serde_json::to_vec(&(
144            "coven.store-membership-state-ref.v1",
145            membership_state_hash,
146            recovery,
147        ))
148        .expect("Store membership state hash serialization cannot fail"),
149    )
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct CircleMetadataCoord {
155    pub author_pubkey: String,
156    pub device_id: String,
157    pub stream_id: AuthorStreamId,
158    pub author_owner_grant: MembershipGrantId,
159    pub seq: u64,
160    pub metadata_hash: ObjectHash,
161}
162
163impl CircleMetadataCoord {
164    pub fn stream_key(&self) -> CircleAuthorStreamKey {
165        CircleAuthorStreamKey {
166            author_pubkey: self.author_pubkey.clone(),
167            device_id: self.device_id.clone(),
168            stream_id: self.stream_id,
169            author_owner_grant: self.author_owner_grant.clone(),
170        }
171    }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct CircleMetadata {
177    pub version: u32,
178    pub store_root_hash: ObjectHash,
179    pub circle_id: CircleId,
180    pub epoch_id: CircleEpochId,
181    pub name: String,
182    pub seq: u64,
183    pub previous_hash: Option<ObjectHash>,
184    pub dependencies: Vec<CircleMetadataCoord>,
185    pub metadata_stamp: String,
186    pub author_pubkey: String,
187    pub device_id: String,
188    pub stream_id: AuthorStreamId,
189    pub author_owner_grant: MembershipGrantId,
190    pub author_roster: CircleRosterStateRef,
191    pub key_fingerprint: KeyFingerprint,
192    pub signature: String,
193}
194
195impl CircleMetadata {
196    fn founder(
197        store_root_hash: ObjectHash,
198        circle_id: CircleId,
199        epoch_id: CircleEpochId,
200        name: &str,
201        metadata_stamp: &str,
202        device_id: &str,
203        stream_id: AuthorStreamId,
204        owner_grant: MembershipGrantId,
205        author_roster: CircleRosterStateRef,
206        key_fingerprint: KeyFingerprint,
207        signer: &UserKeypair,
208    ) -> Result<Self, CircleTransitionError> {
209        if name.trim().is_empty() {
210            return Err(CircleTransitionError::EmptyName);
211        }
212        let author_pubkey = keys::public_key_hex(signer);
213        let mut metadata = Self {
214            version: STORE_PROTOCOL_VERSION,
215            store_root_hash,
216            circle_id,
217            epoch_id,
218            name: name.to_string(),
219            seq: 1,
220            previous_hash: None,
221            dependencies: Vec::new(),
222            metadata_stamp: metadata_stamp.to_string(),
223            author_pubkey,
224            device_id: device_id.to_string(),
225            stream_id,
226            author_owner_grant: owner_grant,
227            author_roster,
228            key_fingerprint,
229            signature: String::new(),
230        };
231        metadata.signature = keys::sign_hex(signer, &metadata.canonical_bytes()).1;
232        Ok(metadata)
233    }
234
235    pub(crate) fn canonical_bytes(&self) -> Vec<u8> {
236        #[derive(Serialize)]
237        struct Signed<'a> {
238            domain: &'static str,
239            version: u32,
240            store_root_hash: ObjectHash,
241            circle_id: CircleId,
242            epoch_id: CircleEpochId,
243            name: &'a str,
244            seq: u64,
245            previous_hash: Option<ObjectHash>,
246            dependencies: &'a [CircleMetadataCoord],
247            metadata_stamp: &'a str,
248            author_pubkey: &'a str,
249            device_id: &'a str,
250            stream_id: AuthorStreamId,
251            author_owner_grant: &'a MembershipGrantId,
252            author_roster: &'a CircleRosterStateRef,
253            key_fingerprint: KeyFingerprint,
254        }
255        serde_json::to_vec(&Signed {
256            domain: METADATA_DOMAIN,
257            version: self.version,
258            store_root_hash: self.store_root_hash,
259            circle_id: self.circle_id,
260            epoch_id: self.epoch_id,
261            name: &self.name,
262            seq: self.seq,
263            previous_hash: self.previous_hash,
264            dependencies: &self.dependencies,
265            metadata_stamp: &self.metadata_stamp,
266            author_pubkey: &self.author_pubkey,
267            device_id: &self.device_id,
268            stream_id: self.stream_id,
269            author_owner_grant: &self.author_owner_grant,
270            author_roster: &self.author_roster,
271            key_fingerprint: self.key_fingerprint,
272        })
273        .expect("circle metadata serialization cannot fail")
274    }
275
276    pub fn metadata_hash(&self) -> ObjectHash {
277        ObjectHash::digest(
278            &serde_json::to_vec(self).expect("circle metadata serialization cannot fail"),
279        )
280    }
281
282    pub fn coord(&self) -> CircleMetadataCoord {
283        CircleMetadataCoord {
284            author_pubkey: self.author_pubkey.clone(),
285            device_id: self.device_id.clone(),
286            stream_id: self.stream_id,
287            author_owner_grant: self.author_owner_grant.clone(),
288            seq: self.seq,
289            metadata_hash: self.metadata_hash(),
290        }
291    }
292
293    pub fn verify(&self) -> bool {
294        let position_is_valid = (self.seq == 1
295            && self.previous_hash.is_none()
296            && self
297                .dependencies
298                .iter()
299                .all(|dependency| dependency.stream_key() != self.coord().stream_key()))
300            || (self.seq > 1 && self.previous_hash.is_some());
301        self.version == STORE_PROTOCOL_VERSION
302            && !self.name.trim().is_empty()
303            && position_is_valid
304            && self
305                .dependencies
306                .windows(2)
307                .all(|pair| pair[0].stream_key() < pair[1].stream_key())
308            && keys::verify_signature_hex(
309                &self.author_pubkey,
310                &self.signature,
311                &self.canonical_bytes(),
312            )
313    }
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(deny_unknown_fields)]
318pub struct CircleMetadataHead {
319    pub version: u32,
320    pub store_root_hash: ObjectHash,
321    pub circle_id: CircleId,
322    pub author_pubkey: String,
323    pub device_id: String,
324    pub stream_id: AuthorStreamId,
325    pub author_owner_grant: MembershipGrantId,
326    pub seq: u64,
327    pub tip_hash: ObjectHash,
328    pub tip: ExactObjectRef,
329    pub successor: SuccessorLink,
330    pub signature: String,
331}
332
333impl CircleMetadataHead {
334    pub(crate) fn signed(
335        metadata: &CircleMetadata,
336        tip: ExactObjectRef,
337        successor: SuccessorLink,
338        signer: &UserKeypair,
339    ) -> Self {
340        let mut head = Self {
341            version: STORE_PROTOCOL_VERSION,
342            store_root_hash: metadata.store_root_hash,
343            circle_id: metadata.circle_id,
344            author_pubkey: metadata.author_pubkey.clone(),
345            device_id: metadata.device_id.clone(),
346            stream_id: metadata.stream_id,
347            author_owner_grant: metadata.author_owner_grant.clone(),
348            seq: metadata.seq,
349            tip_hash: metadata.metadata_hash(),
350            tip,
351            successor,
352            signature: String::new(),
353        };
354        head.signature = keys::sign_hex(signer, &head.canonical_bytes()).1;
355        head
356    }
357
358    pub(crate) fn canonical_bytes(&self) -> Vec<u8> {
359        #[derive(Serialize)]
360        struct Signed<'a> {
361            domain: &'static str,
362            version: u32,
363            store_root_hash: ObjectHash,
364            circle_id: CircleId,
365            author_pubkey: &'a str,
366            device_id: &'a str,
367            stream_id: AuthorStreamId,
368            author_owner_grant: &'a MembershipGrantId,
369            seq: u64,
370            tip_hash: ObjectHash,
371            tip: &'a ExactObjectRef,
372            successor: &'a SuccessorLink,
373        }
374        serde_json::to_vec(&Signed {
375            domain: METADATA_HEAD_DOMAIN,
376            version: self.version,
377            store_root_hash: self.store_root_hash,
378            circle_id: self.circle_id,
379            author_pubkey: &self.author_pubkey,
380            device_id: &self.device_id,
381            stream_id: self.stream_id,
382            author_owner_grant: &self.author_owner_grant,
383            seq: self.seq,
384            tip_hash: self.tip_hash,
385            tip: &self.tip,
386            successor: &self.successor,
387        })
388        .expect("circle metadata head serialization cannot fail")
389    }
390
391    pub fn head_hash(&self) -> ObjectHash {
392        ObjectHash::digest(
393            &serde_json::to_vec(self).expect("circle metadata head serialization cannot fail"),
394        )
395    }
396
397    pub fn coord(&self) -> CircleMetadataCoord {
398        CircleMetadataCoord {
399            author_pubkey: self.author_pubkey.clone(),
400            device_id: self.device_id.clone(),
401            stream_id: self.stream_id,
402            author_owner_grant: self.author_owner_grant.clone(),
403            seq: self.seq,
404            metadata_hash: self.tip_hash,
405        }
406    }
407
408    pub fn verify_for_registration(&self, registration: &StoreDeviceRegistration) -> bool {
409        self.version == STORE_PROTOCOL_VERSION
410            && self.seq > 0
411            && !self.device_id.is_empty()
412            && self.device_id == registration.device_id.to_string()
413            && keys::verify_signature_hex(
414                &registration.device_signing_pubkey,
415                &self.signature,
416                &self.canonical_bytes(),
417            )
418    }
419}
420
421#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423pub struct CircleMetadataHeadRef {
424    pub coord: CircleMetadataCoord,
425    pub head_hash: ObjectHash,
426    pub object: ExactObjectRef,
427}
428
429impl CircleMetadataHeadRef {
430    pub(crate) fn from_stored_head(head: &CircleMetadataHead, object: ExactObjectRef) -> Self {
431        Self {
432            coord: head.coord(),
433            head_hash: head.head_hash(),
434            object,
435        }
436    }
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
440#[serde(deny_unknown_fields)]
441pub struct MergeCircleMetadataStateRef {
442    pub heads: Vec<CircleMetadataHeadRef>,
443    pub selected: CircleMetadataCoord,
444    pub state_hash: ObjectHash,
445}
446
447pub type CircleMetadataStateRef = MergeCircleMetadataStateRef;
448
449/// Exact Circle database image offered when one recipient becomes active.
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(deny_unknown_fields)]
452pub struct CircleBootstrapRef {
453    pub coverage: CommitFrontier,
454    pub schema_version: u32,
455    pub sync_routing_hash: ObjectHash,
456    pub image: SnapshotImageRef,
457    pub blobs: Vec<crate::blob::RowBlobRef>,
458}
459
460impl CircleBootstrapRef {
461    pub(crate) fn verify_for_access(&self, access: &CircleAccessLeaf) -> bool {
462        if super::store_commit::validate_commit_frontier(&self.coverage).is_err() {
463            return false;
464        }
465        let blobs_are_canonical = self.blobs.windows(2).all(|pair| {
466            serde_json::to_vec(&pair[0]).expect("row blob reference serialization cannot fail")
467                < serde_json::to_vec(&pair[1])
468                    .expect("row blob reference serialization cannot fail")
469        });
470        if !blobs_are_canonical
471            || self.blobs.iter().any(|blob| {
472                !matches!(
473                    blob.authority(),
474                    crate::blob::RowBlobAuthority::Remote(
475                        super::audience_package::PackageAudience::Circle {
476                            circle_id,
477                            ..
478                        }
479                    ) if *circle_id == access.circle_id
480                ) || blob.stored().is_none_or(|stored| {
481                    stored.locator().audience()
482                        != crate::blob::locator::RemoteAudience::Circle(access.circle_id)
483                })
484            })
485        {
486            return false;
487        }
488        let semantic_prefix = super::store_commit::circle_bootstrap_image_semantic_prefix(
489            access.circle_id,
490            access.candidate_family,
491            &access.owner_pubkey,
492            access.epoch_id,
493            &access.recipient_slot,
494            self.image.image_hash,
495        );
496        self.image.object.slot().logical_key() == format!("{semantic_prefix}.db")
497    }
498}
499
500/// The exact retained bootstrap coverage a recipient device's live Circle
501/// projection was seeded from: the activating Store commit, the control it
502/// activated under, and the bootstrap reference (its exact cut and image hash
503/// live inside that reference, not re-declared here). Names one row of
504/// `circle_bootstrap_coverage`.
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(deny_unknown_fields)]
507pub struct CircleBootstrapCoverageRef {
508    pub circle_id: CircleId,
509    pub control: CircleControlCoord,
510    pub activation_commit: StoreBatchCommitRef,
511    pub bootstrap: CircleBootstrapRef,
512}
513
514#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
515#[serde(rename_all = "snake_case", deny_unknown_fields)]
516pub enum CircleAccessDisposition {
517    Active {
518        keyring: String,
519        key_fingerprint: KeyFingerprint,
520        roster: CircleRosterStateRef,
521        bootstrap: Option<CircleBootstrapRef>,
522    },
523    Inactive,
524}
525
526#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
527#[serde(deny_unknown_fields)]
528pub struct CircleAccessLeaf {
529    pub version: u32,
530    pub store_root_hash: ObjectHash,
531    pub candidate_family: super::store_commit::CandidateFamilyId,
532    pub circle_id: CircleId,
533    pub epoch_id: CircleEpochId,
534    pub leaf_id: AccessLeafId,
535    pub owner_pubkey: String,
536    pub recipient_pubkey: String,
537    pub recipient_slot: String,
538    pub disposition: CircleAccessDisposition,
539    pub store_membership: StoreMembershipStateRef,
540    pub signature: String,
541}
542
543impl CircleAccessLeaf {
544    pub(crate) fn canonical_bytes(&self) -> Vec<u8> {
545        #[derive(Serialize)]
546        struct Signed<'a> {
547            domain: &'static str,
548            version: u32,
549            store_root_hash: ObjectHash,
550            candidate_family: super::store_commit::CandidateFamilyId,
551            circle_id: CircleId,
552            epoch_id: CircleEpochId,
553            leaf_id: AccessLeafId,
554            owner_pubkey: &'a str,
555            recipient_pubkey: &'a str,
556            recipient_slot: &'a str,
557            disposition: &'a CircleAccessDisposition,
558            store_membership: &'a StoreMembershipStateRef,
559        }
560        serde_json::to_vec(&Signed {
561            domain: ACCESS_DOMAIN,
562            version: self.version,
563            store_root_hash: self.store_root_hash,
564            candidate_family: self.candidate_family,
565            circle_id: self.circle_id,
566            epoch_id: self.epoch_id,
567            leaf_id: self.leaf_id,
568            owner_pubkey: &self.owner_pubkey,
569            recipient_pubkey: &self.recipient_pubkey,
570            recipient_slot: &self.recipient_slot,
571            disposition: &self.disposition,
572            store_membership: &self.store_membership,
573        })
574        .expect("circle access serialization cannot fail")
575    }
576
577    pub fn verify_signature(&self) -> bool {
578        self.version == STORE_PROTOCOL_VERSION
579            && keys::verify_signature_hex(
580                &self.owner_pubkey,
581                &self.signature,
582                &self.canonical_bytes(),
583            )
584    }
585
586    pub(crate) fn verify_for_control(
587        &self,
588        control: &PreparedCircleControl,
589        candidate_family: super::store_commit::CandidateFamilyId,
590    ) -> bool {
591        self.verify_signature()
592            && self.store_root_hash == control.value.store_root_hash
593            && self.candidate_family == candidate_family
594            && self.circle_id == control.value.circle_id
595            && self.epoch_id == control.value.epoch_id()
596            && self.store_membership == control.value.store_membership_state_ref()
597            && match &self.disposition {
598                CircleAccessDisposition::Active {
599                    keyring,
600                    key_fingerprint,
601                    roster,
602                    bootstrap,
603                } => {
604                    roster == &control.value.roster_state_ref()
605                        && *key_fingerprint == control.value.key_fingerprint()
606                        && MasterKeyring::from_serialized(keyring).is_ok_and(|keyring| {
607                            EncryptionService::from(keyring).seal_key_fingerprint()
608                                == *key_fingerprint
609                        })
610                        && bootstrap
611                            .as_ref()
612                            .is_none_or(|bootstrap| bootstrap.verify_for_access(self))
613                }
614                CircleAccessDisposition::Inactive => true,
615            }
616            && self.owner_pubkey == control.value.author_pubkey
617    }
618}
619
620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
621#[serde(rename_all = "snake_case", deny_unknown_fields)]
622pub enum MerkleStep {
623    Left(ObjectHash),
624    Right(ObjectHash),
625}
626
627fn merkle_parent(left: ObjectHash, right: ObjectHash) -> ObjectHash {
628    let mut bytes = Vec::with_capacity(1 + 64);
629    bytes.push(1);
630    bytes.extend_from_slice(left.as_bytes());
631    bytes.extend_from_slice(right.as_bytes());
632    ObjectHash::digest(&bytes)
633}
634
635pub(crate) fn verify_merkle_proof(
636    mut hash: ObjectHash,
637    proof: &[MerkleStep],
638    root: ObjectHash,
639) -> bool {
640    for step in proof {
641        hash = match step {
642            MerkleStep::Left(left) => merkle_parent(*left, hash),
643            MerkleStep::Right(right) => merkle_parent(hash, *right),
644        };
645    }
646    hash == root
647}
648
649pub(crate) fn merkle_root_and_proofs(hashes: &[ObjectHash]) -> (ObjectHash, Vec<Vec<MerkleStep>>) {
650    assert!(
651        !hashes.is_empty(),
652        "a circle control has at least one access leaf"
653    );
654    let mut indexed = hashes
655        .iter()
656        .copied()
657        .enumerate()
658        .collect::<Vec<(usize, ObjectHash)>>();
659    indexed.sort_by_key(|(index, hash)| (*hash, *index));
660    let mut proofs = vec![Vec::new(); hashes.len()];
661    let mut layer = indexed
662        .into_iter()
663        .map(|(index, hash)| (hash, vec![index]))
664        .collect::<Vec<_>>();
665    while layer.len() > 1 {
666        let mut next = Vec::with_capacity(layer.len().div_ceil(2));
667        for pair in layer.chunks(2) {
668            let (left_hash, left_indices) = &pair[0];
669            if let Some((right_hash, right_indices)) = pair.get(1) {
670                for index in left_indices {
671                    proofs[*index].push(MerkleStep::Right(*right_hash));
672                }
673                for index in right_indices {
674                    proofs[*index].push(MerkleStep::Left(*left_hash));
675                }
676                let mut indices = left_indices.clone();
677                indices.extend(right_indices);
678                next.push((merkle_parent(*left_hash, *right_hash), indices));
679            } else {
680                for index in left_indices {
681                    proofs[*index].push(MerkleStep::Right(*left_hash));
682                }
683                next.push((merkle_parent(*left_hash, *left_hash), left_indices.clone()));
684            }
685        }
686        layer = next;
687    }
688    (layer[0].0, proofs)
689}
690
691#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
692#[serde(deny_unknown_fields)]
693pub struct MergeCircleControlOrder {
694    pub device_id: String,
695    pub stream_id: AuthorStreamId,
696    pub author_owner_grant: MembershipGrantId,
697    pub seq: u64,
698    pub previous_control_hash: Option<ObjectHash>,
699    pub dependencies: Vec<CircleControlCoord>,
700}
701
702#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703#[serde(deny_unknown_fields)]
704pub struct ActiveCircleEpochCore {
705    pub epoch_id: CircleEpochId,
706    pub key_fingerprint: KeyFingerprint,
707    pub owners: Vec<String>,
708    pub access_root: ObjectHash,
709    pub origin: CircleEpochOrigin,
710}
711
712#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
713#[serde(rename_all = "snake_case", deny_unknown_fields)]
714pub enum CircleEpochOrigin {
715    Founder,
716    Closed {
717        closed_epoch_id: CircleEpochId,
718        close_control: CircleControlCoord,
719        close_id: CircleEpochCloseId,
720        outcome_hash: ObjectHash,
721        cutoff: CommitFrontier,
722    },
723}
724
725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
726#[serde(deny_unknown_fields)]
727pub struct MergeActiveCircleEpoch {
728    pub common: ActiveCircleEpochCore,
729    pub metadata: MergeCircleMetadataStateRef,
730    pub roster: MergeCircleRosterStateRef,
731    pub store_membership: StoreMembershipStateRef,
732    pub covered_control_heads: Vec<MergeCircleControlHeadRef>,
733}
734
735#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
736#[serde(deny_unknown_fields)]
737pub struct CircleEpochCloseParticipant {
738    pub registration: StoreDeviceRegistrationRef,
739    pub response_slot: ObjectSlot,
740}
741
742#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
743#[serde(deny_unknown_fields)]
744pub struct CircleEpochCloseIntent {
745    pub version: u32,
746    pub store_root_hash: ObjectHash,
747    pub circle_id: CircleId,
748    pub close_id: CircleEpochCloseId,
749    pub epoch_id: CircleEpochId,
750    pub predecessor_roster: MergeCircleRosterStateRef,
751    pub removal: CircleRosterEntry,
752    pub remaining_roster_state_hash: ObjectHash,
753    pub owner_pubkey: String,
754    pub signature: String,
755}
756
757impl CircleEpochCloseIntent {
758    #[allow(clippy::too_many_arguments)]
759    pub(crate) fn signed(
760        store_root_hash: ObjectHash,
761        circle_id: CircleId,
762        close_id: CircleEpochCloseId,
763        epoch_id: CircleEpochId,
764        predecessor_roster: MergeCircleRosterStateRef,
765        removal: CircleRosterEntry,
766        remaining_roster_state_hash: ObjectHash,
767        signer: &UserKeypair,
768    ) -> Result<Self, CircleTransitionError> {
769        let owner_pubkey = keys::public_key_hex(signer);
770        let mut intent = Self {
771            version: STORE_PROTOCOL_VERSION,
772            store_root_hash,
773            circle_id,
774            close_id,
775            epoch_id,
776            predecessor_roster,
777            removal,
778            remaining_roster_state_hash,
779            owner_pubkey,
780            signature: String::new(),
781        };
782        if !intent.verify_shape() {
783            return Err(CircleTransitionError::InvalidCurrentState);
784        }
785        intent.signature = keys::sign_hex(signer, &intent.canonical_bytes()).1;
786        Ok(intent)
787    }
788
789    fn canonical_bytes(&self) -> Vec<u8> {
790        #[derive(Serialize)]
791        struct Signed<'a> {
792            domain: &'static str,
793            version: u32,
794            store_root_hash: ObjectHash,
795            circle_id: CircleId,
796            close_id: CircleEpochCloseId,
797            epoch_id: CircleEpochId,
798            predecessor_roster: &'a MergeCircleRosterStateRef,
799            removal: &'a CircleRosterEntry,
800            remaining_roster_state_hash: ObjectHash,
801            owner_pubkey: &'a str,
802        }
803        serde_json::to_vec(&Signed {
804            domain: "coven.circle-epoch-close-intent.v1",
805            version: self.version,
806            store_root_hash: self.store_root_hash,
807            circle_id: self.circle_id,
808            close_id: self.close_id,
809            epoch_id: self.epoch_id,
810            predecessor_roster: &self.predecessor_roster,
811            removal: &self.removal,
812            remaining_roster_state_hash: self.remaining_roster_state_hash,
813            owner_pubkey: &self.owner_pubkey,
814        })
815        .expect("Circle epoch-close intent serialization cannot fail")
816    }
817
818    fn verify_shape(&self) -> bool {
819        self.version == STORE_PROTOCOL_VERSION
820            && self.removal.verify()
821            && self.removal.store_root_hash == self.store_root_hash
822            && self.removal.circle_id == self.circle_id
823            && self.removal.author_pubkey == self.owner_pubkey
824            && matches!(
825                self.removal.change,
826                super::circle_roster::CircleRosterChange::RemoveMember { .. }
827            )
828    }
829
830    pub fn verify(&self) -> bool {
831        self.verify_shape()
832            && keys::verify_signature_hex(
833                &self.owner_pubkey,
834                &self.signature,
835                &self.canonical_bytes(),
836            )
837    }
838
839    pub fn intent_hash(&self) -> ObjectHash {
840        ObjectHash::digest(
841            &serde_json::to_vec(self).expect("Circle epoch-close intent serialization cannot fail"),
842        )
843    }
844}
845
846#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
847#[serde(deny_unknown_fields)]
848pub struct CircleEpochCloseIntentRef {
849    pub close_id: CircleEpochCloseId,
850    pub intent_hash: ObjectHash,
851    pub object: ExactObjectRef,
852}
853
854impl CircleEpochCloseIntentRef {
855    pub(crate) fn from_intent(
856        intent: &CircleEpochCloseIntent,
857        object: ExactObjectRef,
858    ) -> Result<Self, CircleTransitionError> {
859        let reference = Self {
860            close_id: intent.close_id,
861            intent_hash: intent.intent_hash(),
862            object,
863        };
864        if reference.object.slot().logical_key()
865            != format!(
866                "{}.json",
867                circle_epoch_close_intent_semantic_prefix(
868                    intent.circle_id,
869                    intent.close_id,
870                    intent.intent_hash(),
871                )
872            )
873        {
874            return Err(CircleTransitionError::InvalidCurrentState);
875        }
876        Ok(reference)
877    }
878}
879
880#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
881#[serde(deny_unknown_fields)]
882pub struct CircleEpochClose {
883    pub close_id: CircleEpochCloseId,
884    pub frozen_epoch: MergeActiveCircleEpoch,
885    pub intent: CircleEpochCloseIntentRef,
886    pub frozen_device_state: StoreDeviceStateRef,
887    pub participants: Vec<CircleEpochCloseParticipant>,
888    pub provisional_frontier: CommitFrontier,
889    pub outcome_slot: ObjectSlot,
890}
891
892impl CircleEpochClose {
893    fn verify_shape(&self, circle_id: CircleId) -> bool {
894        super::store_commit::validate_commit_frontier(&self.provisional_frontier).is_ok()
895            && self.intent.close_id == self.close_id
896            && !self.participants.is_empty()
897            && self
898                .participants
899                .windows(2)
900                .all(|pair| pair[0].registration.device_id < pair[1].registration.device_id)
901            && self.participants.iter().all(|participant| {
902                participant.response_slot.logical_key()
903                    == format!(
904                        "{}.json",
905                        circle_epoch_close_response_semantic_prefix(
906                            circle_id,
907                            self.close_id,
908                            participant.registration.device_id,
909                        )
910                    )
911            })
912            && self.outcome_slot.logical_key()
913                == format!(
914                    "{}.json",
915                    circle_epoch_close_outcome_semantic_prefix(circle_id, self.close_id)
916                )
917    }
918}
919
920#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
921#[serde(deny_unknown_fields)]
922pub struct CircleEpochCloseResponse {
923    pub version: u32,
924    pub store_root_hash: ObjectHash,
925    pub circle_id: CircleId,
926    pub close_id: CircleEpochCloseId,
927    pub close_control: CircleControlCoord,
928    pub registration: StoreDeviceRegistrationRef,
929    pub frontier: CommitFrontier,
930    pub signature: String,
931}
932
933impl CircleEpochCloseResponse {
934    pub(crate) fn signed(
935        control: &PreparedCircleControl,
936        registration: StoreDeviceRegistrationRef,
937        frontier: CommitFrontier,
938        author: &StoreDeviceRegistration,
939        signer: &UserKeypair,
940    ) -> Result<Self, CircleTransitionError> {
941        let CircleControlState::EpochClose(close) = control.value.state() else {
942            return Err(CircleTransitionError::InvalidCurrentState);
943        };
944        let mut response = Self {
945            version: STORE_PROTOCOL_VERSION,
946            store_root_hash: control.value.store_root_hash,
947            circle_id: control.value.circle_id,
948            close_id: close.close_id,
949            close_control: control.coord.clone(),
950            registration,
951            frontier,
952            signature: String::new(),
953        };
954        response.signature = keys::sign_hex(signer, &response.canonical_bytes()).1;
955        if !response.verify_for(control, author) {
956            return Err(CircleTransitionError::InvalidCurrentState);
957        }
958        Ok(response)
959    }
960
961    fn canonical_bytes(&self) -> Vec<u8> {
962        #[derive(Serialize)]
963        struct Signed<'a> {
964            domain: &'static str,
965            version: u32,
966            store_root_hash: ObjectHash,
967            circle_id: CircleId,
968            close_id: CircleEpochCloseId,
969            close_control: &'a CircleControlCoord,
970            registration: &'a StoreDeviceRegistrationRef,
971            frontier: &'a CommitFrontier,
972        }
973        serde_json::to_vec(&Signed {
974            domain: "coven.circle-epoch-close-response.v1",
975            version: self.version,
976            store_root_hash: self.store_root_hash,
977            circle_id: self.circle_id,
978            close_id: self.close_id,
979            close_control: &self.close_control,
980            registration: &self.registration,
981            frontier: &self.frontier,
982        })
983        .expect("Circle epoch-close response serialization cannot fail")
984    }
985
986    pub(crate) fn verify_for(
987        &self,
988        control: &PreparedCircleControl,
989        author: &StoreDeviceRegistration,
990    ) -> bool {
991        let CircleControlState::EpochClose(close) = control.value.state() else {
992            return false;
993        };
994        self.version == STORE_PROTOCOL_VERSION
995            && control.verify()
996            && self.store_root_hash == control.value.store_root_hash
997            && self.circle_id == control.value.circle_id
998            && self.close_id == close.close_id
999            && self.close_control == control.coord
1000            && super::store_commit::validate_commit_frontier(&self.frontier).is_ok()
1001            && self.frontier.covers(&close.provisional_frontier)
1002            && self.registration.verify_registration(author).is_ok()
1003            && author.store_root.store_root_hash == self.store_root_hash
1004            && close
1005                .participants
1006                .iter()
1007                .any(|participant| participant.registration == self.registration)
1008            && keys::verify_signature_hex(
1009                &author.device_signing_pubkey,
1010                &self.signature,
1011                &self.canonical_bytes(),
1012            )
1013    }
1014
1015    pub fn response_hash(&self) -> ObjectHash {
1016        ObjectHash::digest(
1017            &serde_json::to_vec(self)
1018                .expect("Circle epoch-close response serialization cannot fail"),
1019        )
1020    }
1021}
1022
1023#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1024#[serde(deny_unknown_fields)]
1025pub struct CircleEpochCloseResponseRef {
1026    pub registration: StoreDeviceRegistrationRef,
1027    pub frontier: CommitFrontier,
1028    pub response_hash: ObjectHash,
1029    pub object: ExactObjectRef,
1030}
1031
1032impl CircleEpochCloseResponseRef {
1033    pub(crate) fn from_response(
1034        response: &CircleEpochCloseResponse,
1035        object: ExactObjectRef,
1036    ) -> Result<Self, CircleTransitionError> {
1037        if object.slot().logical_key()
1038            != format!(
1039                "{}.json",
1040                circle_epoch_close_response_semantic_prefix(
1041                    response.circle_id,
1042                    response.close_id,
1043                    response.registration.device_id,
1044                )
1045            )
1046        {
1047            return Err(CircleTransitionError::InvalidCurrentState);
1048        }
1049        Ok(Self {
1050            registration: response.registration.clone(),
1051            frontier: response.frontier.clone(),
1052            response_hash: response.response_hash(),
1053            object,
1054        })
1055    }
1056
1057    pub(crate) fn verify_response(&self, response: &CircleEpochCloseResponse) -> bool {
1058        self.registration == response.registration
1059            && self.frontier == response.frontier
1060            && self.response_hash == response.response_hash()
1061    }
1062}
1063
1064/// One Owner-signed exclusion of an unavailable participant device. It competes
1065/// at that device's create-once response slot; activating it excludes the device
1066/// from the close cutoff and forces it to reset from the successor bootstrap.
1067#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1068#[serde(deny_unknown_fields)]
1069pub struct CircleEpochCloseExclusion {
1070    pub version: u32,
1071    pub store_root_hash: ObjectHash,
1072    pub circle_id: CircleId,
1073    pub close_id: CircleEpochCloseId,
1074    pub close_control: CircleControlCoord,
1075    pub excluded: StoreDeviceRegistrationRef,
1076    pub owner_pubkey: String,
1077    pub signature: String,
1078}
1079
1080impl CircleEpochCloseExclusion {
1081    pub(crate) fn signed(
1082        control: &PreparedCircleControl,
1083        excluded: StoreDeviceRegistrationRef,
1084        signer: &UserKeypair,
1085    ) -> Result<Self, CircleTransitionError> {
1086        let CircleControlState::EpochClose(close) = control.value.state() else {
1087            return Err(CircleTransitionError::InvalidCurrentState);
1088        };
1089        let mut exclusion = Self {
1090            version: STORE_PROTOCOL_VERSION,
1091            store_root_hash: control.value.store_root_hash,
1092            circle_id: control.value.circle_id,
1093            close_id: close.close_id,
1094            close_control: control.coord.clone(),
1095            excluded,
1096            owner_pubkey: keys::public_key_hex(signer),
1097            signature: String::new(),
1098        };
1099        if !exclusion.verify_shape(control) {
1100            return Err(CircleTransitionError::InvalidCurrentState);
1101        }
1102        exclusion.signature = keys::sign_hex(signer, &exclusion.canonical_bytes()).1;
1103        Ok(exclusion)
1104    }
1105
1106    fn canonical_bytes(&self) -> Vec<u8> {
1107        #[derive(Serialize)]
1108        struct Signed<'a> {
1109            domain: &'static str,
1110            version: u32,
1111            store_root_hash: ObjectHash,
1112            circle_id: CircleId,
1113            close_id: CircleEpochCloseId,
1114            close_control: &'a CircleControlCoord,
1115            excluded: &'a StoreDeviceRegistrationRef,
1116            owner_pubkey: &'a str,
1117        }
1118        serde_json::to_vec(&Signed {
1119            domain: "coven.circle-epoch-close-exclusion.v1",
1120            version: self.version,
1121            store_root_hash: self.store_root_hash,
1122            circle_id: self.circle_id,
1123            close_id: self.close_id,
1124            close_control: &self.close_control,
1125            excluded: &self.excluded,
1126            owner_pubkey: &self.owner_pubkey,
1127        })
1128        .expect("Circle epoch-close exclusion serialization cannot fail")
1129    }
1130
1131    fn verify_shape(&self, control: &PreparedCircleControl) -> bool {
1132        let CircleControlState::EpochClose(close) = control.value.state() else {
1133            return false;
1134        };
1135        self.version == STORE_PROTOCOL_VERSION
1136            && control.verify()
1137            && self.store_root_hash == control.value.store_root_hash
1138            && self.circle_id == control.value.circle_id
1139            && self.close_id == close.close_id
1140            && self.close_control == control.coord
1141            && close
1142                .participants
1143                .iter()
1144                .any(|participant| participant.registration == self.excluded)
1145            && close
1146                .frozen_epoch
1147                .common
1148                .owners
1149                .contains(&self.owner_pubkey)
1150    }
1151
1152    pub(crate) fn verify_for(&self, control: &PreparedCircleControl) -> bool {
1153        self.verify_shape(control)
1154            && keys::verify_signature_hex(
1155                &self.owner_pubkey,
1156                &self.signature,
1157                &self.canonical_bytes(),
1158            )
1159    }
1160
1161    pub fn exclusion_hash(&self) -> ObjectHash {
1162        ObjectHash::digest(
1163            &serde_json::to_vec(self)
1164                .expect("Circle epoch-close exclusion serialization cannot fail"),
1165        )
1166    }
1167}
1168
1169#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1170#[serde(deny_unknown_fields)]
1171pub struct CircleEpochCloseExclusionRef {
1172    pub registration: StoreDeviceRegistrationRef,
1173    pub exclusion_hash: ObjectHash,
1174    pub object: ExactObjectRef,
1175}
1176
1177impl CircleEpochCloseExclusionRef {
1178    pub(crate) fn from_exclusion(
1179        exclusion: &CircleEpochCloseExclusion,
1180        object: ExactObjectRef,
1181    ) -> Result<Self, CircleTransitionError> {
1182        if object.slot().logical_key()
1183            != format!(
1184                "{}.json",
1185                circle_epoch_close_response_semantic_prefix(
1186                    exclusion.circle_id,
1187                    exclusion.close_id,
1188                    exclusion.excluded.device_id,
1189                )
1190            )
1191        {
1192            return Err(CircleTransitionError::InvalidCurrentState);
1193        }
1194        Ok(Self {
1195            registration: exclusion.excluded.clone(),
1196            exclusion_hash: exclusion.exclusion_hash(),
1197            object,
1198        })
1199    }
1200
1201    pub(crate) fn verify_exclusion(&self, exclusion: &CircleEpochCloseExclusion) -> bool {
1202        self.registration == exclusion.excluded && self.exclusion_hash == exclusion.exclusion_hash()
1203    }
1204}
1205
1206/// The exactly-one value a participant's create-once close-response slot holds:
1207/// the device's own signed response, or an Owner-signed exclusion of that device.
1208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1209#[serde(rename_all = "snake_case", deny_unknown_fields)]
1210pub enum CircleEpochCloseResponseSlotValue {
1211    Response(CircleEpochCloseResponse),
1212    Exclusion(CircleEpochCloseExclusion),
1213}
1214
1215impl CircleEpochCloseResponseSlotValue {
1216    pub(crate) fn to_bytes(&self) -> Vec<u8> {
1217        serde_json::to_vec(self)
1218            .expect("Circle epoch-close response slot value serialization cannot fail")
1219    }
1220
1221    pub(crate) fn parse(bytes: &[u8]) -> Result<Self, CircleTransitionError> {
1222        let value: Self = serde_json::from_slice(bytes)
1223            .map_err(|_| CircleTransitionError::InvalidCurrentState)?;
1224        if value.to_bytes() != bytes {
1225            return Err(CircleTransitionError::InvalidCurrentState);
1226        }
1227        Ok(value)
1228    }
1229}
1230
1231/// One participant's contribution to a close outcome: either its verified device
1232/// response (whose frontier joins the cutoff) or an Owner exclusion (which does
1233/// not). The outcome carries one per participant.
1234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1235#[serde(rename_all = "snake_case", deny_unknown_fields)]
1236pub enum CircleEpochCloseSettlement {
1237    Response(CircleEpochCloseResponseRef),
1238    Exclusion(CircleEpochCloseExclusionRef),
1239}
1240
1241impl CircleEpochCloseSettlement {
1242    pub(crate) fn registration(&self) -> &StoreDeviceRegistrationRef {
1243        match self {
1244            Self::Response(reference) => &reference.registration,
1245            Self::Exclusion(reference) => &reference.registration,
1246        }
1247    }
1248
1249    pub(crate) fn object(&self) -> &ExactObjectRef {
1250        match self {
1251            Self::Response(reference) => &reference.object,
1252            Self::Exclusion(reference) => &reference.object,
1253        }
1254    }
1255
1256    pub(crate) fn response_frontier(&self) -> Option<&CommitFrontier> {
1257        match self {
1258            Self::Response(reference) => Some(&reference.frontier),
1259            Self::Exclusion(_) => None,
1260        }
1261    }
1262}
1263
1264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1265#[serde(deny_unknown_fields)]
1266pub struct CircleEpochSuccessor {
1267    pub epoch_id: CircleEpochId,
1268    pub key_fingerprint: KeyFingerprint,
1269    pub owners: Vec<String>,
1270    pub access_root: ObjectHash,
1271    pub metadata: MergeCircleMetadataStateRef,
1272    pub roster: MergeCircleRosterStateRef,
1273    pub store_membership: StoreMembershipStateRef,
1274}
1275
1276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1277#[serde(deny_unknown_fields)]
1278pub struct CircleEpochCloseOutcome {
1279    pub version: u32,
1280    pub store_root_hash: ObjectHash,
1281    pub circle_id: CircleId,
1282    pub close_id: CircleEpochCloseId,
1283    pub close_control: CircleControlCoord,
1284    pub intent: CircleEpochCloseIntentRef,
1285    pub responses: Vec<CircleEpochCloseSettlement>,
1286    pub cutoff: CommitFrontier,
1287    pub successor: CircleEpochSuccessor,
1288    pub owner_pubkey: String,
1289    pub signature: String,
1290}
1291
1292impl CircleEpochCloseOutcome {
1293    pub(crate) fn signed(
1294        control: &PreparedCircleControl,
1295        intent: &CircleEpochCloseIntent,
1296        responses: Vec<CircleEpochCloseSettlement>,
1297        successor: CircleEpochSuccessor,
1298        signer: &UserKeypair,
1299    ) -> Result<Self, CircleTransitionError> {
1300        let CircleControlState::EpochClose(close) = control.value.state() else {
1301            return Err(CircleTransitionError::InvalidCurrentState);
1302        };
1303        let cutoff = responses
1304            .iter()
1305            .filter_map(CircleEpochCloseSettlement::response_frontier)
1306            .try_fold(close.provisional_frontier.clone(), |cutoff, frontier| {
1307                cutoff.join(frontier.clone())
1308            })
1309            .map_err(|_| CircleTransitionError::InvalidCurrentState)?;
1310        let mut outcome = Self {
1311            version: STORE_PROTOCOL_VERSION,
1312            store_root_hash: control.value.store_root_hash,
1313            circle_id: control.value.circle_id,
1314            close_id: close.close_id,
1315            close_control: control.coord.clone(),
1316            intent: close.intent.clone(),
1317            responses,
1318            cutoff,
1319            successor,
1320            owner_pubkey: keys::public_key_hex(signer),
1321            signature: String::new(),
1322        };
1323        if !outcome.verify_shape(control) || !outcome.verify_intent(intent) {
1324            return Err(CircleTransitionError::InvalidCurrentState);
1325        }
1326        outcome.signature = keys::sign_hex(signer, &outcome.canonical_bytes()).1;
1327        Ok(outcome)
1328    }
1329
1330    fn canonical_bytes(&self) -> Vec<u8> {
1331        #[derive(Serialize)]
1332        struct Signed<'a> {
1333            domain: &'static str,
1334            version: u32,
1335            store_root_hash: ObjectHash,
1336            circle_id: CircleId,
1337            close_id: CircleEpochCloseId,
1338            close_control: &'a CircleControlCoord,
1339            intent: &'a CircleEpochCloseIntentRef,
1340            responses: &'a [CircleEpochCloseSettlement],
1341            cutoff: &'a CommitFrontier,
1342            successor: &'a CircleEpochSuccessor,
1343            owner_pubkey: &'a str,
1344        }
1345        serde_json::to_vec(&Signed {
1346            domain: "coven.circle-epoch-close-outcome.v1",
1347            version: self.version,
1348            store_root_hash: self.store_root_hash,
1349            circle_id: self.circle_id,
1350            close_id: self.close_id,
1351            close_control: &self.close_control,
1352            intent: &self.intent,
1353            responses: &self.responses,
1354            cutoff: &self.cutoff,
1355            successor: &self.successor,
1356            owner_pubkey: &self.owner_pubkey,
1357        })
1358        .expect("Circle epoch-close outcome serialization cannot fail")
1359    }
1360
1361    fn verify_shape(&self, control: &PreparedCircleControl) -> bool {
1362        let CircleControlState::EpochClose(close) = control.value.state() else {
1363            return false;
1364        };
1365        let responses_are_canonical = self
1366            .responses
1367            .windows(2)
1368            .all(|pair| pair[0].registration().device_id < pair[1].registration().device_id);
1369        let responses_match_participants =
1370            self.responses.len() == close.participants.len()
1371                && self.responses.iter().zip(&close.participants).all(
1372                    |(settlement, participant)| {
1373                        settlement.registration() == &participant.registration
1374                            && settlement.object().slot() == &participant.response_slot
1375                            && settlement
1376                                .response_frontier()
1377                                .is_none_or(|frontier| frontier.covers(&close.provisional_frontier))
1378                    },
1379                );
1380        let expected_cutoff = self
1381            .responses
1382            .iter()
1383            .filter_map(CircleEpochCloseSettlement::response_frontier)
1384            .try_fold(close.provisional_frontier.clone(), |cutoff, frontier| {
1385                cutoff.join(frontier.clone())
1386            });
1387        self.version == STORE_PROTOCOL_VERSION
1388            && control.verify()
1389            && self.store_root_hash == control.value.store_root_hash
1390            && self.circle_id == control.value.circle_id
1391            && self.close_id == close.close_id
1392            && self.close_control == control.coord
1393            && self.intent == close.intent
1394            && responses_are_canonical
1395            && responses_match_participants
1396            && expected_cutoff.is_ok_and(|cutoff| cutoff == self.cutoff)
1397            && super::store_commit::validate_commit_frontier(&self.cutoff).is_ok()
1398            && self.successor.epoch_id != close.frozen_epoch.common.epoch_id
1399            && self.successor.key_fingerprint != close.frozen_epoch.common.key_fingerprint
1400            && !self.successor.owners.is_empty()
1401            && self
1402                .successor
1403                .owners
1404                .windows(2)
1405                .all(|pair| pair[0] < pair[1])
1406            && close
1407                .frozen_epoch
1408                .common
1409                .owners
1410                .contains(&self.owner_pubkey)
1411    }
1412
1413    fn verify_intent(&self, intent: &CircleEpochCloseIntent) -> bool {
1414        intent.verify()
1415            && intent.close_id == self.close_id
1416            && intent.circle_id == self.circle_id
1417            && intent.store_root_hash == self.store_root_hash
1418            && intent.intent_hash() == self.intent.intent_hash
1419            && self.successor.roster.state_hash == intent.remaining_roster_state_hash
1420    }
1421
1422    pub(crate) fn verify_for(
1423        &self,
1424        control: &PreparedCircleControl,
1425        intent: &CircleEpochCloseIntent,
1426        settlements: &[(
1427            CircleEpochCloseSettlement,
1428            CircleEpochCloseResponseSlotValue,
1429        )],
1430    ) -> bool {
1431        self.verify_shape(control)
1432            && self.verify_intent(intent)
1433            && self.responses.len() == settlements.len()
1434            && self
1435                .responses
1436                .iter()
1437                .zip(settlements)
1438                .all(|(expected, (settlement, slot_value))| {
1439                    expected == settlement
1440                        && match (settlement, slot_value) {
1441                            (
1442                                CircleEpochCloseSettlement::Response(reference),
1443                                CircleEpochCloseResponseSlotValue::Response(response),
1444                            ) => {
1445                                reference.verify_response(response)
1446                                    && response.close_control == self.close_control
1447                            }
1448                            (
1449                                CircleEpochCloseSettlement::Exclusion(reference),
1450                                CircleEpochCloseResponseSlotValue::Exclusion(exclusion),
1451                            ) => {
1452                                reference.verify_exclusion(exclusion)
1453                                    && exclusion.verify_for(control)
1454                                    && exclusion.close_control == self.close_control
1455                            }
1456                            _ => false,
1457                        }
1458                })
1459            && keys::verify_signature_hex(
1460                &self.owner_pubkey,
1461                &self.signature,
1462                &self.canonical_bytes(),
1463            )
1464    }
1465
1466    pub(crate) fn verify_signature(&self) -> bool {
1467        keys::verify_signature_hex(&self.owner_pubkey, &self.signature, &self.canonical_bytes())
1468    }
1469
1470    pub fn outcome_hash(&self) -> ObjectHash {
1471        ObjectHash::digest(
1472            &serde_json::to_vec(self)
1473                .expect("Circle epoch-close outcome serialization cannot fail"),
1474        )
1475    }
1476}
1477
1478#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1479#[serde(deny_unknown_fields)]
1480pub struct CircleEpochCloseOutcomeRef {
1481    pub close_id: CircleEpochCloseId,
1482    pub outcome_hash: ObjectHash,
1483    pub object: ExactObjectRef,
1484}
1485
1486impl CircleEpochCloseOutcomeRef {
1487    pub(crate) fn from_outcome(
1488        outcome: &CircleEpochCloseOutcome,
1489        object: ExactObjectRef,
1490    ) -> Result<Self, CircleTransitionError> {
1491        if object.slot().logical_key()
1492            != format!(
1493                "{}.json",
1494                circle_epoch_close_outcome_semantic_prefix(outcome.circle_id, outcome.close_id)
1495            )
1496        {
1497            return Err(CircleTransitionError::InvalidCurrentState);
1498        }
1499        Ok(Self {
1500            close_id: outcome.close_id,
1501            outcome_hash: outcome.outcome_hash(),
1502            object,
1503        })
1504    }
1505}
1506
1507/// One Owner-signed cancellation of an epoch close. It competes at the same
1508/// create-once outcome slot as the final outcome; activating it reopens the
1509/// frozen epoch instead of rotating to a successor epoch.
1510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1511#[serde(deny_unknown_fields)]
1512pub struct CircleEpochCloseCancellation {
1513    pub version: u32,
1514    pub store_root_hash: ObjectHash,
1515    pub circle_id: CircleId,
1516    pub close_id: CircleEpochCloseId,
1517    pub close_control: CircleControlCoord,
1518    pub intent: CircleEpochCloseIntentRef,
1519    pub owner_pubkey: String,
1520    pub signature: String,
1521}
1522
1523impl CircleEpochCloseCancellation {
1524    pub(crate) fn signed(
1525        control: &PreparedCircleControl,
1526        signer: &UserKeypair,
1527    ) -> Result<Self, CircleTransitionError> {
1528        let CircleControlState::EpochClose(close) = control.value.state() else {
1529            return Err(CircleTransitionError::InvalidCurrentState);
1530        };
1531        let mut cancellation = Self {
1532            version: STORE_PROTOCOL_VERSION,
1533            store_root_hash: control.value.store_root_hash,
1534            circle_id: control.value.circle_id,
1535            close_id: close.close_id,
1536            close_control: control.coord.clone(),
1537            intent: close.intent.clone(),
1538            owner_pubkey: keys::public_key_hex(signer),
1539            signature: String::new(),
1540        };
1541        if !cancellation.verify_shape(control) {
1542            return Err(CircleTransitionError::InvalidCurrentState);
1543        }
1544        cancellation.signature = keys::sign_hex(signer, &cancellation.canonical_bytes()).1;
1545        Ok(cancellation)
1546    }
1547
1548    fn canonical_bytes(&self) -> Vec<u8> {
1549        #[derive(Serialize)]
1550        struct Signed<'a> {
1551            domain: &'static str,
1552            version: u32,
1553            store_root_hash: ObjectHash,
1554            circle_id: CircleId,
1555            close_id: CircleEpochCloseId,
1556            close_control: &'a CircleControlCoord,
1557            intent: &'a CircleEpochCloseIntentRef,
1558            owner_pubkey: &'a str,
1559        }
1560        serde_json::to_vec(&Signed {
1561            domain: "coven.circle-epoch-close-cancellation.v1",
1562            version: self.version,
1563            store_root_hash: self.store_root_hash,
1564            circle_id: self.circle_id,
1565            close_id: self.close_id,
1566            close_control: &self.close_control,
1567            intent: &self.intent,
1568            owner_pubkey: &self.owner_pubkey,
1569        })
1570        .expect("Circle epoch-close cancellation serialization cannot fail")
1571    }
1572
1573    fn verify_shape(&self, control: &PreparedCircleControl) -> bool {
1574        let CircleControlState::EpochClose(close) = control.value.state() else {
1575            return false;
1576        };
1577        self.version == STORE_PROTOCOL_VERSION
1578            && control.verify()
1579            && self.store_root_hash == control.value.store_root_hash
1580            && self.circle_id == control.value.circle_id
1581            && self.close_id == close.close_id
1582            && self.close_control == control.coord
1583            && self.intent == close.intent
1584            && close
1585                .frozen_epoch
1586                .common
1587                .owners
1588                .contains(&self.owner_pubkey)
1589    }
1590
1591    pub(crate) fn verify_for(&self, control: &PreparedCircleControl) -> bool {
1592        self.verify_shape(control)
1593            && keys::verify_signature_hex(
1594                &self.owner_pubkey,
1595                &self.signature,
1596                &self.canonical_bytes(),
1597            )
1598    }
1599
1600    pub(crate) fn verify_signature(&self) -> bool {
1601        keys::verify_signature_hex(&self.owner_pubkey, &self.signature, &self.canonical_bytes())
1602    }
1603
1604    pub fn cancellation_hash(&self) -> ObjectHash {
1605        ObjectHash::digest(
1606            &serde_json::to_vec(self)
1607                .expect("Circle epoch-close cancellation serialization cannot fail"),
1608        )
1609    }
1610}
1611
1612#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1613#[serde(deny_unknown_fields)]
1614pub struct CircleEpochCloseCancellationRef {
1615    pub close_id: CircleEpochCloseId,
1616    pub cancellation_hash: ObjectHash,
1617    pub object: ExactObjectRef,
1618}
1619
1620impl CircleEpochCloseCancellationRef {
1621    pub(crate) fn from_cancellation(
1622        cancellation: &CircleEpochCloseCancellation,
1623        object: ExactObjectRef,
1624    ) -> Result<Self, CircleTransitionError> {
1625        if object.slot().logical_key()
1626            != format!(
1627                "{}.json",
1628                circle_epoch_close_outcome_semantic_prefix(
1629                    cancellation.circle_id,
1630                    cancellation.close_id
1631                )
1632            )
1633        {
1634            return Err(CircleTransitionError::InvalidCurrentState);
1635        }
1636        Ok(Self {
1637            close_id: cancellation.close_id,
1638            cancellation_hash: cancellation.cancellation_hash(),
1639            object,
1640        })
1641    }
1642}
1643
1644/// The exactly-one value the create-once epoch-close outcome slot holds. Readers
1645/// parse this tagged form and dispatch on the settled arm: a final outcome
1646/// rotates to a successor epoch, a cancellation reopens the frozen epoch.
1647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1648#[serde(rename_all = "snake_case", deny_unknown_fields)]
1649pub enum CircleEpochCloseSlotValue {
1650    Outcome(CircleEpochCloseOutcome),
1651    Cancellation(CircleEpochCloseCancellation),
1652}
1653
1654impl CircleEpochCloseSlotValue {
1655    pub(crate) fn to_bytes(&self) -> Vec<u8> {
1656        serde_json::to_vec(self).expect("Circle epoch-close slot value serialization cannot fail")
1657    }
1658
1659    pub(crate) fn parse(bytes: &[u8]) -> Result<Self, CircleTransitionError> {
1660        let value: Self = serde_json::from_slice(bytes)
1661            .map_err(|_| CircleTransitionError::InvalidCurrentState)?;
1662        if value.to_bytes() != bytes {
1663            return Err(CircleTransitionError::InvalidCurrentState);
1664        }
1665        Ok(value)
1666    }
1667}
1668
1669/// A terminal deletion. It freezes the epoch spine it terminated — the same
1670/// `MergeActiveCircleEpoch` an `EpochClose` freezes — so historical package
1671/// verification and exact reclamation keep the epoch, key fingerprint, and
1672/// roster-head spine with no live access material.
1673#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1674#[serde(deny_unknown_fields)]
1675pub struct DeletedCircle {
1676    pub frozen_epoch: MergeActiveCircleEpoch,
1677}
1678
1679#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1680#[serde(rename_all = "snake_case", deny_unknown_fields)]
1681pub enum CircleControlState {
1682    ActiveEpoch(MergeActiveCircleEpoch),
1683    EpochClose(CircleEpochClose),
1684    Deleted(DeletedCircle),
1685}
1686
1687impl CircleControlState {
1688    pub(crate) fn access_epoch(&self) -> &MergeActiveCircleEpoch {
1689        match self {
1690            Self::ActiveEpoch(active) => active,
1691            Self::EpochClose(close) => &close.frozen_epoch,
1692            Self::Deleted(deleted) => &deleted.frozen_epoch,
1693        }
1694    }
1695
1696    pub(crate) fn access_epoch_mut(&mut self) -> &mut MergeActiveCircleEpoch {
1697        match self {
1698            Self::ActiveEpoch(active) => active,
1699            Self::EpochClose(close) => &mut close.frozen_epoch,
1700            Self::Deleted(deleted) => &mut deleted.frozen_epoch,
1701        }
1702    }
1703
1704    pub(crate) fn active_epoch(&self) -> Option<&MergeActiveCircleEpoch> {
1705        match self {
1706            Self::ActiveEpoch(active) => Some(active),
1707            Self::EpochClose(_) | Self::Deleted(_) => None,
1708        }
1709    }
1710
1711    pub(crate) fn active_epoch_mut(&mut self) -> Option<&mut MergeActiveCircleEpoch> {
1712        match self {
1713            Self::ActiveEpoch(active) => Some(active),
1714            Self::EpochClose(_) | Self::Deleted(_) => None,
1715        }
1716    }
1717
1718    pub(crate) fn is_deleted(&self) -> bool {
1719        matches!(self, Self::Deleted(_))
1720    }
1721}
1722
1723#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1724#[serde(deny_unknown_fields)]
1725pub struct MergeCircleControlHeadRef {
1726    pub coord: CircleControlCoord,
1727    pub head_hash: ObjectHash,
1728    pub object: ExactObjectRef,
1729}
1730
1731/// One losing branch of a resolved control conflict, carried so the resolution
1732/// can cover every branch's frontier rather than only the chosen branch's: the
1733/// branch's control head, its metadata and roster head frontiers, and the
1734/// metadata entry that branch selected. The resolution unions these into its own
1735/// frontier so no author-stream head is re-allocated once the conflict collapses,
1736/// and re-derives its name as the deterministic metadata selection across the
1737/// union.
1738#[derive(Debug, Clone)]
1739pub(crate) struct ResolvedConflictBranch {
1740    pub control_head: MergeCircleControlHeadRef,
1741    pub metadata_heads: Vec<CircleMetadataHeadRef>,
1742    pub roster_heads: Vec<CircleRosterHeadRef>,
1743    pub selected_metadata: CircleMetadata,
1744}
1745
1746/// Insert `head` into a frontier keyed by author stream, keeping the deeper
1747/// (higher-sequence) head when the stream already carries one. Merging every
1748/// conflicting branch's heads this way yields the union frontier: each stream is
1749/// covered at its deepest position across all branches, so a device that authored
1750/// on that stream continues from its own head instead of re-allocating it.
1751pub(crate) fn merge_frontier_head<H>(
1752    frontier: &mut Vec<H>,
1753    head: H,
1754    stream_key: impl Fn(&H) -> CircleAuthorStreamKey,
1755    seq: impl Fn(&H) -> u64,
1756) {
1757    let key = stream_key(&head);
1758    match frontier
1759        .iter_mut()
1760        .find(|existing| stream_key(existing) == key)
1761    {
1762        Some(existing) if seq(&head) > seq(existing) => *existing = head,
1763        Some(_) => {}
1764        None => frontier.push(head),
1765    }
1766}
1767
1768#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1769#[serde(rename_all = "snake_case", deny_unknown_fields)]
1770pub enum MergeCircleOwnerAuthorityRef {
1771    Roster {
1772        roster: MergeCircleRosterStateRef,
1773        grant_id: MembershipGrantId,
1774        created_at: super::circle_roster::CircleRosterCoord,
1775    },
1776    ConflictResolution {
1777        conflict_hash: ObjectHash,
1778        resolution_hash: ObjectHash,
1779    },
1780}
1781
1782impl MergeCircleOwnerAuthorityRef {
1783    pub fn grant_id(&self, author_pubkey: &str) -> MembershipGrantId {
1784        match self {
1785            Self::Roster { grant_id, .. } => grant_id.clone(),
1786            Self::ConflictResolution { conflict_hash, .. } => {
1787                super::circle_roster::derive_circle_resolution_grant(conflict_hash, author_pubkey)
1788            }
1789        }
1790    }
1791}
1792
1793#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1794#[serde(deny_unknown_fields)]
1795pub struct CircleControlValue {
1796    pub order: MergeCircleControlOrder,
1797    pub state: CircleControlState,
1798    pub author_authority: MergeCircleOwnerAuthorityRef,
1799    pub membership_authority: MembershipGrantCreationAuthority,
1800}
1801
1802#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1803#[serde(deny_unknown_fields)]
1804pub struct CircleControl {
1805    pub version: u32,
1806    pub store_root_hash: ObjectHash,
1807    pub circle_id: CircleId,
1808    pub value: CircleControlValue,
1809    pub author_pubkey: String,
1810    pub signature: String,
1811}
1812
1813impl CircleControl {
1814    pub fn state(&self) -> &CircleControlState {
1815        &self.value.state
1816    }
1817
1818    pub fn active_epoch(&self) -> Option<&MergeActiveCircleEpoch> {
1819        self.value.state.active_epoch()
1820    }
1821
1822    pub(crate) fn access_epoch(&self) -> &MergeActiveCircleEpoch {
1823        self.value.state.access_epoch()
1824    }
1825
1826    pub fn active_common(&self) -> &ActiveCircleEpochCore {
1827        &self.access_epoch().common
1828    }
1829
1830    pub fn epoch_id(&self) -> CircleEpochId {
1831        self.active_common().epoch_id
1832    }
1833
1834    pub fn key_fingerprint(&self) -> KeyFingerprint {
1835        self.active_common().key_fingerprint
1836    }
1837
1838    pub fn owners(&self) -> &[String] {
1839        &self.active_common().owners
1840    }
1841
1842    pub fn access_root(&self) -> ObjectHash {
1843        self.active_common().access_root
1844    }
1845
1846    pub fn roster_state_ref(&self) -> CircleRosterStateRef {
1847        self.access_epoch().roster.clone()
1848    }
1849
1850    pub fn metadata_state_ref(&self) -> CircleMetadataStateRef {
1851        self.access_epoch().metadata.clone()
1852    }
1853
1854    pub fn store_membership_state_ref(&self) -> StoreMembershipStateRef {
1855        self.access_epoch().store_membership.clone()
1856    }
1857
1858    pub fn membership_authority(&self) -> &MembershipGrantCreationAuthority {
1859        &self.value.membership_authority
1860    }
1861
1862    pub fn previous_control_hash(&self) -> Option<ObjectHash> {
1863        self.value.order.previous_control_hash
1864    }
1865
1866    pub(crate) fn is_founder(&self) -> bool {
1867        self.value.order.seq == 1
1868            && self.value.order.previous_control_hash.is_none()
1869            && self.value.order.dependencies.is_empty()
1870    }
1871
1872    pub(crate) fn causally_covers(&self, prior: &Self) -> bool {
1873        if self.store_root_hash != prior.store_root_hash || self.circle_id != prior.circle_id {
1874            return false;
1875        }
1876        self.value.order.previous_control_hash == Some(prior.control_hash())
1877            || self
1878                .value
1879                .order
1880                .dependencies
1881                .binary_search(&prior.coord())
1882                .is_ok()
1883    }
1884
1885    pub fn ordinal(&self) -> u64 {
1886        self.value.order.seq
1887    }
1888
1889    pub fn author_grant_id(&self) -> MembershipGrantId {
1890        self.value.author_authority.grant_id(&self.author_pubkey)
1891    }
1892
1893    pub(crate) fn canonical_bytes(&self) -> Vec<u8> {
1894        #[derive(Serialize)]
1895        struct Signed<'a> {
1896            domain: &'static str,
1897            version: u32,
1898            store_root_hash: ObjectHash,
1899            circle_id: CircleId,
1900            value: &'a CircleControlValue,
1901            author_pubkey: &'a str,
1902        }
1903        serde_json::to_vec(&Signed {
1904            domain: CONTROL_DOMAIN,
1905            version: self.version,
1906            store_root_hash: self.store_root_hash,
1907            circle_id: self.circle_id,
1908            value: &self.value,
1909            author_pubkey: &self.author_pubkey,
1910        })
1911        .expect("circle control serialization cannot fail")
1912    }
1913
1914    pub fn control_hash(&self) -> ObjectHash {
1915        ObjectHash::digest(
1916            &serde_json::to_vec(self).expect("circle control serialization cannot fail"),
1917        )
1918    }
1919
1920    pub fn verify(&self) -> bool {
1921        let order = &self.value.order;
1922        let access_epoch = self.access_epoch();
1923        let author_authority = &self.value.author_authority;
1924        let grant_id = author_authority.grant_id(&self.author_pubkey);
1925        let stream_key = CircleAuthorStreamKey {
1926            author_pubkey: self.author_pubkey.clone(),
1927            device_id: order.device_id.clone(),
1928            stream_id: order.stream_id,
1929            author_owner_grant: order.author_owner_grant.clone(),
1930        };
1931        let covered_are_canonical = access_epoch
1932            .covered_control_heads
1933            .windows(2)
1934            .all(|pair| pair[0].coord.stream_key() < pair[1].coord.stream_key());
1935        let own_predecessor = access_epoch
1936            .covered_control_heads
1937            .iter()
1938            .find(|head| head.coord.stream_key() == stream_key);
1939        let expected_dependencies = access_epoch
1940            .covered_control_heads
1941            .iter()
1942            .filter(|head| head.coord.stream_key() != stream_key)
1943            .map(|head| head.coord.clone())
1944            .collect::<Vec<_>>();
1945        let order_is_valid = !order.device_id.is_empty()
1946            && order.seq > 0
1947            && order.author_owner_grant == grant_id
1948            && covered_are_canonical
1949            && order.dependencies == expected_dependencies;
1950        let authority_is_founder_roster = matches!(
1951            author_authority,
1952            MergeCircleOwnerAuthorityRef::Roster { roster, .. }
1953                if roster == &access_epoch.roster
1954        );
1955        let founder = order.seq == 1 && access_epoch.covered_control_heads.is_empty();
1956        let continuity_is_valid = match (order.seq, own_predecessor) {
1957            (1, None) => order.previous_control_hash.is_none(),
1958            (seq, Some(predecessor)) if seq > 1 => {
1959                predecessor.coord.seq.checked_add(1) == Some(seq)
1960                    && order.previous_control_hash == Some(predecessor.coord.control_hash)
1961            }
1962            _ => false,
1963        };
1964        let founder_identity_is_valid = !founder
1965            || (authority_is_founder_roster
1966                && self.circle_id
1967                    == CircleId::founder(self.store_root_hash, &self.author_pubkey, &grant_id));
1968        let common = &access_epoch.common;
1969        let owners_are_canonical =
1970            !common.owners.is_empty() && common.owners.windows(2).all(|pair| pair[0] < pair[1]);
1971        let origin_is_valid = match &common.origin {
1972            CircleEpochOrigin::Founder => true,
1973            CircleEpochOrigin::Closed { cutoff, .. } => {
1974                super::store_commit::validate_commit_frontier(cutoff).is_ok()
1975            }
1976        };
1977        let state_is_valid = match &self.value.state {
1978            CircleControlState::ActiveEpoch(_) => true,
1979            CircleControlState::EpochClose(close) => !founder && close.verify_shape(self.circle_id),
1980            // A deletion is always a successor of a live control; the frozen
1981            // epoch it carries is validated by the shared access-epoch checks
1982            // above.
1983            CircleControlState::Deleted(_) => !founder,
1984        };
1985        self.version == STORE_PROTOCOL_VERSION
1986            && owners_are_canonical
1987            && origin_is_valid
1988            && state_is_valid
1989            && order_is_valid
1990            && continuity_is_valid
1991            && founder_identity_is_valid
1992            && keys::verify_signature_hex(
1993                &self.author_pubkey,
1994                &self.signature,
1995                &self.canonical_bytes(),
1996            )
1997    }
1998
1999    pub fn coord(&self) -> CircleControlCoord {
2000        let order = &self.value.order;
2001        CircleControlCoord {
2002            device_id: order.device_id.clone(),
2003            stream_id: order.stream_id,
2004            author_pubkey: self.author_pubkey.clone(),
2005            author_owner_grant: order.author_owner_grant.clone(),
2006            seq: order.seq,
2007            control_hash: self.control_hash(),
2008        }
2009    }
2010}
2011
2012#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2013#[serde(deny_unknown_fields)]
2014pub struct CircleControlHead {
2015    pub version: u32,
2016    pub store_root_hash: ObjectHash,
2017    pub circle_id: CircleId,
2018    pub control: CircleControlCoord,
2019    pub entry: ExactObjectRef,
2020    pub successor: SuccessorLink,
2021    pub signature: String,
2022}
2023
2024impl CircleControlHead {
2025    pub(crate) fn signed(
2026        control: &CircleControl,
2027        entry: ExactObjectRef,
2028        successor: SuccessorLink,
2029        signer: &UserKeypair,
2030    ) -> Self {
2031        let mut head = Self {
2032            version: STORE_PROTOCOL_VERSION,
2033            store_root_hash: control.store_root_hash,
2034            circle_id: control.circle_id,
2035            control: control.coord(),
2036            entry,
2037            successor,
2038            signature: String::new(),
2039        };
2040        head.signature = keys::sign_hex(signer, &head.canonical_bytes()).1;
2041        head
2042    }
2043
2044    pub(crate) fn canonical_bytes(&self) -> Vec<u8> {
2045        #[derive(Serialize)]
2046        struct Signed<'a> {
2047            domain: &'static str,
2048            version: u32,
2049            store_root_hash: ObjectHash,
2050            circle_id: CircleId,
2051            control: &'a CircleControlCoord,
2052            entry: &'a ExactObjectRef,
2053            successor: &'a SuccessorLink,
2054        }
2055        serde_json::to_vec(&Signed {
2056            domain: "coven.circle-control-head.v1",
2057            version: self.version,
2058            store_root_hash: self.store_root_hash,
2059            circle_id: self.circle_id,
2060            control: &self.control,
2061            entry: &self.entry,
2062            successor: &self.successor,
2063        })
2064        .expect("circle control head serialization cannot fail")
2065    }
2066
2067    pub fn head_hash(&self) -> ObjectHash {
2068        ObjectHash::digest(
2069            &serde_json::to_vec(self).expect("circle control head serialization cannot fail"),
2070        )
2071    }
2072
2073    pub fn verify(&self, registration: &StoreDeviceRegistration) -> bool {
2074        self.version == STORE_PROTOCOL_VERSION
2075            && self.control.validate().is_ok()
2076            && self.control.device_id == registration.device_id.to_string()
2077            && keys::verify_signature_hex(
2078                &registration.device_signing_pubkey,
2079                &self.signature,
2080                &self.canonical_bytes(),
2081            )
2082    }
2083}
2084
2085#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2086#[serde(deny_unknown_fields)]
2087pub struct AccessEnvelope {
2088    pub version: u32,
2089    pub store_root_hash: ObjectHash,
2090    pub candidate_family: super::store_commit::CandidateFamilyId,
2091    pub circle_id: CircleId,
2092    pub owner_pubkey: String,
2093    pub recipient_slot: String,
2094    pub control_hash: ObjectHash,
2095    pub leaf_id: AccessLeafId,
2096    pub leaf_hash: ObjectHash,
2097    pub value_hash: ObjectHash,
2098    pub proof: Vec<MerkleStep>,
2099    pub signature: String,
2100}
2101
2102impl AccessEnvelope {
2103    pub(crate) fn canonical_bytes(&self) -> Vec<u8> {
2104        #[derive(Serialize)]
2105        struct Signed<'a> {
2106            domain: &'static str,
2107            version: u32,
2108            store_root_hash: ObjectHash,
2109            candidate_family: super::store_commit::CandidateFamilyId,
2110            circle_id: CircleId,
2111            owner_pubkey: &'a str,
2112            recipient_slot: &'a str,
2113            control_hash: ObjectHash,
2114            leaf_id: AccessLeafId,
2115            leaf_hash: ObjectHash,
2116            value_hash: ObjectHash,
2117            proof: &'a [MerkleStep],
2118        }
2119        serde_json::to_vec(&Signed {
2120            domain: ENVELOPE_DOMAIN,
2121            version: self.version,
2122            store_root_hash: self.store_root_hash,
2123            candidate_family: self.candidate_family,
2124            circle_id: self.circle_id,
2125            owner_pubkey: &self.owner_pubkey,
2126            recipient_slot: &self.recipient_slot,
2127            control_hash: self.control_hash,
2128            leaf_id: self.leaf_id,
2129            leaf_hash: self.leaf_hash,
2130            value_hash: self.value_hash,
2131            proof: &self.proof,
2132        })
2133        .expect("access envelope serialization cannot fail")
2134    }
2135
2136    pub fn verify(
2137        &self,
2138        control: &PreparedCircleControl,
2139        candidate_family: super::store_commit::CandidateFamilyId,
2140    ) -> bool {
2141        self.version == STORE_PROTOCOL_VERSION
2142            && self.store_root_hash == control.value.store_root_hash
2143            && self.candidate_family == candidate_family
2144            && self.circle_id == control.value.circle_id
2145            && self.owner_pubkey == control.value.author_pubkey
2146            && self.control_hash == control.coord.control_hash()
2147            && keys::verify_signature_hex(
2148                &self.owner_pubkey,
2149                &self.signature,
2150                &self.canonical_bytes(),
2151            )
2152            && verify_merkle_proof(self.leaf_hash, &self.proof, control.value.access_root())
2153    }
2154}
2155
2156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2157#[serde(deny_unknown_fields)]
2158pub struct PreparedCircleControl {
2159    pub coord: CircleControlCoord,
2160    pub bytes: Vec<u8>,
2161    pub value: CircleControl,
2162}
2163
2164impl PreparedCircleControl {
2165    pub fn verify(&self) -> bool {
2166        self.bytes
2167            == serde_json::to_vec(&self.value).expect("circle control serialization cannot fail")
2168            && self.value.verify()
2169            && self.coord == self.value.coord()
2170    }
2171}
2172
2173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2174#[serde(deny_unknown_fields)]
2175pub struct PreparedAccessLeaf {
2176    pub bytes: Vec<u8>,
2177    pub value: CircleAccessLeaf,
2178    pub leaf_hash: ObjectHash,
2179}
2180
2181impl PreparedAccessLeaf {
2182    pub fn verify(
2183        &self,
2184        control: &PreparedCircleControl,
2185        candidate_family: super::store_commit::CandidateFamilyId,
2186    ) -> bool {
2187        self.value.verify_for_control(control, candidate_family)
2188            && ObjectHash::digest(&self.bytes) == self.leaf_hash
2189    }
2190
2191    pub fn verify_envelope(
2192        &self,
2193        control: &PreparedCircleControl,
2194        envelope: &AccessEnvelope,
2195        candidate_family: super::store_commit::CandidateFamilyId,
2196    ) -> bool {
2197        self.verify(control, candidate_family)
2198            && envelope.verify(control, candidate_family)
2199            && self.leaf_hash == envelope.leaf_hash
2200            && envelope.value_hash
2201                == ObjectHash::digest(
2202                    &serde_json::to_vec(&self.value)
2203                        .expect("circle access leaf serialization cannot fail"),
2204                )
2205            && self.value.leaf_id == envelope.leaf_id
2206            && self.value.owner_pubkey == envelope.owner_pubkey
2207            && self.value.recipient_slot == envelope.recipient_slot
2208    }
2209}
2210
2211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2212#[serde(deny_unknown_fields)]
2213pub struct PreparedCircleAccess {
2214    pub leaf: PreparedAccessLeaf,
2215    pub envelope: AccessEnvelope,
2216}
2217
2218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2219#[serde(deny_unknown_fields)]
2220pub struct CircleRosterPolicyObjects {
2221    pub entry: CircleRosterEntry,
2222    pub head: CircleRosterHead,
2223}
2224
2225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2226#[serde(deny_unknown_fields)]
2227pub struct CircleTransitionPolicyObjects {
2228    pub roster: Option<CircleRosterPolicyObjects>,
2229    pub metadata_head: Option<CircleMetadataHead>,
2230    pub control_head: CircleControlHead,
2231}
2232
2233#[derive(Debug, Clone)]
2234pub(crate) enum CircleRosterDraftPolicy {
2235    Inherited,
2236    Founder {
2237        entry: CircleRosterEntry,
2238    },
2239    Successor {
2240        predecessor: CircleRosterChain,
2241        entry: CircleRosterEntry,
2242    },
2243}
2244
2245#[derive(Debug, Clone)]
2246pub(crate) struct CircleTransitionDraftPolicy {
2247    pub roster: CircleRosterDraftPolicy,
2248    pub metadata_successor: bool,
2249}
2250
2251#[derive(Debug, Clone)]
2252pub(crate) struct CircleTransitionDraft {
2253    pub circle_id: CircleId,
2254    pub epoch_id: CircleEpochId,
2255    pub keyring: String,
2256    pub roster: CircleMaterializedRoster,
2257    pub policy: CircleTransitionDraftPolicy,
2258    pub metadata: CircleMetadata,
2259    pub close_intent: Option<CircleEpochCloseIntent>,
2260    pub close_finalization: Option<CircleEpochCloseFinalizationDraft>,
2261    pub close_cancellation: Option<CircleEpochCloseCancellationDraft>,
2262    pub access: Vec<PreparedCircleAccess>,
2263    pub control: PreparedCircleControl,
2264}
2265
2266#[derive(Debug, Clone)]
2267pub(crate) struct CircleEpochCloseFinalizationDraft {
2268    pub close_control: PreparedCircleControl,
2269    pub intent: CircleEpochCloseIntent,
2270    pub responses: Vec<CircleEpochCloseSettlement>,
2271    pub outcome_slot: ObjectSlot,
2272}
2273
2274#[derive(Debug, Clone)]
2275pub(crate) struct CircleEpochCloseCancellationDraft {
2276    pub close_control: PreparedCircleControl,
2277    pub outcome_slot: ObjectSlot,
2278}
2279
2280#[derive(Debug, Clone)]
2281struct FounderRosterObjects {
2282    entry: CircleRosterEntry,
2283    resolved: ResolvedCircleRoster,
2284}
2285
2286struct PreparedAccessMaterial {
2287    value: CircleAccessLeaf,
2288    bytes: Vec<u8>,
2289    leaf_hash: ObjectHash,
2290}
2291
2292#[allow(clippy::too_many_arguments)]
2293fn prepare_access_material(
2294    store_root_hash: ObjectHash,
2295    candidate_family: super::store_commit::CandidateFamilyId,
2296    circle_id: CircleId,
2297    epoch_id: CircleEpochId,
2298    keyring: &str,
2299    key_fingerprint: KeyFingerprint,
2300    roster_state: &CircleRosterStateRef,
2301    roster_members: &std::collections::BTreeMap<String, super::circle::CircleRole>,
2302    store_membership: &StoreMembershipStateRef,
2303    store_members: &[(String, MemberRole)],
2304    bootstraps: &std::collections::BTreeMap<String, CircleBootstrapRef>,
2305    ids: &dyn crate::id_provider::IdProvider,
2306    signer: &UserKeypair,
2307) -> Result<Vec<PreparedAccessMaterial>, CircleTransitionError> {
2308    let author_pubkey = keys::public_key_hex(signer);
2309    store_members
2310        .iter()
2311        .map(|(recipient_pubkey, _)| {
2312            let recipient_slot = recipient_slot(signer, recipient_pubkey, circle_id)?;
2313            let disposition = if roster_members.contains_key(recipient_pubkey) {
2314                CircleAccessDisposition::Active {
2315                    keyring: keyring.to_string(),
2316                    key_fingerprint,
2317                    roster: roster_state.clone(),
2318                    bootstrap: bootstraps.get(recipient_pubkey).cloned(),
2319                }
2320            } else {
2321                CircleAccessDisposition::Inactive
2322            };
2323            let mut value = CircleAccessLeaf {
2324                version: STORE_PROTOCOL_VERSION,
2325                store_root_hash,
2326                candidate_family,
2327                circle_id,
2328                epoch_id,
2329                leaf_id: AccessLeafId::generate(ids),
2330                owner_pubkey: author_pubkey.clone(),
2331                recipient_pubkey: recipient_pubkey.clone(),
2332                recipient_slot,
2333                disposition,
2334                store_membership: store_membership.clone(),
2335                signature: String::new(),
2336            };
2337            value.signature = keys::sign_hex(signer, &value.canonical_bytes()).1;
2338            let recipient_ed25519: [u8; keys::SIGN_PUBLICKEYBYTES] = hex::decode(recipient_pubkey)
2339                .map_err(|_| CircleTransitionError::InvalidRecipient(recipient_pubkey.clone()))?
2340                .try_into()
2341                .map_err(|_| CircleTransitionError::InvalidRecipient(recipient_pubkey.clone()))?;
2342            let recipient_x25519 = keys::ed25519_to_x25519_public_key(&recipient_ed25519)
2343                .map_err(|_| CircleTransitionError::InvalidRecipient(recipient_pubkey.clone()))?;
2344            let plaintext =
2345                serde_json::to_vec(&value).expect("circle access serialization cannot fail");
2346            let bytes = keys::seal_box_encrypt(&plaintext, &recipient_x25519);
2347            let leaf_hash = ObjectHash::digest(&bytes);
2348            Ok(PreparedAccessMaterial {
2349                value,
2350                bytes,
2351                leaf_hash,
2352            })
2353        })
2354        .collect()
2355}
2356
2357fn prepare_access_envelopes(
2358    store_root_hash: ObjectHash,
2359    candidate_family: super::store_commit::CandidateFamilyId,
2360    circle_id: CircleId,
2361    control: &PreparedCircleControl,
2362    leaves: Vec<PreparedAccessMaterial>,
2363    proofs: Vec<Vec<MerkleStep>>,
2364    signer: &UserKeypair,
2365) -> Vec<PreparedCircleAccess> {
2366    let author_pubkey = keys::public_key_hex(signer);
2367    leaves
2368        .into_iter()
2369        .zip(proofs)
2370        .map(|(leaf, proof)| {
2371            let mut envelope = AccessEnvelope {
2372                version: STORE_PROTOCOL_VERSION,
2373                store_root_hash,
2374                candidate_family,
2375                circle_id,
2376                owner_pubkey: author_pubkey.clone(),
2377                recipient_slot: leaf.value.recipient_slot.clone(),
2378                control_hash: control.coord.control_hash(),
2379                leaf_id: leaf.value.leaf_id,
2380                leaf_hash: leaf.leaf_hash,
2381                value_hash: ObjectHash::digest(
2382                    &serde_json::to_vec(&leaf.value)
2383                        .expect("circle access leaf serialization cannot fail"),
2384                ),
2385                proof,
2386                signature: String::new(),
2387            };
2388            envelope.signature = keys::sign_hex(signer, &envelope.canonical_bytes()).1;
2389            PreparedCircleAccess {
2390                leaf: PreparedAccessLeaf {
2391                    bytes: leaf.bytes,
2392                    value: leaf.value,
2393                    leaf_hash: leaf.leaf_hash,
2394                },
2395                envelope,
2396            }
2397        })
2398        .collect()
2399}
2400
2401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2402#[serde(deny_unknown_fields)]
2403pub struct PreparedCircleTransition {
2404    pub circle_id: CircleId,
2405    pub epoch_id: CircleEpochId,
2406    pub keyring: String,
2407    pub roster: CircleMaterializedRoster,
2408    pub policy_objects: CircleTransitionPolicyObjects,
2409    pub metadata: CircleMetadata,
2410    pub close_intent: Option<CircleEpochCloseIntent>,
2411    pub close_outcome: Option<CircleEpochCloseOutcome>,
2412    pub close_cancellation: Option<CircleEpochCloseCancellation>,
2413    pub access: Vec<PreparedCircleAccess>,
2414    pub control: PreparedCircleControl,
2415}
2416
2417impl PreparedCircleTransition {
2418    pub fn resolved_roster(&self) -> CircleMaterializedRoster {
2419        self.roster.clone()
2420    }
2421
2422    pub fn control_ref(
2423        &self,
2424        objects: super::store_commit::CircleActivationObjects,
2425        head_object: Option<ExactObjectRef>,
2426    ) -> super::store_commit::CircleControlRef {
2427        let head_object =
2428            head_object.expect("prepared Circle transition must contain its stored head");
2429        super::store_commit::CircleControlRef {
2430            circle_id: self.circle_id,
2431            control: self.control.coord.clone(),
2432            head_hash: self.policy_objects.control_head.head_hash(),
2433            head_object,
2434            objects,
2435        }
2436    }
2437}
2438
2439struct CircleSuccessorContext<'a> {
2440    store_members: Vec<(String, MemberRole)>,
2441    author_pubkey: String,
2442    epoch: &'a MergeActiveCircleEpoch,
2443    grant_id: MembershipGrantId,
2444    author_authority: MergeCircleOwnerAuthorityRef,
2445    key_fingerprint: KeyFingerprint,
2446}
2447
2448/// The successor context for a command that publishes a new active epoch: the
2449/// current control must be `ActiveEpoch`, so a closing or deleted control is
2450/// refused.
2451fn circle_successor_context<'a>(
2452    store_members: Vec<(String, MemberRole)>,
2453    current_control: &'a PreparedCircleControl,
2454    current_roster: &CircleMaterializedRoster,
2455    current_metadata: &CircleMetadata,
2456    keyring: &str,
2457    signer: &UserKeypair,
2458) -> Result<CircleSuccessorContext<'a>, CircleTransitionError> {
2459    let epoch = current_control
2460        .value
2461        .active_epoch()
2462        .ok_or(CircleTransitionError::InvalidCurrentState)?;
2463    circle_authored_successor_context(
2464        store_members,
2465        current_control,
2466        current_roster,
2467        current_metadata,
2468        keyring,
2469        signer,
2470        epoch,
2471    )
2472}
2473
2474/// The successor context for a terminal deletion, which supersedes an in-flight
2475/// close. It authors over the control's access epoch — the active epoch itself,
2476/// or a close's frozen epoch — so a `Closing` control resolves to the frozen
2477/// spine the deletion freezes, rather than being refused for lacking an active
2478/// epoch.
2479fn circle_delete_successor_context<'a>(
2480    store_members: Vec<(String, MemberRole)>,
2481    current_control: &'a PreparedCircleControl,
2482    current_roster: &CircleMaterializedRoster,
2483    current_metadata: &CircleMetadata,
2484    keyring: &str,
2485    signer: &UserKeypair,
2486) -> Result<CircleSuccessorContext<'a>, CircleTransitionError> {
2487    let epoch = current_control.value.access_epoch();
2488    circle_authored_successor_context(
2489        store_members,
2490        current_control,
2491        current_roster,
2492        current_metadata,
2493        keyring,
2494        signer,
2495        epoch,
2496    )
2497}
2498
2499fn circle_authored_successor_context<'a>(
2500    mut store_members: Vec<(String, MemberRole)>,
2501    current_control: &PreparedCircleControl,
2502    current_roster: &CircleMaterializedRoster,
2503    current_metadata: &CircleMetadata,
2504    keyring: &str,
2505    signer: &UserKeypair,
2506    epoch: &'a MergeActiveCircleEpoch,
2507) -> Result<CircleSuccessorContext<'a>, CircleTransitionError> {
2508    if !current_control.verify()
2509        || !current_roster.verify()
2510        || !current_metadata.verify()
2511        || current_control.value.circle_id != current_metadata.circle_id
2512        || current_control.value.epoch_id() != current_metadata.epoch_id
2513    {
2514        return Err(CircleTransitionError::InvalidCurrentState);
2515    }
2516    let author_pubkey = keys::public_key_hex(signer);
2517    store_members.sort_by(|left, right| left.0.cmp(&right.0));
2518    store_members.dedup_by(|left, right| left.0 == right.0);
2519    if !store_members
2520        .iter()
2521        .any(|(pubkey, role)| pubkey == &author_pubkey && role.can_write())
2522    {
2523        return Err(CircleTransitionError::AuthorNotStoreWriter);
2524    }
2525    if current_roster.members().get(&author_pubkey) != Some(&super::circle::CircleRole::Owner) {
2526        return Err(CircleTransitionError::AuthorNotCircleOwner);
2527    }
2528    let key_fingerprint = EncryptionService::from(
2529        MasterKeyring::from_serialized(keyring)
2530            .map_err(|_| CircleTransitionError::InvalidCurrentState)?,
2531    )
2532    .seal_key_fingerprint();
2533    if key_fingerprint != current_control.value.key_fingerprint()
2534        || current_metadata.key_fingerprint != key_fingerprint
2535    {
2536        return Err(CircleTransitionError::InvalidCurrentState);
2537    }
2538    let (grant_id, record) = current_roster
2539        .active_grants()
2540        .find(|(_, record)| {
2541            record.member_pubkey == author_pubkey && record.role == super::circle::CircleRole::Owner
2542        })
2543        .ok_or(CircleTransitionError::AuthorNotCircleOwner)?;
2544    let author_authority = match &record.creation_authority {
2545        CircleGrantCreationAuthority::Entry(created_at) => MergeCircleOwnerAuthorityRef::Roster {
2546            roster: epoch.roster.clone(),
2547            grant_id: grant_id.clone(),
2548            created_at: created_at.clone(),
2549        },
2550        CircleGrantCreationAuthority::ConflictResolution(resolution) => {
2551            MergeCircleOwnerAuthorityRef::ConflictResolution {
2552                conflict_hash: resolution.conflict_hash,
2553                resolution_hash: resolution.resolution_hash,
2554            }
2555        }
2556    };
2557    Ok(CircleSuccessorContext {
2558        store_members,
2559        author_pubkey,
2560        epoch,
2561        grant_id: grant_id.clone(),
2562        author_authority,
2563        key_fingerprint,
2564    })
2565}
2566
2567impl CircleTransitionDraft {
2568    #[allow(clippy::too_many_arguments)]
2569    pub(crate) fn founder(
2570        store_root_hash: ObjectHash,
2571        candidate_family: super::store_commit::CandidateFamilyId,
2572        device_id: &str,
2573        name: &str,
2574        metadata_stamp: &str,
2575        store_membership: StoreMembershipStateRef,
2576        membership_authority: MembershipGrantCreationAuthority,
2577        mut store_members: Vec<(String, MemberRole)>,
2578        ids: &dyn crate::id_provider::IdProvider,
2579        signer: &UserKeypair,
2580    ) -> Result<Self, CircleTransitionError> {
2581        let author_pubkey = keys::public_key_hex(signer);
2582        store_members.sort_by(|left, right| left.0.cmp(&right.0));
2583        store_members.dedup_by(|left, right| left.0 == right.0);
2584        if !store_members
2585            .iter()
2586            .any(|(pubkey, role)| pubkey == &author_pubkey && role.can_write())
2587        {
2588            return Err(CircleTransitionError::AuthorNotStoreWriter);
2589        }
2590        let owner_grant =
2591            MembershipGrantId(generated_id_digest(ids, OWNER_GRANT_ID_GENERATION_DOMAIN));
2592        let author_stream_id = AuthorStreamId::from_digest(generated_id_digest(
2593            ids,
2594            b"coven.circle-transition-draft-stream.v1\0",
2595        ));
2596        let circle_id = CircleId::founder(store_root_hash, &author_pubkey, &owner_grant);
2597        let epoch_id = CircleEpochId::generate(ids);
2598        let keyring = MasterKeyring::generate();
2599        let encryption = EncryptionService::from(keyring.clone());
2600        let key_fingerprint = encryption.seal_key_fingerprint();
2601        let entry = CircleRosterEntry::founder(
2602            store_root_hash,
2603            circle_id,
2604            device_id,
2605            author_stream_id,
2606            owner_grant.clone(),
2607            signer,
2608        );
2609        let roster_objects = FounderRosterObjects {
2610            resolved: CircleRosterChain::from_entries(vec![entry.clone()])?.resolved(),
2611            entry,
2612        };
2613        let roster_state = MergeCircleRosterStateRef {
2614            heads: Vec::new(),
2615            resolutions: Vec::new(),
2616            state_hash: roster_objects.resolved.state_hash,
2617        };
2618        let metadata = CircleMetadata::founder(
2619            store_root_hash,
2620            circle_id,
2621            epoch_id,
2622            name,
2623            metadata_stamp,
2624            device_id,
2625            author_stream_id,
2626            owner_grant.clone(),
2627            roster_state.clone(),
2628            key_fingerprint,
2629            signer,
2630        )?;
2631        let metadata_state = MergeCircleMetadataStateRef {
2632            heads: Vec::new(),
2633            selected: metadata.coord(),
2634            state_hash: metadata.metadata_hash(),
2635        };
2636        let roster = roster_objects.resolved.clone();
2637        let leaves = prepare_access_material(
2638            store_root_hash,
2639            candidate_family,
2640            circle_id,
2641            epoch_id,
2642            &keyring.to_serialized(),
2643            key_fingerprint,
2644            &roster_state,
2645            &roster.members(),
2646            &store_membership,
2647            &store_members,
2648            &std::collections::BTreeMap::new(),
2649            ids,
2650            signer,
2651        )?;
2652        let leaf_hashes = leaves.iter().map(|leaf| leaf.leaf_hash).collect::<Vec<_>>();
2653        let (access_root, proofs) = merkle_root_and_proofs(&leaf_hashes);
2654        let common = ActiveCircleEpochCore {
2655            epoch_id,
2656            key_fingerprint,
2657            owners: vec![author_pubkey.clone()],
2658            access_root,
2659            origin: CircleEpochOrigin::Founder,
2660        };
2661        let value = CircleControlValue {
2662            order: MergeCircleControlOrder {
2663                device_id: device_id.to_string(),
2664                stream_id: author_stream_id,
2665                author_owner_grant: owner_grant.clone(),
2666                seq: 1,
2667                previous_control_hash: None,
2668                dependencies: Vec::new(),
2669            },
2670            state: CircleControlState::ActiveEpoch(MergeActiveCircleEpoch {
2671                common,
2672                metadata: metadata_state,
2673                roster: roster_state.clone(),
2674                store_membership,
2675                covered_control_heads: Vec::new(),
2676            }),
2677            author_authority: MergeCircleOwnerAuthorityRef::Roster {
2678                roster: roster_state,
2679                grant_id: owner_grant.clone(),
2680                created_at: roster_objects.entry.coord(),
2681            },
2682            membership_authority,
2683        };
2684        let mut control_value = CircleControl {
2685            version: STORE_PROTOCOL_VERSION,
2686            store_root_hash,
2687            circle_id,
2688            value,
2689            author_pubkey: author_pubkey.clone(),
2690            signature: String::new(),
2691        };
2692        control_value.signature = keys::sign_hex(signer, &control_value.canonical_bytes()).1;
2693        let control = PreparedCircleControl {
2694            coord: control_value.coord(),
2695            bytes: serde_json::to_vec(&control_value)
2696                .expect("circle control serialization cannot fail"),
2697            value: control_value,
2698        };
2699        let policy_objects = CircleTransitionDraftPolicy {
2700            roster: CircleRosterDraftPolicy::Founder {
2701                entry: roster_objects.entry,
2702            },
2703            metadata_successor: true,
2704        };
2705        let access = prepare_access_envelopes(
2706            store_root_hash,
2707            candidate_family,
2708            circle_id,
2709            &control,
2710            leaves,
2711            proofs,
2712            signer,
2713        );
2714        Ok(Self {
2715            circle_id,
2716            epoch_id,
2717            keyring: keyring.to_serialized(),
2718            roster,
2719            policy: policy_objects,
2720            metadata,
2721            close_intent: None,
2722            close_finalization: None,
2723            close_cancellation: None,
2724            access,
2725            control,
2726        })
2727    }
2728
2729    #[allow(clippy::too_many_arguments)]
2730    pub(crate) fn add_member(
2731        candidate_family: super::store_commit::CandidateFamilyId,
2732        device_id: &str,
2733        store_membership: StoreMembershipStateRef,
2734        membership_authority: MembershipGrantCreationAuthority,
2735        store_members: Vec<(String, MemberRole)>,
2736        current_control: &PreparedCircleControl,
2737        current_roster: &CircleMaterializedRoster,
2738        current_roster_chain: CircleRosterChain,
2739        current_metadata: &CircleMetadata,
2740        keyring: &str,
2741        roster_stream: AuthorStreamId,
2742        member_pubkey: String,
2743        role: super::circle::CircleRole,
2744        bootstrap: CircleBootstrapRef,
2745        ids: &dyn crate::id_provider::IdProvider,
2746        signer: &UserKeypair,
2747    ) -> Result<Self, CircleTransitionError> {
2748        if current_roster_chain.try_resolved()? != *current_roster {
2749            return Err(CircleTransitionError::InvalidCurrentState);
2750        }
2751        let context = circle_successor_context(
2752            store_members,
2753            current_control,
2754            current_roster,
2755            current_metadata,
2756            keyring,
2757            signer,
2758        )?;
2759        let CircleSuccessorContext {
2760            store_members,
2761            author_pubkey,
2762            epoch: active_epoch,
2763            grant_id,
2764            author_authority,
2765            key_fingerprint,
2766        } = context;
2767        if !store_members
2768            .iter()
2769            .any(|(pubkey, _)| pubkey == &member_pubkey)
2770        {
2771            return Err(CircleTransitionError::MemberNotInStore(member_pubkey));
2772        }
2773        let entry = current_roster_chain.signed_set_member(
2774            device_id,
2775            roster_stream,
2776            member_pubkey.clone(),
2777            role,
2778            signer,
2779        )?;
2780        let roster = current_roster_chain.resolved_with_successor(entry.clone())?;
2781        let roster_state = MergeCircleRosterStateRef {
2782            heads: active_epoch.roster.heads.clone(),
2783            resolutions: active_epoch.roster.resolutions.clone(),
2784            state_hash: roster.state_hash,
2785        };
2786        let store_root_hash = current_control.value.store_root_hash;
2787        let circle_id = current_control.value.circle_id;
2788        let epoch_id = current_control.value.epoch_id();
2789        let mut control_value = CircleControl {
2790            version: STORE_PROTOCOL_VERSION,
2791            store_root_hash,
2792            circle_id,
2793            value: CircleControlValue {
2794                order: MergeCircleControlOrder {
2795                    device_id: device_id.to_string(),
2796                    stream_id: roster_stream,
2797                    author_owner_grant: grant_id.clone(),
2798                    seq: current_control
2799                        .value
2800                        .ordinal()
2801                        .checked_add(1)
2802                        .ok_or(CircleTransitionError::SequenceOverflow)?,
2803                    previous_control_hash: Some(current_control.coord.control_hash()),
2804                    dependencies: vec![current_control.coord.clone()],
2805                },
2806                state: CircleControlState::ActiveEpoch(MergeActiveCircleEpoch {
2807                    common: active_epoch.common.clone(),
2808                    metadata: active_epoch.metadata.clone(),
2809                    roster: roster_state.clone(),
2810                    store_membership,
2811                    covered_control_heads: active_epoch.covered_control_heads.clone(),
2812                }),
2813                author_authority,
2814                membership_authority,
2815            },
2816            author_pubkey,
2817            signature: String::new(),
2818        };
2819        let bootstraps =
2820            std::collections::BTreeMap::from([(member_pubkey.clone(), bootstrap.clone())]);
2821        let leaves = prepare_access_material(
2822            store_root_hash,
2823            candidate_family,
2824            circle_id,
2825            epoch_id,
2826            keyring,
2827            key_fingerprint,
2828            &roster_state,
2829            &roster.members(),
2830            &control_value
2831                .value
2832                .state
2833                .active_epoch()
2834                .expect("member addition constructs an active epoch")
2835                .store_membership,
2836            &store_members,
2837            &bootstraps,
2838            ids,
2839            signer,
2840        )?;
2841        let leaf_hashes = leaves.iter().map(|leaf| leaf.leaf_hash).collect::<Vec<_>>();
2842        let (access_root, proofs) = merkle_root_and_proofs(&leaf_hashes);
2843        control_value
2844            .value
2845            .state
2846            .active_epoch_mut()
2847            .expect("member addition constructs an active epoch")
2848            .common
2849            .access_root = access_root;
2850        control_value.signature = keys::sign_hex(signer, &control_value.canonical_bytes()).1;
2851        let control = PreparedCircleControl {
2852            coord: control_value.coord(),
2853            bytes: serde_json::to_vec(&control_value)
2854                .expect("circle control serialization cannot fail"),
2855            value: control_value,
2856        };
2857        let access = prepare_access_envelopes(
2858            store_root_hash,
2859            candidate_family,
2860            circle_id,
2861            &control,
2862            leaves,
2863            proofs,
2864            signer,
2865        );
2866        Ok(Self {
2867            circle_id,
2868            epoch_id,
2869            keyring: keyring.to_string(),
2870            roster,
2871            policy: CircleTransitionDraftPolicy {
2872                roster: CircleRosterDraftPolicy::Successor {
2873                    predecessor: current_roster_chain,
2874                    entry,
2875                },
2876                metadata_successor: false,
2877            },
2878            metadata: current_metadata.clone(),
2879            close_intent: None,
2880            close_finalization: None,
2881            close_cancellation: None,
2882            access,
2883            control,
2884        })
2885    }
2886
2887    #[allow(clippy::too_many_arguments)]
2888    pub(crate) fn close_epoch(
2889        candidate_family: super::store_commit::CandidateFamilyId,
2890        device_id: &str,
2891        store_membership: StoreMembershipStateRef,
2892        membership_authority: MembershipGrantCreationAuthority,
2893        store_members: Vec<(String, MemberRole)>,
2894        current_control: &PreparedCircleControl,
2895        current_roster: &CircleMaterializedRoster,
2896        current_metadata: &CircleMetadata,
2897        keyring: &str,
2898        close_id: CircleEpochCloseId,
2899        close_intent: CircleEpochCloseIntent,
2900        intent: CircleEpochCloseIntentRef,
2901        frozen_device_state: StoreDeviceStateRef,
2902        participants: Vec<CircleEpochCloseParticipant>,
2903        provisional_frontier: CommitFrontier,
2904        outcome_slot: ObjectSlot,
2905        ids: &dyn crate::id_provider::IdProvider,
2906        signer: &UserKeypair,
2907    ) -> Result<Self, CircleTransitionError> {
2908        let context = circle_successor_context(
2909            store_members,
2910            current_control,
2911            current_roster,
2912            current_metadata,
2913            keyring,
2914            signer,
2915        )?;
2916        let CircleSuccessorContext {
2917            store_members,
2918            author_pubkey,
2919            epoch: active_epoch,
2920            grant_id,
2921            author_authority,
2922            key_fingerprint,
2923        } = context;
2924        let store_root_hash = current_control.value.store_root_hash;
2925        let circle_id = current_control.value.circle_id;
2926        let epoch_id = current_control.value.epoch_id();
2927        if close_intent.close_id != close_id
2928            || close_intent.intent_hash() != intent.intent_hash
2929            || close_intent.circle_id != circle_id
2930            || close_intent.epoch_id != epoch_id
2931        {
2932            return Err(CircleTransitionError::InvalidCurrentState);
2933        }
2934        let roster_state = active_epoch.roster.clone();
2935        let mut control_value = CircleControl {
2936            version: STORE_PROTOCOL_VERSION,
2937            store_root_hash,
2938            circle_id,
2939            value: CircleControlValue {
2940                order: MergeCircleControlOrder {
2941                    device_id: device_id.to_string(),
2942                    stream_id: active_epoch
2943                        .covered_control_heads
2944                        .iter()
2945                        .find(|head| {
2946                            head.coord.author_pubkey == author_pubkey
2947                                && head.coord.device_id == device_id
2948                                && head.coord.author_owner_grant == grant_id
2949                        })
2950                        .map_or_else(
2951                            || {
2952                                AuthorStreamId::from_digest(generated_id_digest(
2953                                    ids,
2954                                    b"coven.circle-transition-draft-stream.v1\0",
2955                                ))
2956                            },
2957                            |head| head.coord.stream_id,
2958                        ),
2959                    author_owner_grant: grant_id,
2960                    seq: current_control
2961                        .value
2962                        .ordinal()
2963                        .checked_add(1)
2964                        .ok_or(CircleTransitionError::SequenceOverflow)?,
2965                    previous_control_hash: Some(current_control.coord.control_hash()),
2966                    dependencies: vec![current_control.coord.clone()],
2967                },
2968                state: CircleControlState::EpochClose(CircleEpochClose {
2969                    close_id,
2970                    frozen_epoch: MergeActiveCircleEpoch {
2971                        common: active_epoch.common.clone(),
2972                        metadata: active_epoch.metadata.clone(),
2973                        roster: roster_state.clone(),
2974                        store_membership,
2975                        covered_control_heads: active_epoch.covered_control_heads.clone(),
2976                    },
2977                    intent,
2978                    frozen_device_state,
2979                    participants,
2980                    provisional_frontier,
2981                    outcome_slot,
2982                }),
2983                author_authority,
2984                membership_authority,
2985            },
2986            author_pubkey,
2987            signature: String::new(),
2988        };
2989        let leaves = prepare_access_material(
2990            store_root_hash,
2991            candidate_family,
2992            circle_id,
2993            epoch_id,
2994            keyring,
2995            key_fingerprint,
2996            &roster_state,
2997            &current_roster.members(),
2998            &control_value.access_epoch().store_membership,
2999            &store_members,
3000            &std::collections::BTreeMap::new(),
3001            ids,
3002            signer,
3003        )?;
3004        let leaf_hashes = leaves.iter().map(|leaf| leaf.leaf_hash).collect::<Vec<_>>();
3005        let (access_root, proofs) = merkle_root_and_proofs(&leaf_hashes);
3006        control_value
3007            .value
3008            .state
3009            .access_epoch_mut()
3010            .common
3011            .access_root = access_root;
3012        control_value.signature = keys::sign_hex(signer, &control_value.canonical_bytes()).1;
3013        let control = PreparedCircleControl {
3014            coord: control_value.coord(),
3015            bytes: serde_json::to_vec(&control_value)
3016                .expect("circle control serialization cannot fail"),
3017            value: control_value,
3018        };
3019        let access = prepare_access_envelopes(
3020            store_root_hash,
3021            candidate_family,
3022            circle_id,
3023            &control,
3024            leaves,
3025            proofs,
3026            signer,
3027        );
3028        Ok(Self {
3029            circle_id,
3030            epoch_id,
3031            keyring: keyring.to_string(),
3032            roster: current_roster.clone(),
3033            policy: CircleTransitionDraftPolicy {
3034                roster: CircleRosterDraftPolicy::Inherited,
3035                metadata_successor: false,
3036            },
3037            metadata: current_metadata.clone(),
3038            close_intent: Some(close_intent),
3039            close_finalization: None,
3040            close_cancellation: None,
3041            access,
3042            control,
3043        })
3044    }
3045
3046    #[allow(clippy::too_many_arguments)]
3047    pub(crate) fn finalize_epoch_close(
3048        candidate_family: super::store_commit::CandidateFamilyId,
3049        device_id: &str,
3050        author_registration: &StoreDeviceRegistrationRef,
3051        metadata_stamp: &str,
3052        store_membership: StoreMembershipStateRef,
3053        membership_authority: MembershipGrantCreationAuthority,
3054        mut store_members: Vec<(String, MemberRole)>,
3055        close_control: &PreparedCircleControl,
3056        current_roster: &CircleMaterializedRoster,
3057        current_roster_chain: CircleRosterChain,
3058        current_metadata: &CircleMetadata,
3059        keyring: &str,
3060        intent: CircleEpochCloseIntent,
3061        responses: Vec<CircleEpochCloseSettlement>,
3062        ids: &dyn crate::id_provider::IdProvider,
3063        signer: &UserKeypair,
3064    ) -> Result<Self, CircleTransitionError> {
3065        let CircleControlState::EpochClose(close) = close_control.value.state() else {
3066            return Err(CircleTransitionError::InvalidCurrentState);
3067        };
3068        if !close_control.verify()
3069            || current_roster_chain.try_resolved()? != *current_roster
3070            || current_roster.state_hash() != close.frozen_epoch.roster.state_hash
3071            || current_metadata.coord() != close.frozen_epoch.metadata.selected
3072            || current_metadata.epoch_id != close.frozen_epoch.common.epoch_id
3073            || current_metadata.key_fingerprint != close.frozen_epoch.common.key_fingerprint
3074            || intent.intent_hash() != close.intent.intent_hash
3075            || intent.close_id != close.close_id
3076        {
3077            return Err(CircleTransitionError::InvalidCurrentState);
3078        }
3079        let author_pubkey = keys::public_key_hex(signer);
3080        store_members.sort_by(|left, right| left.0.cmp(&right.0));
3081        store_members.dedup_by(|left, right| left.0 == right.0);
3082        if !store_members
3083            .iter()
3084            .any(|(pubkey, role)| pubkey == &author_pubkey && role.can_write())
3085        {
3086            return Err(CircleTransitionError::AuthorNotStoreWriter);
3087        }
3088        let (grant_id, owner_record) = current_roster
3089            .active_grants()
3090            .find(|(_, record)| {
3091                record.member_pubkey == author_pubkey
3092                    && record.role == super::circle::CircleRole::Owner
3093            })
3094            .ok_or(CircleTransitionError::AuthorNotCircleOwner)?;
3095        let author_authority = match &owner_record.creation_authority {
3096            CircleGrantCreationAuthority::Entry(created_at) => {
3097                MergeCircleOwnerAuthorityRef::Roster {
3098                    roster: close.frozen_epoch.roster.clone(),
3099                    grant_id: grant_id.clone(),
3100                    created_at: created_at.clone(),
3101                }
3102            }
3103            CircleGrantCreationAuthority::ConflictResolution(resolution) => {
3104                MergeCircleOwnerAuthorityRef::ConflictResolution {
3105                    conflict_hash: resolution.conflict_hash,
3106                    resolution_hash: resolution.resolution_hash,
3107                }
3108            }
3109        };
3110        let old_encryption = EncryptionService::from(
3111            MasterKeyring::from_serialized(keyring)
3112                .map_err(|_| CircleTransitionError::InvalidCurrentState)?,
3113        );
3114        if old_encryption.seal_key_fingerprint() != close.frozen_epoch.common.key_fingerprint {
3115            return Err(CircleTransitionError::InvalidCurrentState);
3116        }
3117        let new_generation = old_encryption
3118            .current_generation()
3119            .checked_add(1)
3120            .ok_or(CircleTransitionError::SequenceOverflow)?;
3121        let encryption = old_encryption
3122            .with_appended_generation(new_generation, crate::encryption::generate_random_key())
3123            .map_err(|_| CircleTransitionError::InvalidCurrentState)?;
3124        let keyring = encryption
3125            .to_keyring_string()
3126            .map_err(|_| CircleTransitionError::InvalidCurrentState)?;
3127        let key_fingerprint = encryption.seal_key_fingerprint();
3128        let epoch_id = CircleEpochId::generate(ids);
3129        let roster = current_roster_chain.resolved_with_successor(intent.removal.clone())?;
3130        if roster.state_hash() != intent.remaining_roster_state_hash {
3131            return Err(CircleTransitionError::InvalidCurrentState);
3132        }
3133        let roster_members = roster.members();
3134        let owners = roster_members
3135            .iter()
3136            .filter_map(|(pubkey, role)| {
3137                (*role == super::circle::CircleRole::Owner).then_some(pubkey.clone())
3138            })
3139            .collect::<Vec<_>>();
3140        if owners.is_empty() {
3141            return Err(CircleTransitionError::InvalidCurrentState);
3142        }
3143        let roster_state = MergeCircleRosterStateRef {
3144            heads: close.frozen_epoch.roster.heads.clone(),
3145            resolutions: close.frozen_epoch.roster.resolutions.clone(),
3146            state_hash: roster.state_hash(),
3147        };
3148        let metadata_stream = super::store_commit::StreamActivation::grant_authorized_stream_id(
3149            close_control.value.store_root_hash,
3150            author_registration,
3151            grant_id,
3152            super::store_commit::StreamAnchorDomain::CircleMetadata {
3153                circle_id: close_control.value.circle_id,
3154            },
3155        );
3156        let prior_metadata = close
3157            .frozen_epoch
3158            .metadata
3159            .heads
3160            .iter()
3161            .find(|head| head.coord.stream_id == metadata_stream);
3162        let mut metadata = current_metadata.clone();
3163        metadata.epoch_id = epoch_id;
3164        metadata.metadata_stamp = metadata_stamp.to_string();
3165        metadata.author_pubkey = author_pubkey.clone();
3166        metadata.device_id = device_id.to_string();
3167        metadata.stream_id = metadata_stream;
3168        metadata.author_owner_grant = grant_id.clone();
3169        metadata.seq = prior_metadata.map_or(Ok(1), |head| {
3170            head.coord
3171                .seq
3172                .checked_add(1)
3173                .ok_or(CircleTransitionError::SequenceOverflow)
3174        })?;
3175        metadata.previous_hash = prior_metadata.map(|head| head.coord.metadata_hash);
3176        metadata.dependencies = close
3177            .frozen_epoch
3178            .metadata
3179            .heads
3180            .iter()
3181            .map(|head| head.coord.clone())
3182            .collect();
3183        metadata.author_roster = roster_state.clone();
3184        metadata.key_fingerprint = key_fingerprint;
3185        metadata.signature = keys::sign_hex(signer, &metadata.canonical_bytes()).1;
3186        let metadata_state = MergeCircleMetadataStateRef {
3187            heads: close.frozen_epoch.metadata.heads.clone(),
3188            selected: metadata.coord(),
3189            state_hash: metadata.metadata_hash(),
3190        };
3191        let mut control_value = CircleControl {
3192            version: STORE_PROTOCOL_VERSION,
3193            store_root_hash: close_control.value.store_root_hash,
3194            circle_id: close_control.value.circle_id,
3195            value: CircleControlValue {
3196                order: MergeCircleControlOrder {
3197                    device_id: device_id.to_string(),
3198                    stream_id: close_control.value.value.order.stream_id,
3199                    author_owner_grant: grant_id.clone(),
3200                    seq: close_control
3201                        .value
3202                        .ordinal()
3203                        .checked_add(1)
3204                        .ok_or(CircleTransitionError::SequenceOverflow)?,
3205                    previous_control_hash: Some(close_control.coord.control_hash()),
3206                    dependencies: vec![close_control.coord.clone()],
3207                },
3208                state: CircleControlState::ActiveEpoch(MergeActiveCircleEpoch {
3209                    common: ActiveCircleEpochCore {
3210                        epoch_id,
3211                        key_fingerprint,
3212                        owners,
3213                        access_root: close.frozen_epoch.common.access_root,
3214                        origin: close.frozen_epoch.common.origin.clone(),
3215                    },
3216                    metadata: metadata_state,
3217                    roster: roster_state.clone(),
3218                    store_membership,
3219                    covered_control_heads: close.frozen_epoch.covered_control_heads.clone(),
3220                }),
3221                author_authority,
3222                membership_authority,
3223            },
3224            author_pubkey,
3225            signature: String::new(),
3226        };
3227        let leaves = prepare_access_material(
3228            control_value.store_root_hash,
3229            candidate_family,
3230            control_value.circle_id,
3231            epoch_id,
3232            &keyring,
3233            key_fingerprint,
3234            &roster_state,
3235            &roster_members,
3236            &control_value.access_epoch().store_membership,
3237            &store_members,
3238            &std::collections::BTreeMap::new(),
3239            ids,
3240            signer,
3241        )?;
3242        let leaf_hashes = leaves.iter().map(|leaf| leaf.leaf_hash).collect::<Vec<_>>();
3243        let (access_root, proofs) = merkle_root_and_proofs(&leaf_hashes);
3244        control_value
3245            .value
3246            .state
3247            .active_epoch_mut()
3248            .expect("Circle finalization constructs an active epoch")
3249            .common
3250            .access_root = access_root;
3251        let control = PreparedCircleControl {
3252            coord: control_value.coord(),
3253            bytes: serde_json::to_vec(&control_value)
3254                .expect("Circle control serialization cannot fail"),
3255            value: control_value,
3256        };
3257        let access = prepare_access_envelopes(
3258            control.value.store_root_hash,
3259            candidate_family,
3260            control.value.circle_id,
3261            &control,
3262            leaves,
3263            proofs,
3264            signer,
3265        );
3266        Ok(Self {
3267            circle_id: control.value.circle_id,
3268            epoch_id,
3269            keyring,
3270            roster,
3271            policy: CircleTransitionDraftPolicy {
3272                roster: CircleRosterDraftPolicy::Successor {
3273                    predecessor: current_roster_chain,
3274                    entry: intent.removal.clone(),
3275                },
3276                metadata_successor: true,
3277            },
3278            metadata,
3279            close_intent: None,
3280            close_finalization: Some(CircleEpochCloseFinalizationDraft {
3281                close_control: close_control.clone(),
3282                intent,
3283                responses,
3284                outcome_slot: close.outcome_slot.clone(),
3285            }),
3286            close_cancellation: None,
3287            access,
3288            control,
3289        })
3290    }
3291
3292    /// Reopen a frozen epoch by cancelling its close. The successor restores the
3293    /// frozen epoch's protocol identity — same epoch, key generation, roster and
3294    /// metadata frontiers, and origin — re-issuing only the control-bound access
3295    /// material to the reopening control.
3296    #[allow(clippy::too_many_arguments)]
3297    pub(crate) fn reopen_epoch(
3298        candidate_family: super::store_commit::CandidateFamilyId,
3299        device_id: &str,
3300        store_membership: StoreMembershipStateRef,
3301        membership_authority: MembershipGrantCreationAuthority,
3302        mut store_members: Vec<(String, MemberRole)>,
3303        close_control: &PreparedCircleControl,
3304        current_roster: &CircleMaterializedRoster,
3305        current_metadata: &CircleMetadata,
3306        keyring: &str,
3307        ids: &dyn crate::id_provider::IdProvider,
3308        signer: &UserKeypair,
3309    ) -> Result<Self, CircleTransitionError> {
3310        let CircleControlState::EpochClose(close) = close_control.value.state() else {
3311            return Err(CircleTransitionError::InvalidCurrentState);
3312        };
3313        let frozen = &close.frozen_epoch;
3314        if !close_control.verify()
3315            || current_roster.state_hash() != frozen.roster.state_hash
3316            || current_metadata.coord() != frozen.metadata.selected
3317            || current_metadata.epoch_id != frozen.common.epoch_id
3318            || current_metadata.key_fingerprint != frozen.common.key_fingerprint
3319        {
3320            return Err(CircleTransitionError::InvalidCurrentState);
3321        }
3322        let author_pubkey = keys::public_key_hex(signer);
3323        store_members.sort_by(|left, right| left.0.cmp(&right.0));
3324        store_members.dedup_by(|left, right| left.0 == right.0);
3325        if !store_members
3326            .iter()
3327            .any(|(pubkey, role)| pubkey == &author_pubkey && role.can_write())
3328        {
3329            return Err(CircleTransitionError::AuthorNotStoreWriter);
3330        }
3331        let (grant_id, owner_record) = current_roster
3332            .active_grants()
3333            .find(|(_, record)| {
3334                record.member_pubkey == author_pubkey
3335                    && record.role == super::circle::CircleRole::Owner
3336            })
3337            .ok_or(CircleTransitionError::AuthorNotCircleOwner)?;
3338        let author_authority = match &owner_record.creation_authority {
3339            CircleGrantCreationAuthority::Entry(created_at) => {
3340                MergeCircleOwnerAuthorityRef::Roster {
3341                    roster: frozen.roster.clone(),
3342                    grant_id: grant_id.clone(),
3343                    created_at: created_at.clone(),
3344                }
3345            }
3346            CircleGrantCreationAuthority::ConflictResolution(resolution) => {
3347                MergeCircleOwnerAuthorityRef::ConflictResolution {
3348                    conflict_hash: resolution.conflict_hash,
3349                    resolution_hash: resolution.resolution_hash,
3350                }
3351            }
3352        };
3353        let encryption = EncryptionService::from(
3354            MasterKeyring::from_serialized(keyring)
3355                .map_err(|_| CircleTransitionError::InvalidCurrentState)?,
3356        );
3357        let key_fingerprint = encryption.seal_key_fingerprint();
3358        if key_fingerprint != frozen.common.key_fingerprint {
3359            return Err(CircleTransitionError::InvalidCurrentState);
3360        }
3361        let epoch_id = frozen.common.epoch_id;
3362        let roster_state = frozen.roster.clone();
3363        let mut control_value = CircleControl {
3364            version: STORE_PROTOCOL_VERSION,
3365            store_root_hash: close_control.value.store_root_hash,
3366            circle_id: close_control.value.circle_id,
3367            value: CircleControlValue {
3368                order: MergeCircleControlOrder {
3369                    device_id: device_id.to_string(),
3370                    stream_id: close_control.value.value.order.stream_id,
3371                    author_owner_grant: grant_id.clone(),
3372                    seq: close_control
3373                        .value
3374                        .ordinal()
3375                        .checked_add(1)
3376                        .ok_or(CircleTransitionError::SequenceOverflow)?,
3377                    previous_control_hash: Some(close_control.coord.control_hash()),
3378                    dependencies: vec![close_control.coord.clone()],
3379                },
3380                state: CircleControlState::ActiveEpoch(MergeActiveCircleEpoch {
3381                    common: ActiveCircleEpochCore {
3382                        epoch_id,
3383                        key_fingerprint,
3384                        owners: frozen.common.owners.clone(),
3385                        access_root: frozen.common.access_root,
3386                        origin: frozen.common.origin.clone(),
3387                    },
3388                    metadata: frozen.metadata.clone(),
3389                    roster: roster_state.clone(),
3390                    store_membership,
3391                    covered_control_heads: frozen.covered_control_heads.clone(),
3392                }),
3393                author_authority,
3394                membership_authority,
3395            },
3396            author_pubkey,
3397            signature: String::new(),
3398        };
3399        let leaves = prepare_access_material(
3400            control_value.store_root_hash,
3401            candidate_family,
3402            control_value.circle_id,
3403            epoch_id,
3404            keyring,
3405            key_fingerprint,
3406            &roster_state,
3407            &current_roster.members(),
3408            &control_value.access_epoch().store_membership,
3409            &store_members,
3410            &std::collections::BTreeMap::new(),
3411            ids,
3412            signer,
3413        )?;
3414        let leaf_hashes = leaves.iter().map(|leaf| leaf.leaf_hash).collect::<Vec<_>>();
3415        let (access_root, proofs) = merkle_root_and_proofs(&leaf_hashes);
3416        control_value
3417            .value
3418            .state
3419            .active_epoch_mut()
3420            .expect("Circle reopen constructs an active epoch")
3421            .common
3422            .access_root = access_root;
3423        control_value.signature = keys::sign_hex(signer, &control_value.canonical_bytes()).1;
3424        let control = PreparedCircleControl {
3425            coord: control_value.coord(),
3426            bytes: serde_json::to_vec(&control_value)
3427                .expect("Circle control serialization cannot fail"),
3428            value: control_value,
3429        };
3430        let access = prepare_access_envelopes(
3431            control.value.store_root_hash,
3432            candidate_family,
3433            control.value.circle_id,
3434            &control,
3435            leaves,
3436            proofs,
3437            signer,
3438        );
3439        Ok(Self {
3440            circle_id: control.value.circle_id,
3441            epoch_id,
3442            keyring: keyring.to_string(),
3443            roster: current_roster.clone(),
3444            policy: CircleTransitionDraftPolicy {
3445                roster: CircleRosterDraftPolicy::Inherited,
3446                metadata_successor: false,
3447            },
3448            metadata: current_metadata.clone(),
3449            close_intent: None,
3450            close_finalization: None,
3451            close_cancellation: Some(CircleEpochCloseCancellationDraft {
3452                close_control: close_control.clone(),
3453                outcome_slot: close.outcome_slot.clone(),
3454            }),
3455            access,
3456            control,
3457        })
3458    }
3459
3460    #[allow(clippy::too_many_arguments)]
3461    pub(crate) fn rename(
3462        candidate_family: super::store_commit::CandidateFamilyId,
3463        device_id: &str,
3464        name: &str,
3465        metadata_stamp: &str,
3466        store_membership: StoreMembershipStateRef,
3467        membership_authority: MembershipGrantCreationAuthority,
3468        store_members: Vec<(String, MemberRole)>,
3469        current_control: &PreparedCircleControl,
3470        current_roster: &CircleMaterializedRoster,
3471        current_metadata: &CircleMetadata,
3472        keyring: &str,
3473        ids: &dyn crate::id_provider::IdProvider,
3474        signer: &UserKeypair,
3475    ) -> Result<Self, CircleTransitionError> {
3476        if name.trim().is_empty() {
3477            return Err(CircleTransitionError::EmptyName);
3478        }
3479        let context = circle_successor_context(
3480            store_members,
3481            current_control,
3482            current_roster,
3483            current_metadata,
3484            keyring,
3485            signer,
3486        )?;
3487        let CircleSuccessorContext {
3488            store_members,
3489            author_pubkey,
3490            epoch: active_epoch,
3491            grant_id,
3492            author_authority,
3493            key_fingerprint,
3494        } = context;
3495        let store_root_hash = current_control.value.store_root_hash;
3496        let circle_id = current_control.value.circle_id;
3497        let epoch_id = current_control.value.epoch_id();
3498        let roster_state = current_control.value.roster_state_ref();
3499
3500        let own_head = active_epoch.metadata.heads.iter().find(|head| {
3501            head.coord.author_pubkey == author_pubkey
3502                && head.coord.device_id == device_id
3503                && head.coord.author_owner_grant == grant_id
3504        });
3505        let author_stream_id = own_head.map_or_else(
3506            || {
3507                AuthorStreamId::from_digest(generated_id_digest(
3508                    ids,
3509                    b"coven.circle-transition-draft-stream.v1\0",
3510                ))
3511            },
3512            |head| head.coord.stream_id,
3513        );
3514        let metadata_seq = match own_head {
3515            Some(head) => head
3516                .coord
3517                .seq
3518                .checked_add(1)
3519                .ok_or(CircleTransitionError::SequenceOverflow)?,
3520            None => 1,
3521        };
3522        let metadata_previous = own_head.map(|head| head.coord.metadata_hash);
3523        let metadata_dependencies = active_epoch
3524            .metadata
3525            .heads
3526            .iter()
3527            .map(|head| head.coord.clone())
3528            .collect::<Vec<_>>();
3529        let metadata_state = active_epoch.metadata.clone();
3530        let mut control_value = CircleControlValue {
3531            order: MergeCircleControlOrder {
3532                device_id: device_id.to_string(),
3533                stream_id: author_stream_id,
3534                author_owner_grant: grant_id.clone(),
3535                seq: current_control
3536                    .value
3537                    .ordinal()
3538                    .checked_add(1)
3539                    .ok_or(CircleTransitionError::SequenceOverflow)?,
3540                previous_control_hash: Some(current_control.coord.control_hash()),
3541                dependencies: vec![current_control.coord.clone()],
3542            },
3543            state: CircleControlState::ActiveEpoch(MergeActiveCircleEpoch {
3544                common: active_epoch.common.clone(),
3545                metadata: active_epoch.metadata.clone(),
3546                roster: active_epoch.roster.clone(),
3547                store_membership,
3548                covered_control_heads: active_epoch.covered_control_heads.clone(),
3549            }),
3550            author_authority,
3551            membership_authority,
3552        };
3553        let author_owner_grant = grant_id.clone();
3554
3555        let mut metadata = CircleMetadata {
3556            version: STORE_PROTOCOL_VERSION,
3557            store_root_hash,
3558            circle_id,
3559            epoch_id,
3560            name: name.to_string(),
3561            seq: metadata_seq,
3562            previous_hash: metadata_previous,
3563            dependencies: metadata_dependencies,
3564            metadata_stamp: metadata_stamp.to_string(),
3565            author_pubkey: author_pubkey.clone(),
3566            device_id: device_id.to_string(),
3567            stream_id: author_stream_id,
3568            author_owner_grant,
3569            author_roster: roster_state.clone(),
3570            key_fingerprint,
3571            signature: String::new(),
3572        };
3573        metadata.signature = keys::sign_hex(signer, &metadata.canonical_bytes()).1;
3574
3575        let mut metadata_state = metadata_state;
3576        let selected = [current_metadata, &metadata]
3577            .into_iter()
3578            .max_by_key(|entry| {
3579                (
3580                    entry.metadata_stamp.as_str(),
3581                    entry.author_pubkey.as_str(),
3582                    entry.device_id.as_str(),
3583                    entry.metadata_hash(),
3584                )
3585            })
3586            .expect("current and successor metadata are non-empty");
3587        metadata_state.selected = selected.coord();
3588        metadata_state.state_hash = selected.metadata_hash();
3589
3590        let leaves = prepare_access_material(
3591            store_root_hash,
3592            candidate_family,
3593            circle_id,
3594            epoch_id,
3595            keyring,
3596            key_fingerprint,
3597            &roster_state,
3598            &current_roster.members(),
3599            &control_value
3600                .state
3601                .active_epoch()
3602                .expect("rename constructs an active epoch")
3603                .store_membership,
3604            &store_members,
3605            &std::collections::BTreeMap::new(),
3606            ids,
3607            signer,
3608        )?;
3609        let leaf_hashes = leaves.iter().map(|leaf| leaf.leaf_hash).collect::<Vec<_>>();
3610        let (access_root, proofs) = merkle_root_and_proofs(&leaf_hashes);
3611        let active_epoch = control_value
3612            .state
3613            .active_epoch_mut()
3614            .expect("rename constructs an active epoch");
3615        active_epoch.common.access_root = access_root;
3616        active_epoch.metadata = metadata_state;
3617        let mut control_value = CircleControl {
3618            version: STORE_PROTOCOL_VERSION,
3619            store_root_hash,
3620            circle_id,
3621            value: control_value,
3622            author_pubkey,
3623            signature: String::new(),
3624        };
3625        control_value.signature = keys::sign_hex(signer, &control_value.canonical_bytes()).1;
3626        let control = PreparedCircleControl {
3627            coord: control_value.coord(),
3628            bytes: serde_json::to_vec(&control_value)
3629                .expect("circle control serialization cannot fail"),
3630            value: control_value,
3631        };
3632        let policy_objects = CircleTransitionDraftPolicy {
3633            roster: CircleRosterDraftPolicy::Inherited,
3634            metadata_successor: true,
3635        };
3636        let access = prepare_access_envelopes(
3637            store_root_hash,
3638            candidate_family,
3639            circle_id,
3640            &control,
3641            leaves,
3642            proofs,
3643            signer,
3644        );
3645        Ok(Self {
3646            circle_id,
3647            epoch_id,
3648            keyring: keyring.to_string(),
3649            roster: current_roster.clone(),
3650            policy: policy_objects,
3651            metadata,
3652            close_intent: None,
3653            close_finalization: None,
3654            close_cancellation: None,
3655            access,
3656            control,
3657        })
3658    }
3659
3660    /// Build a successor of the chosen conflicting branch that causally covers
3661    /// every branch, collapsing a retained `ControlConflict` to a single
3662    /// resolved control. The successor inherits the chosen branch's epoch, key
3663    /// generation, and roster contents verbatim — it changes no membership, keys,
3664    /// or deletion intent. It merges the control, metadata, and roster head
3665    /// frontiers across every branch (the union of covered heads), so a device
3666    /// that authored a losing branch continues its own author streams instead of
3667    /// re-allocating their head slots. The name is not inherited from the chosen
3668    /// branch but re-derived as the deterministic metadata selection over the
3669    /// merged frontier: the metadata layer resolves its own conflict — the
3670    /// canonical maximum across every covered head — independent of which control
3671    /// branch the Owner chose. `losing_branches` are the retained branches other
3672    /// than `chosen`; preparation adds the chosen branch head, so the resolved
3673    /// control's causal dependencies and predecessor together name every branch.
3674    #[allow(clippy::too_many_arguments)]
3675    pub(crate) fn resolve(
3676        candidate_family: super::store_commit::CandidateFamilyId,
3677        device_id: &str,
3678        store_membership: StoreMembershipStateRef,
3679        membership_authority: MembershipGrantCreationAuthority,
3680        store_members: Vec<(String, MemberRole)>,
3681        chosen_control: &PreparedCircleControl,
3682        chosen_roster: &CircleMaterializedRoster,
3683        chosen_metadata: &CircleMetadata,
3684        keyring: &str,
3685        losing_branches: Vec<ResolvedConflictBranch>,
3686        ids: &dyn crate::id_provider::IdProvider,
3687        signer: &UserKeypair,
3688    ) -> Result<Self, CircleTransitionError> {
3689        let context = circle_successor_context(
3690            store_members,
3691            chosen_control,
3692            chosen_roster,
3693            chosen_metadata,
3694            keyring,
3695            signer,
3696        )?;
3697        let CircleSuccessorContext {
3698            store_members,
3699            author_pubkey,
3700            epoch: active_epoch,
3701            grant_id: _,
3702            author_authority,
3703            key_fingerprint,
3704        } = context;
3705        let store_root_hash = chosen_control.value.store_root_hash;
3706        let circle_id = chosen_control.value.circle_id;
3707        let epoch_id = chosen_control.value.epoch_id();
3708        let roster_state = chosen_control.value.roster_state_ref();
3709
3710        // Merge the control, metadata, and roster head frontiers across every
3711        // branch: the chosen branch's frontiers extended by each losing branch's,
3712        // one head per author stream at its deepest position. Every branch's
3713        // heads become covered, so no author-stream head is re-allocated once the
3714        // conflict collapses. Preparation adds the chosen branch head and derives
3715        // the predecessor and dependencies from the control frontier, so the
3716        // resolved control directly names every branch.
3717        let mut covered_control_heads = active_epoch.covered_control_heads.clone();
3718        let mut metadata = active_epoch.metadata.clone();
3719        let mut roster = active_epoch.roster.clone();
3720        for branch in &losing_branches {
3721            merge_frontier_head(
3722                &mut covered_control_heads,
3723                branch.control_head.clone(),
3724                |head| head.coord.stream_key(),
3725                |head| head.coord.seq,
3726            );
3727            for head in &branch.metadata_heads {
3728                merge_frontier_head(
3729                    &mut metadata.heads,
3730                    head.clone(),
3731                    |head| head.coord.stream_key(),
3732                    |head| head.coord.seq,
3733                );
3734            }
3735            for head in &branch.roster_heads {
3736                merge_frontier_head(
3737                    &mut roster.heads,
3738                    head.clone(),
3739                    |head| head.coord.stream_key(),
3740                    |head| head.coord.seq,
3741                );
3742            }
3743        }
3744        covered_control_heads.sort_by_key(|head| head.coord.stream_key());
3745        metadata.heads.sort_by_key(|head| head.coord.stream_key());
3746        roster.heads.sort_by_key(|head| head.coord.stream_key());
3747
3748        // The name is the deterministic metadata selection across the merged
3749        // frontier. Each branch's selected metadata is already the canonical
3750        // maximum over its own covered history, so the maximum across the branch
3751        // selections is the canonical selection over their union.
3752        let selected_metadata = std::iter::once(chosen_metadata)
3753            .chain(
3754                losing_branches
3755                    .iter()
3756                    .map(|branch| &branch.selected_metadata),
3757            )
3758            .max_by_key(|entry| {
3759                (
3760                    entry.metadata_stamp.clone(),
3761                    entry.author_pubkey.clone(),
3762                    entry.device_id.clone(),
3763                    entry.metadata_hash(),
3764                )
3765            })
3766            .expect("a resolution names at least the chosen branch's metadata")
3767            .clone();
3768        metadata.selected = selected_metadata.coord();
3769        metadata.state_hash = selected_metadata.metadata_hash();
3770
3771        let mut control_value = CircleControl {
3772            version: STORE_PROTOCOL_VERSION,
3773            store_root_hash,
3774            circle_id,
3775            value: CircleControlValue {
3776                order: MergeCircleControlOrder {
3777                    device_id: device_id.to_string(),
3778                    stream_id: active_epoch
3779                        .covered_control_heads
3780                        .iter()
3781                        .find(|head| head.coord.stream_key().author_pubkey == author_pubkey)
3782                        .map_or_else(
3783                            || {
3784                                AuthorStreamId::from_digest(generated_id_digest(
3785                                    ids,
3786                                    b"coven.circle-transition-draft-stream.v1\0",
3787                                ))
3788                            },
3789                            |head| head.coord.stream_id,
3790                        ),
3791                    author_owner_grant: chosen_metadata.author_owner_grant.clone(),
3792                    seq: chosen_control
3793                        .value
3794                        .ordinal()
3795                        .checked_add(1)
3796                        .ok_or(CircleTransitionError::SequenceOverflow)?,
3797                    previous_control_hash: Some(chosen_control.coord.control_hash()),
3798                    dependencies: vec![chosen_control.coord.clone()],
3799                },
3800                state: CircleControlState::ActiveEpoch(MergeActiveCircleEpoch {
3801                    common: active_epoch.common.clone(),
3802                    metadata,
3803                    roster,
3804                    store_membership,
3805                    covered_control_heads,
3806                }),
3807                author_authority,
3808                membership_authority,
3809            },
3810            author_pubkey,
3811            signature: String::new(),
3812        };
3813        let leaves = prepare_access_material(
3814            store_root_hash,
3815            candidate_family,
3816            circle_id,
3817            epoch_id,
3818            keyring,
3819            key_fingerprint,
3820            &roster_state,
3821            &chosen_roster.members(),
3822            &control_value
3823                .value
3824                .state
3825                .active_epoch()
3826                .expect("control resolution constructs an active epoch")
3827                .store_membership,
3828            &store_members,
3829            &std::collections::BTreeMap::new(),
3830            ids,
3831            signer,
3832        )?;
3833        let leaf_hashes = leaves.iter().map(|leaf| leaf.leaf_hash).collect::<Vec<_>>();
3834        let (access_root, proofs) = merkle_root_and_proofs(&leaf_hashes);
3835        control_value
3836            .value
3837            .state
3838            .active_epoch_mut()
3839            .expect("control resolution constructs an active epoch")
3840            .common
3841            .access_root = access_root;
3842        control_value.signature = keys::sign_hex(signer, &control_value.canonical_bytes()).1;
3843        let control = PreparedCircleControl {
3844            coord: control_value.coord(),
3845            bytes: serde_json::to_vec(&control_value)
3846                .expect("circle control serialization cannot fail"),
3847            value: control_value,
3848        };
3849        let access = prepare_access_envelopes(
3850            store_root_hash,
3851            candidate_family,
3852            circle_id,
3853            &control,
3854            leaves,
3855            proofs,
3856            signer,
3857        );
3858        Ok(Self {
3859            circle_id,
3860            epoch_id,
3861            keyring: keyring.to_string(),
3862            roster: chosen_roster.clone(),
3863            policy: CircleTransitionDraftPolicy {
3864                roster: CircleRosterDraftPolicy::Inherited,
3865                metadata_successor: false,
3866            },
3867            metadata: selected_metadata,
3868            close_intent: None,
3869            close_finalization: None,
3870            close_cancellation: None,
3871            access,
3872            control,
3873        })
3874    }
3875
3876    /// Build the terminal deletion: a successor of the current control whose
3877    /// state is `Deleted`, freezing the epoch spine for historical verification
3878    /// and reclamation. It publishes no roster successor, metadata successor,
3879    /// access material, or bootstraps — its control inherits the predecessor's
3880    /// access root.
3881    #[allow(clippy::too_many_arguments)]
3882    pub(crate) fn delete(
3883        device_id: &str,
3884        store_membership: StoreMembershipStateRef,
3885        membership_authority: MembershipGrantCreationAuthority,
3886        store_members: Vec<(String, MemberRole)>,
3887        current_control: &PreparedCircleControl,
3888        current_roster: &CircleMaterializedRoster,
3889        current_metadata: &CircleMetadata,
3890        keyring: &str,
3891        ids: &dyn crate::id_provider::IdProvider,
3892        signer: &UserKeypair,
3893    ) -> Result<Self, CircleTransitionError> {
3894        let context = circle_delete_successor_context(
3895            store_members,
3896            current_control,
3897            current_roster,
3898            current_metadata,
3899            keyring,
3900            signer,
3901        )?;
3902        let CircleSuccessorContext {
3903            store_members: _,
3904            author_pubkey,
3905            epoch,
3906            grant_id,
3907            author_authority,
3908            key_fingerprint: _,
3909        } = context;
3910        let store_root_hash = current_control.value.store_root_hash;
3911        let circle_id = current_control.value.circle_id;
3912        let epoch_id = current_control.value.epoch_id();
3913        let frozen_epoch = MergeActiveCircleEpoch {
3914            common: epoch.common.clone(),
3915            metadata: epoch.metadata.clone(),
3916            roster: epoch.roster.clone(),
3917            store_membership,
3918            covered_control_heads: epoch.covered_control_heads.clone(),
3919        };
3920        let stream_id = epoch
3921            .covered_control_heads
3922            .iter()
3923            .find(|head| head.coord.stream_key().author_pubkey == author_pubkey)
3924            .map_or_else(
3925                || {
3926                    AuthorStreamId::from_digest(generated_id_digest(
3927                        ids,
3928                        b"coven.circle-transition-draft-stream.v1\0",
3929                    ))
3930                },
3931                |head| head.coord.stream_id,
3932            );
3933        let mut control_value = CircleControl {
3934            version: STORE_PROTOCOL_VERSION,
3935            store_root_hash,
3936            circle_id,
3937            value: CircleControlValue {
3938                order: MergeCircleControlOrder {
3939                    device_id: device_id.to_string(),
3940                    stream_id,
3941                    author_owner_grant: grant_id,
3942                    seq: current_control
3943                        .value
3944                        .ordinal()
3945                        .checked_add(1)
3946                        .ok_or(CircleTransitionError::SequenceOverflow)?,
3947                    previous_control_hash: Some(current_control.coord.control_hash()),
3948                    dependencies: vec![current_control.coord.clone()],
3949                },
3950                state: CircleControlState::Deleted(DeletedCircle { frozen_epoch }),
3951                author_authority,
3952                membership_authority,
3953            },
3954            author_pubkey,
3955            signature: String::new(),
3956        };
3957        control_value.signature = keys::sign_hex(signer, &control_value.canonical_bytes()).1;
3958        let control = PreparedCircleControl {
3959            coord: control_value.coord(),
3960            bytes: serde_json::to_vec(&control_value)
3961                .expect("circle control serialization cannot fail"),
3962            value: control_value,
3963        };
3964        Ok(Self {
3965            circle_id,
3966            epoch_id,
3967            keyring: keyring.to_string(),
3968            roster: current_roster.clone(),
3969            policy: CircleTransitionDraftPolicy {
3970                roster: CircleRosterDraftPolicy::Inherited,
3971                metadata_successor: false,
3972            },
3973            metadata: current_metadata.clone(),
3974            close_intent: None,
3975            close_finalization: None,
3976            close_cancellation: None,
3977            access: Vec::new(),
3978            control,
3979        })
3980    }
3981}
3982
3983#[derive(Debug, Clone, Copy)]
3984pub enum CircleSemanticSlot<'a> {
3985    Control {
3986        circle_id: CircleId,
3987        control: &'a CircleControlCoord,
3988    },
3989    ControlHead {
3990        circle_id: CircleId,
3991        control: &'a CircleControlCoord,
3992        head_hash: ObjectHash,
3993    },
3994    RosterEntry {
3995        circle_id: CircleId,
3996        coord: &'a super::circle_roster::CircleRosterCoord,
3997    },
3998    RosterHead {
3999        circle_id: CircleId,
4000        head: &'a CircleRosterHeadRef,
4001    },
4002    RosterResolution {
4003        circle_id: CircleId,
4004        resolution: &'a super::circle_roster::CircleRosterConflictResolutionRef,
4005    },
4006    MetadataEntry {
4007        circle_id: CircleId,
4008        coord: &'a CircleMetadataCoord,
4009    },
4010    MetadataHead {
4011        circle_id: CircleId,
4012        head: &'a CircleMetadataHeadRef,
4013    },
4014}
4015
4016pub fn circle_semantic_prefix(slot: CircleSemanticSlot<'_>) -> String {
4017    match slot {
4018        CircleSemanticSlot::Control { circle_id, control } => format!(
4019            "circle-control/{}/merge/entries/{author_pubkey}/{device_id}/{author_owner_grant}/{stream_id}/{seq}/{control_hash}",
4020            circle_id,
4021            author_pubkey = control.author_pubkey,
4022            device_id = control.device_id,
4023            author_owner_grant = control.author_owner_grant,
4024            stream_id = control.stream_id,
4025            seq = control.seq,
4026            control_hash = control.control_hash,
4027        ),
4028        CircleSemanticSlot::ControlHead {
4029            circle_id,
4030            control,
4031            head_hash: _,
4032        } => {
4033            circle_control_head_prefix(
4034                circle_id,
4035                &CircleAuthorStreamKey {
4036                    author_pubkey: control.author_pubkey.clone(),
4037                    device_id: control.device_id.clone(),
4038                    stream_id: control.stream_id,
4039                    author_owner_grant: control.author_owner_grant.clone(),
4040                },
4041                control.seq,
4042            )
4043        }
4044        CircleSemanticSlot::RosterEntry { circle_id, coord } => format!(
4045            "circles/{circle_id}/roster/entries/{}/{}/{}/{}/{}/{}",
4046            coord.author_pubkey,
4047            coord.device_id,
4048            coord.author_owner_grant,
4049            coord.stream_id,
4050            coord.seq,
4051            coord.entry_hash
4052        ),
4053        CircleSemanticSlot::RosterHead { circle_id, head } => {
4054            circle_roster_head_prefix(circle_id, &head.coord.stream_key(), head.coord.seq)
4055        }
4056        CircleSemanticSlot::RosterResolution {
4057            circle_id,
4058            resolution,
4059        } => format!(
4060            "circles/{circle_id}/roster/resolutions/{}/{}/{}",
4061            resolution.conflict_hash,
4062            resolution.resolver_pubkey,
4063            resolution.resolution_hash
4064        ),
4065        CircleSemanticSlot::MetadataEntry { circle_id, coord } => format!(
4066            "circles/{circle_id}/metadata/entries/{}/{}/{}/{}/{}/{}",
4067            coord.author_pubkey,
4068            coord.device_id,
4069            coord.author_owner_grant,
4070            coord.stream_id,
4071            coord.seq,
4072            coord.metadata_hash
4073        ),
4074        CircleSemanticSlot::MetadataHead { circle_id, head } => {
4075            circle_metadata_head_prefix(circle_id, &head.coord.stream_key(), head.coord.seq)
4076        }
4077    }
4078}
4079
4080pub fn circle_control_head_prefix(
4081    circle_id: CircleId,
4082    stream: &CircleAuthorStreamKey,
4083    seq: u64,
4084) -> String {
4085    format!(
4086        "circle-control/{circle_id}/merge/heads/{}/{}/{}/{}/{seq}",
4087        stream.author_pubkey, stream.device_id, stream.author_owner_grant, stream.stream_id
4088    )
4089}
4090
4091pub fn circle_roster_head_prefix(
4092    circle_id: CircleId,
4093    stream: &CircleAuthorStreamKey,
4094    seq: u64,
4095) -> String {
4096    format!(
4097        "circles/{circle_id}/roster/heads/{}/{}/{}/{}/{seq}",
4098        stream.author_pubkey, stream.device_id, stream.author_owner_grant, stream.stream_id
4099    )
4100}
4101
4102pub fn circle_metadata_head_prefix(
4103    circle_id: CircleId,
4104    stream: &CircleAuthorStreamKey,
4105    seq: u64,
4106) -> String {
4107    format!(
4108        "circles/{circle_id}/metadata/heads/{}/{}/{}/{}/{seq}",
4109        stream.author_pubkey, stream.device_id, stream.author_owner_grant, stream.stream_id
4110    )
4111}
4112
4113pub fn circle_epoch_close_outcome_semantic_prefix(
4114    circle_id: CircleId,
4115    close_id: CircleEpochCloseId,
4116) -> String {
4117    format!("circles/{circle_id}/epoch-close/{close_id}/outcome")
4118}
4119
4120pub fn circle_epoch_close_intent_semantic_prefix(
4121    circle_id: CircleId,
4122    close_id: CircleEpochCloseId,
4123    intent_hash: ObjectHash,
4124) -> String {
4125    format!("circles/{circle_id}/epoch-close/{close_id}/intent/{intent_hash}")
4126}
4127
4128pub fn circle_epoch_close_response_semantic_prefix(
4129    circle_id: CircleId,
4130    close_id: CircleEpochCloseId,
4131    device_id: super::store_commit::StoreDeviceId,
4132) -> String {
4133    format!("circles/{circle_id}/epoch-close/{close_id}/responses/{device_id}")
4134}
4135
4136pub fn verify_circle_semantic_prefix(
4137    actual: &str,
4138    slot: CircleSemanticSlot<'_>,
4139) -> Result<(), CircleSemanticPathError> {
4140    let expected = circle_semantic_prefix(slot);
4141    if actual == expected {
4142        Ok(())
4143    } else {
4144        Err(CircleSemanticPathError {
4145            expected,
4146            actual: actual.to_string(),
4147        })
4148    }
4149}
4150
4151#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
4152#[error("Circle object path {actual:?} does not match signed coordinate path {expected:?}")]
4153pub struct CircleSemanticPathError {
4154    pub expected: String,
4155    pub actual: String,
4156}
4157
4158pub fn recipient_slot(
4159    owner: &UserKeypair,
4160    recipient_pubkey: &str,
4161    circle_id: CircleId,
4162) -> Result<String, CircleTransitionError> {
4163    recipient_slot_with_peer(owner, recipient_pubkey, circle_id)
4164}
4165
4166pub fn recipient_slot_with_peer(
4167    local_identity: &UserKeypair,
4168    peer_pubkey: &str,
4169    circle_id: CircleId,
4170) -> Result<String, CircleTransitionError> {
4171    let peer_ed25519: [u8; keys::SIGN_PUBLICKEYBYTES] = hex::decode(peer_pubkey)
4172        .map_err(|_| CircleTransitionError::InvalidRecipient(peer_pubkey.to_string()))?
4173        .try_into()
4174        .map_err(|_| CircleTransitionError::InvalidRecipient(peer_pubkey.to_string()))?;
4175    let peer_x25519 = keys::ed25519_to_x25519_public_key(&peer_ed25519)
4176        .map_err(|_| CircleTransitionError::InvalidRecipient(peer_pubkey.to_string()))?;
4177    let shared = keys::x25519_shared_secret(local_identity.to_x25519_secret_key(), peer_x25519)
4178        .map_err(|_| CircleTransitionError::InvalidRecipient(peer_pubkey.to_string()))?;
4179    let mut mac = Hmac::<Sha256>::new_from_slice(&shared).expect("HMAC accepts X25519 output");
4180    mac.update(RECIPIENT_SLOT_DOMAIN);
4181    mac.update(circle_id.as_bytes());
4182    Ok(hex::encode(mac.finalize().into_bytes()))
4183}
4184
4185#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
4186pub enum CircleTransitionError {
4187    #[error("circle name cannot be empty")]
4188    EmptyName,
4189    #[error("circle Store membership reference does not hash the supplied member state")]
4190    MembershipStateMismatch,
4191    #[error("circle creator is not a current Store writer")]
4192    AuthorNotStoreWriter,
4193    #[error("circle operation author is not a current Circle Owner")]
4194    AuthorNotCircleOwner,
4195    #[error("circle operation current state is invalid")]
4196    InvalidCurrentState,
4197    #[error("circle transition sequence overflow")]
4198    SequenceOverflow,
4199    #[error("circle creator Store grant does not match the Store policy")]
4200    MembershipGrantPolicy,
4201    #[error("circle recipient has an invalid Ed25519 public key: {0}")]
4202    InvalidRecipient(String),
4203    #[error("circle member is not a current Store member: {0}")]
4204    MemberNotInStore(String),
4205    #[error("circle roster: {0}")]
4206    Roster(#[from] CircleRosterError),
4207}
4208
4209#[cfg(test)]
4210mod tests {
4211    use super::*;
4212
4213    #[test]
4214    fn semantic_paths_bind_writer_and_author_stream_id() {
4215        let grant = MembershipGrantId(ObjectHash::digest(b"path grant"));
4216        let first = super::super::circle_roster::CircleRosterCoord {
4217            author_pubkey: "owner".to_string(),
4218            device_id: "device".to_string(),
4219            stream_id: AuthorStreamId::from_bytes([1; 32]),
4220            author_owner_grant: grant.clone(),
4221            seq: 1,
4222            entry_hash: ObjectHash::digest(b"entry"),
4223        };
4224        let mut substituted = first.clone();
4225        substituted.stream_id = AuthorStreamId::from_bytes([2; 32]);
4226        let circle_id = CircleId::from_bytes([7; 16]);
4227        let first_path = circle_semantic_prefix(CircleSemanticSlot::RosterEntry {
4228            circle_id,
4229            coord: &first,
4230        });
4231
4232        assert!(first_path.contains(&first.stream_id.to_string()));
4233        assert!(verify_circle_semantic_prefix(
4234            &first_path,
4235            CircleSemanticSlot::RosterEntry {
4236                circle_id,
4237                coord: &substituted,
4238            },
4239        )
4240        .is_err());
4241    }
4242}