Skip to main content

coven_protocol/circle_activation/
current_state.rs

1use super::access::*;
2use super::*;
3
4#[derive(Debug, Clone)]
5pub struct CircleAuthoringState {
6    pub candidate_family: CandidateFamilyId,
7    pub control: PreparedCircleControl,
8    pub access: CircleAccessLeaf,
9    pub roster: CircleMaterializedRoster,
10    pub metadata: CircleMetadata,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct CircleCurrentControl {
16    pub(super) control: PreparedCircleControl,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case", deny_unknown_fields)]
21pub(crate) enum CircleInactiveAccess {
22    NotGranted,
23    Inactive {
24        candidate_family: CandidateFamilyId,
25        access: CircleAccessLeaf,
26    },
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case", deny_unknown_fields)]
31pub struct CircleAccessibleState {
32    pub(super) current: CircleCurrentControl,
33    candidate_family: CandidateFamilyId,
34    access: CircleAccessLeaf,
35    roster: CircleMaterializedRoster,
36    metadata: CircleMetadata,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct CircleInactiveState {
42    current: CircleCurrentControl,
43    access: CircleInactiveAccess,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case", deny_unknown_fields)]
48pub enum CircleCurrentState {
49    Active(Box<CircleAccessibleState>),
50    Closing(Box<CircleAccessibleState>),
51    Inactive(Box<CircleInactiveState>),
52    Deleted(Box<CircleCurrentControl>),
53    ControlConflict { branches: Vec<CircleCurrentControl> },
54}
55
56/// The roster identities that hold no active Store membership grant at the
57/// current materialized membership chain. Their presence in the resolved roster
58/// is what makes a Circle rotation-required until an Owner closes the epoch and
59/// activates a successor roster without them.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct RotationRequired {
62    pub removed_members: Vec<String>,
63}
64
65impl CircleCurrentControl {
66    fn from_verified(activation: &VerifiedCircleReference) -> Self {
67        Self {
68            control: activation.control.clone(),
69        }
70    }
71
72    pub fn circle_id(&self) -> CircleId {
73        self.control.value.circle_id
74    }
75
76    pub fn coordinate(&self) -> &CircleControlCoord {
77        &self.control.coord
78    }
79
80    pub(super) fn control_hash(&self) -> ObjectHash {
81        self.control.coord.control_hash()
82    }
83
84    fn causally_covers(&self, prior: &Self) -> bool {
85        self.control.value.causally_covers(&prior.control.value)
86    }
87
88    fn verify(&self) -> bool {
89        self.control.verify()
90    }
91
92    #[cfg(any(test, feature = "test-utils"))]
93    pub fn control_mut_for_test(&mut self) -> &mut PreparedCircleControl {
94        &mut self.control
95    }
96
97    #[cfg(any(test, feature = "test-utils"))]
98    pub fn control_hash_for_test(&self) -> ObjectHash {
99        self.control_hash()
100    }
101}
102
103impl CircleCurrentState {
104    pub fn from_verified(
105        candidate_family: CandidateFamilyId,
106        activation: &VerifiedCircleReference,
107    ) -> Result<Self, CircleStateError> {
108        if activation
109            .local_access
110            .as_ref()
111            .is_some_and(|access| access.leaf.value.candidate_family != candidate_family)
112        {
113            return Err(CircleStateError::Invariant(
114                "Circle access belongs to another candidate family".to_string(),
115            ));
116        }
117        Self::from_verified_reference(activation)
118    }
119
120    /// Project an already verified activation without its activating commit.
121    /// The access leaf owns its candidate family; commit acceptance separately
122    /// binds that family through `from_verified`.
123    pub fn from_verified_reference(
124        activation: &VerifiedCircleReference,
125    ) -> Result<Self, CircleStateError> {
126        let current = CircleCurrentControl::from_verified(activation);
127        // A deletion is terminal and carries no live access material; it reduces
128        // to Deleted regardless of any retained access leaf.
129        if current.control.value.state().is_deleted() {
130            let state = Self::Deleted(Box::new(current));
131            return if state.verify() {
132                Ok(state)
133            } else {
134                Err(CircleStateError::Invariant(
135                    "verified Circle deletion cannot form a valid current state".to_string(),
136                ))
137            };
138        }
139        let state = match &activation.local_access {
140            None => Self::Inactive(Box::new(CircleInactiveState {
141                current,
142                access: CircleInactiveAccess::NotGranted,
143            })),
144            Some(VerifiedCircleAccess {
145                leaf, active: None, ..
146            }) => Self::Inactive(Box::new(CircleInactiveState {
147                current,
148                access: CircleInactiveAccess::Inactive {
149                    candidate_family: leaf.value.candidate_family,
150                    access: leaf.value.clone(),
151                },
152            })),
153            Some(VerifiedCircleAccess {
154                leaf,
155                active: Some(active),
156                ..
157            }) => {
158                let accessible = Box::new(CircleAccessibleState {
159                    current,
160                    candidate_family: leaf.value.candidate_family,
161                    access: leaf.value.clone(),
162                    roster: active.roster.clone(),
163                    metadata: active.metadata.clone(),
164                });
165                match accessible.current.control.value.state() {
166                    crate::circle::CircleControlState::ActiveEpoch(_) => Self::Active(accessible),
167                    crate::circle::CircleControlState::EpochClose(_) => Self::Closing(accessible),
168                    crate::circle::CircleControlState::Deleted(_) => {
169                        return Err(CircleStateError::Invariant(
170                            "verified Circle deletion cannot carry active access".to_string(),
171                        ))
172                    }
173                }
174            }
175        };
176        if state.verify() {
177            Ok(state)
178        } else {
179            Err(CircleStateError::Invariant(
180                "verified Circle activation cannot form a valid current state".to_string(),
181            ))
182        }
183    }
184
185    pub fn advance(self, next: Self) -> Result<Self, CircleStateError> {
186        if !self.verify() || !next.verify() {
187            return Err(CircleStateError::Invariant(
188                "Circle current-state reduction received invalid state".to_string(),
189            ));
190        }
191        if self.circle_id() != next.circle_id() {
192            return Err(CircleStateError::Invariant(
193                "Circle current-state reduction crossed Circle identities".to_string(),
194            ));
195        }
196        match self {
197            Self::Active(active) => advance_resolved_control(active.current, next),
198            Self::Closing(closing) => advance_resolved_control(closing.current, next),
199            Self::Inactive(inactive) => advance_resolved_control(inactive.current, next),
200            // A deletion is terminal. Dependency-readiness materializes it
201            // before anything descending from it, so a control that causally
202            // covers it here is an invalid descendant and is rejected; a
203            // concurrent branch that does not cover it surfaces as the conflict
204            // the Owner must resolve, exactly like any racing successor.
205            Self::Deleted(deleted) => {
206                let next_current = next.resolved_control().ok_or_else(|| {
207                    CircleStateError::Invariant(
208                        "new Circle activation is already conflicted".to_string(),
209                    )
210                })?;
211                if next_current.causally_covers(&deleted) {
212                    return Err(CircleStateError::Invariant(
213                        "Circle deletion is terminal; a control descending from it is invalid"
214                            .to_string(),
215                    ));
216                }
217                let mut branches = vec![*deleted, next_current.clone()];
218                canonicalize_control_branches(&mut branches)?;
219                Ok(Self::ControlConflict { branches })
220            }
221            Self::ControlConflict { mut branches } => {
222                let next_current = next
223                    .resolved_control()
224                    .ok_or_else(|| {
225                        CircleStateError::Invariant(
226                            "new Circle activation is already conflicted".to_string(),
227                        )
228                    })?
229                    .clone();
230                branches.retain(|branch| !next_current.causally_covers(branch));
231                if branches.is_empty() {
232                    return Ok(next);
233                }
234                branches.push(next_current);
235                canonicalize_control_branches(&mut branches)?;
236                Ok(Self::ControlConflict { branches })
237            }
238        }
239    }
240
241    pub fn without_local_access(self) -> Self {
242        match self {
243            Self::Active(accessible) | Self::Closing(accessible) => {
244                Self::Inactive(Box::new(CircleInactiveState {
245                    current: accessible.current,
246                    access: CircleInactiveAccess::NotGranted,
247                }))
248            }
249            Self::Inactive(inactive) => Self::Inactive(Box::new(CircleInactiveState {
250                current: inactive.current,
251                access: CircleInactiveAccess::NotGranted,
252            })),
253            Self::Deleted(deleted) => Self::Deleted(deleted),
254            Self::ControlConflict { branches } => Self::ControlConflict { branches },
255        }
256    }
257
258    pub fn verify(&self) -> bool {
259        match self {
260            Self::Active(active) => {
261                matches!(
262                    active.current.control.value.state(),
263                    crate::circle::CircleControlState::ActiveEpoch(_)
264                ) && verify_accessible_state(active)
265            }
266            Self::Closing(closing) => {
267                matches!(
268                    closing.current.control.value.state(),
269                    crate::circle::CircleControlState::EpochClose(_)
270                ) && verify_accessible_state(closing)
271            }
272            Self::Inactive(inactive) => {
273                inactive.current.verify()
274                    && match &inactive.access {
275                        CircleInactiveAccess::NotGranted => true,
276                        CircleInactiveAccess::Inactive {
277                            candidate_family,
278                            access,
279                        } => {
280                            access.verify_for_control(&inactive.current.control, *candidate_family)
281                                && matches!(access.disposition, CircleAccessDisposition::Inactive)
282                        }
283                    }
284            }
285            Self::Deleted(deleted) => {
286                matches!(
287                    deleted.control.value.state(),
288                    crate::circle::CircleControlState::Deleted(_)
289                ) && deleted.verify()
290            }
291            Self::ControlConflict { branches } => {
292                branches.len() >= 2
293                    && branches.iter().all(|branch| {
294                        branch.verify() && branch.circle_id() == branches[0].circle_id()
295                    })
296                    && branches
297                        .windows(2)
298                        .all(|pair| pair[0].control_hash() < pair[1].control_hash())
299            }
300        }
301    }
302
303    pub fn circle_id(&self) -> CircleId {
304        match self {
305            Self::Active(active) => active.current.circle_id(),
306            Self::Closing(closing) => closing.current.circle_id(),
307            Self::Inactive(inactive) => inactive.current.circle_id(),
308            Self::Deleted(deleted) => deleted.circle_id(),
309            Self::ControlConflict { branches } => branches[0].circle_id(),
310        }
311    }
312
313    /// A rotation is required when the resolved roster names identities that hold
314    /// no active Store membership grant. Only meaningful for states that carry a
315    /// roster; `Inactive` and `ControlConflict` return `None`.
316    pub fn rotation_required(
317        &self,
318        active_store_members: &BTreeSet<String>,
319    ) -> Option<RotationRequired> {
320        let accessible = match self {
321            Self::Active(accessible) | Self::Closing(accessible) => accessible,
322            Self::Inactive(_) | Self::Deleted(_) | Self::ControlConflict { .. } => return None,
323        };
324        let removed_members: Vec<String> = accessible
325            .roster
326            .members()
327            .into_keys()
328            .filter(|pubkey| !active_store_members.contains(pubkey))
329            .collect();
330        if removed_members.is_empty() {
331            None
332        } else {
333            Some(RotationRequired { removed_members })
334        }
335    }
336
337    /// Map this internal current state to the public [`crate::circle::CircleState`].
338    /// This is the single place the derivation lives.
339    ///
340    /// Rotation-required is surfaced only for an `Active` Circle. A `Closing`
341    /// Circle whose roster still names a removed Store member stays `Closing`
342    /// rather than reporting `RotationRequired`: an epoch close is already the
343    /// exit path a rotation drives toward, so once a close is in flight the close
344    /// is the operative state to show. `Inactive`, `Deleted`, and
345    /// `ControlConflict` carry no roster to make a rotation judgment from.
346    pub fn derived_state(
347        &self,
348        active_store_members: &BTreeSet<String>,
349    ) -> crate::circle::CircleState {
350        use crate::circle::CircleState;
351        match self {
352            Self::Active(_) => match self.rotation_required(active_store_members) {
353                Some(RotationRequired { removed_members }) => {
354                    CircleState::RotationRequired { removed_members }
355                }
356                None => CircleState::Active,
357            },
358            Self::Closing(_) => CircleState::Closing,
359            Self::Inactive(_) => CircleState::Inactive,
360            Self::Deleted(_) => CircleState::Deleted,
361            Self::ControlConflict { branches } => CircleState::ControlConflict {
362                branches: branches
363                    .iter()
364                    .map(|branch| branch.coordinate().clone())
365                    .collect(),
366            },
367        }
368    }
369
370    /// The Circle's display name and the local identity's role, for the public
371    /// list item. Both come from the resolved roster and metadata an accessible
372    /// state carries (`Active` or `Closing`); an `Inactive`, `Deleted`, or
373    /// conflicted Circle resolves neither.
374    pub fn display(
375        &self,
376        identity_pubkey: &str,
377    ) -> (Option<String>, Option<crate::circle::CircleRole>) {
378        let accessible = match self {
379            Self::Active(accessible) | Self::Closing(accessible) => accessible,
380            Self::Inactive(_) | Self::Deleted(_) | Self::ControlConflict { .. } => {
381                return (None, None)
382            }
383        };
384        let role = accessible.roster.members().get(identity_pubkey).copied();
385        (Some(accessible.metadata.name.clone()), role)
386    }
387
388    pub fn active(
389        &self,
390    ) -> Option<(
391        &CircleCurrentControl,
392        &CircleAccessLeaf,
393        &CircleMaterializedRoster,
394        &CircleMetadata,
395    )> {
396        match self {
397            Self::Active(active) => Some((
398                &active.current,
399                &active.access,
400                &active.roster,
401                &active.metadata,
402            )),
403            Self::Closing(_)
404            | Self::Inactive(_)
405            | Self::Deleted(_)
406            | Self::ControlConflict { .. } => None,
407        }
408    }
409
410    pub fn active_record_count(&self) -> usize {
411        match self {
412            Self::Active(_) | Self::Closing(_) => 1,
413            Self::Inactive(_) | Self::Deleted(_) => 0,
414            Self::ControlConflict { branches } => branches.len(),
415        }
416    }
417
418    pub fn authoring_state(&self) -> Option<CircleAuthoringState> {
419        match self {
420            Self::Active(active) => Some(CircleAuthoringState {
421                candidate_family: active.candidate_family,
422                control: active.current.control.clone(),
423                access: active.access.clone(),
424                roster: active.roster.clone(),
425                metadata: active.metadata.clone(),
426            }),
427            Self::Closing(_)
428            | Self::Inactive(_)
429            | Self::Deleted(_)
430            | Self::ControlConflict { .. } => None,
431        }
432    }
433
434    pub fn closing_authoring_state(&self) -> Option<CircleAuthoringState> {
435        match self {
436            Self::Closing(closing) => Some(CircleAuthoringState {
437                candidate_family: closing.candidate_family,
438                control: closing.current.control.clone(),
439                access: closing.access.clone(),
440                roster: closing.roster.clone(),
441                metadata: closing.metadata.clone(),
442            }),
443            Self::Active(_)
444            | Self::Inactive(_)
445            | Self::Deleted(_)
446            | Self::ControlConflict { .. } => None,
447        }
448    }
449
450    /// The authoring state a terminal deletion signs from. Deletion is the one
451    /// command that authors from a closing epoch, so it accepts any state whose
452    /// local device holds owner access — `Active` or `Closing` — and reads the
453    /// frozen epoch spine through the control's `access_epoch`. `Inactive`,
454    /// `Deleted`, and `ControlConflict` hold no owner access to sign a successor.
455    pub fn deletable_authoring_state(&self) -> Option<CircleAuthoringState> {
456        match self {
457            Self::Active(accessible) | Self::Closing(accessible) => Some(CircleAuthoringState {
458                candidate_family: accessible.candidate_family,
459                control: accessible.current.control.clone(),
460                access: accessible.access.clone(),
461                roster: accessible.roster.clone(),
462                metadata: accessible.metadata.clone(),
463            }),
464            Self::Inactive(_) | Self::Deleted(_) | Self::ControlConflict { .. } => None,
465        }
466    }
467
468    pub fn epoch_access(
469        &self,
470        expected_control: &CircleControlCoord,
471    ) -> Result<Option<CircleEpochAccess>, CircleStateError> {
472        let Self::Active(active) = self else {
473            return Ok(None);
474        };
475        if active.current.coordinate() != expected_control {
476            return Ok(None);
477        }
478        if !verify_accessible_state(active) {
479            return Err(CircleStateError::Invariant(format!(
480                "Circle {} current package access is invalid",
481                active.current.circle_id()
482            )));
483        }
484        epoch_access_from(
485            active.current.circle_id(),
486            &active.current.control.value,
487            &active.access.disposition,
488            &active.roster,
489        )
490        .map(Some)
491    }
492
493    pub fn resolved_control(&self) -> Option<&CircleCurrentControl> {
494        match self {
495            Self::Active(active) => Some(&active.current),
496            Self::Closing(closing) => Some(&closing.current),
497            Self::Inactive(inactive) => Some(&inactive.current),
498            Self::Deleted(deleted) => Some(deleted),
499            Self::ControlConflict { .. } => None,
500        }
501    }
502
503    /// Whether this Circle's control history has terminated in a deletion.
504    pub fn is_deleted(&self) -> bool {
505        matches!(self, Self::Deleted(_))
506    }
507
508    /// The retained conflicting branch coordinates, in canonical order, when
509    /// this Circle's control history forked into concurrent valid successors.
510    /// `None` for every resolved state.
511    pub fn conflict_branches(&self) -> Option<Vec<CircleControlCoord>> {
512        match self {
513            Self::ControlConflict { branches } => Some(
514                branches
515                    .iter()
516                    .map(|branch| branch.coordinate().clone())
517                    .collect(),
518            ),
519            Self::Active(_) | Self::Closing(_) | Self::Inactive(_) | Self::Deleted(_) => None,
520        }
521    }
522
523    pub fn closing_control(&self) -> Option<&PreparedCircleControl> {
524        match self {
525            Self::Closing(closing) => Some(&closing.current.control),
526            Self::Active(_)
527            | Self::Inactive(_)
528            | Self::Deleted(_)
529            | Self::ControlConflict { .. } => None,
530        }
531    }
532
533    #[cfg(any(test, feature = "test-utils"))]
534    pub fn active_current_mut_for_test(&mut self) -> Option<&mut CircleCurrentControl> {
535        match self {
536            Self::Active(active) => Some(&mut active.current),
537            _ => None,
538        }
539    }
540}
541
542fn verify_accessible_state(state: &CircleAccessibleState) -> bool {
543    state.current.verify()
544        && state
545            .access
546            .verify_for_control(&state.current.control, state.candidate_family)
547        && matches!(
548            state.access.disposition,
549            CircleAccessDisposition::Active { .. }
550        )
551        && state.roster.verify()
552        && state.metadata.verify()
553        && state.metadata.circle_id == state.current.circle_id()
554        && state.metadata.epoch_id == state.current.control.value.epoch_id()
555        && state.metadata.key_fingerprint == state.current.control.value.key_fingerprint()
556        && metadata_matches_control(&state.metadata, &state.current.control.value)
557        && roster_matches_control(&state.roster, &state.current.control.value)
558}
559
560fn advance_resolved_control(
561    current: CircleCurrentControl,
562    next: CircleCurrentState,
563) -> Result<CircleCurrentState, CircleStateError> {
564    let next_current = next.resolved_control().ok_or_else(|| {
565        CircleStateError::Invariant("new Circle activation is already conflicted".to_string())
566    })?;
567    if next_current.causally_covers(&current) {
568        Ok(next)
569    } else {
570        let mut branches = vec![current, next_current.clone()];
571        canonicalize_control_branches(&mut branches)?;
572        Ok(CircleCurrentState::ControlConflict { branches })
573    }
574}
575
576fn canonicalize_control_branches(
577    branches: &mut [CircleCurrentControl],
578) -> Result<(), CircleStateError> {
579    branches.sort_by_key(CircleCurrentControl::control_hash);
580    if branches
581        .windows(2)
582        .any(|pair| pair[0].control_hash() == pair[1].control_hash())
583    {
584        return Err(CircleStateError::Invariant(
585            "Circle control conflict contains a duplicate branch".to_string(),
586        ));
587    }
588    Ok(())
589}
590
591fn roster_matches_control(roster: &CircleMaterializedRoster, control: &CircleControl) -> bool {
592    control.roster_state_ref().state_hash == roster.state_hash()
593}
594
595fn metadata_matches_control(metadata: &CircleMetadata, control: &CircleControl) -> bool {
596    let state = control.metadata_state_ref();
597    state.selected == metadata.coord() && state.state_hash == metadata.metadata_hash()
598}