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