Skip to main content

coven_protocol/circle_activation/
activations.rs

1use super::access::*;
2use super::*;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct VerifiedStreamActivations {
6    activating_commit: StoreBatchCommitRef,
7    activations: Vec<StreamActivation>,
8}
9
10impl VerifiedStreamActivations {
11    pub fn none(
12        commit: &StoreBatchCommit,
13        activating_commit: &StoreBatchCommitRef,
14    ) -> Result<Self, crate::store_commit::StoreProtocolError> {
15        if !commit.stream_activations().is_empty() {
16            return Err(crate::store_commit::StoreProtocolError::Malformed(
17                "Store commit stream activations have not been verified".to_string(),
18            ));
19        }
20        activating_commit.verify_commit(commit)?;
21        Ok(Self {
22            activating_commit: activating_commit.clone(),
23            activations: Vec::new(),
24        })
25    }
26
27    pub fn from_verified_circle_commit(
28        commit: &StoreBatchCommit,
29        activating_commit: &StoreBatchCommitRef,
30    ) -> Result<Self, crate::store_commit::StoreProtocolError> {
31        activating_commit.verify_commit(commit)?;
32        Ok(Self {
33            activating_commit: activating_commit.clone(),
34            activations: commit.stream_activations().to_vec(),
35        })
36    }
37
38    pub(crate) fn from_verified_store_control(
39        commit: &StoreBatchCommit,
40        activating_commit: &StoreBatchCommitRef,
41    ) -> Result<Self, crate::store_commit::StoreProtocolError> {
42        activating_commit.verify_commit(commit)?;
43        if commit.control().is_none() {
44            return Err(crate::store_commit::StoreProtocolError::Malformed(
45                "verified Store membership activations carry another control".to_string(),
46            ));
47        }
48        Ok(Self {
49            activating_commit: activating_commit.clone(),
50            activations: commit.stream_activations().to_vec(),
51        })
52    }
53
54    pub fn as_slice(&self) -> &[StreamActivation] {
55        &self.activations
56    }
57
58    pub fn activating_commit(&self) -> &StoreBatchCommitRef {
59        &self.activating_commit
60    }
61}
62
63#[derive(Debug, Clone)]
64pub struct VerifiedStreamActivationPrefix {
65    by_activation: BTreeMap<StreamActivationId, (StreamActivation, StoreBatchCommitRef)>,
66}
67
68impl VerifiedStreamActivationPrefix {
69    pub fn empty() -> Self {
70        Self {
71            by_activation: BTreeMap::new(),
72        }
73    }
74
75    pub fn activation(
76        &self,
77        activation_id: StreamActivationId,
78    ) -> Option<&(StreamActivation, StoreBatchCommitRef)> {
79        self.by_activation.get(&activation_id)
80    }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct VerifiedCircleActivations {
85    pub(super) circles: Vec<VerifiedCircleReference>,
86    pub(super) stream_activations: VerifiedStreamActivations,
87    pub(super) bootstraps: Vec<VerifiedCircleImage>,
88    /// Transient: the local device's exclusions detected from the verified
89    /// outcomes this activation carries. Never serialized into the retained
90    /// form — a reset is dispatched from the durable `circle_close_exclusions`
91    /// row this records, not from replayed activations.
92    pub(super) local_exclusions: Vec<LocalCircleExclusion>,
93    /// Transient: exclusions whose successor bootstrap could not be read this
94    /// pull. The pull records the exclusion and holds the successor; a later
95    /// pull that reads the bootstrap completes the reset.
96    pub(super) bootstrap_pending_exclusions: Vec<LocalCircleExclusion>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101struct RetainedCircleActivations {
102    activating_commit: StoreBatchCommitRef,
103    circles: Vec<RetainedCircleReference>,
104    bootstraps: Vec<VerifiedCircleImage>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109struct RetainedCircleReference {
110    reference: CircleControlRef,
111    circle_id: CircleId,
112    control: PreparedCircleControl,
113    local_access: Option<RetainedCircleAccess>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118struct RetainedCircleAccess {
119    access: PreparedCircleAccess,
120    state: RetainedCircleAccessState,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case", deny_unknown_fields)]
125enum RetainedCircleAccessState {
126    Active {
127        roster: CircleMaterializedRoster,
128        metadata: CircleMetadata,
129    },
130    Inactive,
131}
132
133impl VerifiedCircleActivations {
134    pub fn from_verified_parts(
135        circles: Vec<VerifiedCircleReference>,
136        stream_activations: VerifiedStreamActivations,
137        bootstraps: Vec<VerifiedCircleImage>,
138        local_exclusions: Vec<LocalCircleExclusion>,
139        bootstrap_pending_exclusions: Vec<LocalCircleExclusion>,
140    ) -> Self {
141        Self {
142            circles,
143            stream_activations,
144            bootstraps,
145            local_exclusions,
146            bootstrap_pending_exclusions,
147        }
148    }
149
150    pub fn none(
151        commit: &StoreBatchCommit,
152        commit_ref: &StoreBatchCommitRef,
153    ) -> Result<Self, crate::store_commit::StoreProtocolError> {
154        Ok(Self {
155            circles: Vec::new(),
156            stream_activations: VerifiedStreamActivations::none(commit, commit_ref)?,
157            bootstraps: Vec::new(),
158            local_exclusions: Vec::new(),
159            bootstrap_pending_exclusions: Vec::new(),
160        })
161    }
162
163    pub fn membership_control(
164        commit: &StoreBatchCommit,
165        commit_ref: &StoreBatchCommitRef,
166    ) -> Result<Self, crate::store_commit::StoreProtocolError> {
167        if !commit.circle_controls().is_empty() {
168            return Err(crate::store_commit::StoreProtocolError::Malformed(
169                "Store membership control also carries Circle controls".to_string(),
170            ));
171        }
172        Ok(Self {
173            circles: Vec::new(),
174            stream_activations: VerifiedStreamActivations::from_verified_store_control(
175                commit, commit_ref,
176            )?,
177            bootstraps: Vec::new(),
178            local_exclusions: Vec::new(),
179            bootstrap_pending_exclusions: Vec::new(),
180        })
181    }
182
183    pub fn circles(&self) -> &[VerifiedCircleReference] {
184        &self.circles
185    }
186
187    pub fn stream_activations(&self) -> &VerifiedStreamActivations {
188        &self.stream_activations
189    }
190
191    pub fn bootstraps(&self) -> &[VerifiedCircleImage] {
192        &self.bootstraps
193    }
194
195    pub fn local_exclusions(&self) -> &[LocalCircleExclusion] {
196        &self.local_exclusions
197    }
198
199    pub fn bootstrap_pending_exclusions(&self) -> &[LocalCircleExclusion] {
200        &self.bootstrap_pending_exclusions
201    }
202
203    pub fn to_retained(&self) -> Result<Vec<u8>, CircleStateError> {
204        let retained = RetainedCircleActivations {
205            activating_commit: self.stream_activations.activating_commit.clone(),
206            circles: self
207                .circles
208                .iter()
209                .map(RetainedCircleReference::from_verified)
210                .collect(),
211            bootstraps: self.bootstraps.clone(),
212        };
213        serde_json::to_vec(&retained).map_err(|source| CircleStateError::Json {
214            operation: "serialize retained Circle activations",
215            source,
216        })
217    }
218
219    pub fn parse_retained_for_verified_commit(
220        bytes: &[u8],
221        verified: &VerifiedStoreBatchCommit,
222        recipient_pubkey: Option<&str>,
223    ) -> Result<Self, CircleStateError> {
224        let commit = verified.value();
225        let commit_ref = verified.reference();
226        let retained: RetainedCircleActivations =
227            serde_json::from_slice(bytes).map_err(|source| CircleStateError::Json {
228                operation: "parse retained Circle activations",
229                source,
230            })?;
231        let canonical = serde_json::to_vec(&retained).map_err(|source| CircleStateError::Json {
232            operation: "serialize parsed retained Circle activations",
233            source,
234        })?;
235        if canonical != bytes {
236            return Err(CircleStateError::Invariant(
237                "retained Circle activation bytes are not canonical".to_string(),
238            ));
239        }
240        if retained.activating_commit != *commit_ref
241            || retained.circles.len() != commit.circle_controls().len()
242        {
243            return Err(CircleStateError::Invariant(
244                "retained Circle activations differ from their exact Store commit".to_string(),
245            ));
246        }
247
248        let circles = retained
249            .circles
250            .into_iter()
251            .zip(commit.circle_controls())
252            .map(|(retained, reference)| {
253                retained.verify_and_open(verified, recipient_pubkey, reference)
254            })
255            .collect::<Result<Vec<_>, _>>()?;
256        let mut expected_bootstraps = BTreeMap::new();
257        for circle in &circles {
258            let Some(access) = circle.local_access.as_ref() else {
259                continue;
260            };
261            let CircleAccessDisposition::Active {
262                bootstrap: Some(reference),
263                ..
264            } = &access.leaf.value.disposition
265            else {
266                continue;
267            };
268            if expected_bootstraps
269                .insert(
270                    (circle.circle_id, circle.control.coord.clone()),
271                    (&access.leaf.value, reference),
272                )
273                .is_some()
274            {
275                return Err(CircleStateError::Invariant(
276                    "retained Circle activations repeat a bootstrap recipient".to_string(),
277                ));
278            }
279        }
280        if retained.bootstraps.len() != expected_bootstraps.len() {
281            return Err(CircleStateError::Invariant(
282                "retained Circle bootstrap set is incomplete".to_string(),
283            ));
284        }
285        for bootstrap in &retained.bootstraps {
286            let (access, reference) = expected_bootstraps
287                .remove(&(bootstrap.circle_id, bootstrap.control.clone()))
288                .ok_or_else(|| {
289                    CircleStateError::Invariant(
290                        "retained Circle bootstrap has no signed access leaf".to_string(),
291                    )
292                })?;
293            if bootstrap.reference != *reference {
294                return Err(CircleStateError::Invariant(
295                    "retained Circle bootstrap reference differs from its access leaf".to_string(),
296                ));
297            }
298            bootstrap.verify_for_access(access)?;
299        }
300        Ok(Self {
301            circles,
302            stream_activations: VerifiedStreamActivations::from_verified_circle_commit(
303                commit, commit_ref,
304            )?,
305            bootstraps: retained.bootstraps,
306            local_exclusions: Vec::new(),
307            bootstrap_pending_exclusions: Vec::new(),
308        })
309    }
310
311    #[cfg(any(test, feature = "test-utils"))]
312    pub fn parse_retained(
313        bytes: &[u8],
314        commit: &StoreBatchCommit,
315        commit_ref: &StoreBatchCommitRef,
316        author: &StoreDeviceRegistration,
317        recipient_pubkey: Option<&str>,
318    ) -> Result<Self, CircleStateError> {
319        let verified = VerifiedStoreBatchCommit::parse(
320            &commit.to_bytes(),
321            commit.store_root_hash,
322            commit_ref,
323            author,
324        )?;
325        Self::parse_retained_for_verified_commit(bytes, &verified, recipient_pubkey)
326    }
327}
328
329impl RetainedCircleReference {
330    fn from_verified(verified: &VerifiedCircleReference) -> Self {
331        Self {
332            reference: verified.reference.clone(),
333            circle_id: verified.circle_id,
334            control: verified.control.clone(),
335            local_access: verified
336                .local_access
337                .as_ref()
338                .map(RetainedCircleAccess::from_verified),
339        }
340    }
341
342    fn verify_and_open(
343        self,
344        verified: &VerifiedStoreBatchCommit,
345        recipient_pubkey: Option<&str>,
346        reference: &CircleControlRef,
347    ) -> Result<VerifiedCircleReference, CircleStateError> {
348        let commit = verified.value();
349        if self.reference != *reference || self.circle_id != reference.circle_id() {
350            return Err(CircleStateError::Invariant(
351                "retained Circle reference differs from its exact Store commit".to_string(),
352            ));
353        }
354        verify_control_context_for_verified_commit(reference, &self.control, verified)?;
355        let local_access = self
356            .local_access
357            .map(|access| {
358                access.verify_and_open(commit, reference, &self.control, recipient_pubkey)
359            })
360            .transpose()?;
361        let verified = VerifiedCircleReference {
362            reference: self.reference,
363            circle_id: self.circle_id,
364            control: self.control,
365            local_access,
366        };
367        CircleCurrentState::from_verified(commit.candidate_family(), &verified)?;
368        Ok(verified)
369    }
370}
371
372impl RetainedCircleAccess {
373    fn from_verified(verified: &VerifiedCircleAccess) -> Self {
374        let state = match &verified.active {
375            Some(active) => RetainedCircleAccessState::Active {
376                roster: active.roster.clone(),
377                metadata: active.metadata.clone(),
378            },
379            None => RetainedCircleAccessState::Inactive,
380        };
381        Self {
382            access: PreparedCircleAccess {
383                leaf: verified.leaf.clone(),
384                envelope: verified.envelope.clone(),
385            },
386            state,
387        }
388    }
389
390    fn verify_and_open(
391        self,
392        commit: &StoreBatchCommit,
393        reference: &CircleControlRef,
394        control: &PreparedCircleControl,
395        recipient_pubkey: Option<&str>,
396    ) -> Result<VerifiedCircleAccess, CircleStateError> {
397        if !self.access.leaf.verify_envelope(
398            control,
399            &self.access.envelope,
400            commit.candidate_family(),
401        ) {
402            return Err(CircleStateError::Invariant(
403                "retained Circle access leaf and envelope failed verification".to_string(),
404            ));
405        }
406        if let Some(recipient_pubkey) = recipient_pubkey {
407            if self.access.leaf.value.recipient_pubkey != recipient_pubkey {
408                return Err(CircleStateError::Invariant(
409                    "retained Circle access names another local recipient".to_string(),
410                ));
411            }
412        }
413        if !reference
414            .objects()
415            .access
416            .iter()
417            .any(|candidate| retained_access_matches(candidate, &self.access))
418        {
419            return Err(CircleStateError::Invariant(
420                "retained Circle access differs from every exact commit reference".to_string(),
421            ));
422        }
423        let active = match (self.access.leaf.value.disposition.clone(), self.state) {
424            (
425                CircleAccessDisposition::Active { .. },
426                RetainedCircleAccessState::Active { roster, metadata },
427            ) => Some(VerifiedCircleActive { roster, metadata }),
428            (CircleAccessDisposition::Inactive, RetainedCircleAccessState::Inactive) => None,
429            _ => {
430                return Err(CircleStateError::Invariant(
431                    "retained Circle access state differs from its signed disposition".to_string(),
432                ));
433            }
434        };
435        Ok(VerifiedCircleAccess {
436            envelope: self.access.envelope,
437            leaf: self.access.leaf,
438            active,
439        })
440    }
441}
442
443fn retained_access_matches(
444    reference: &CircleAccessObjectRef,
445    access: &PreparedCircleAccess,
446) -> bool {
447    reference.envelope.owner_pubkey == access.envelope.owner_pubkey
448        && reference.envelope.recipient_slot == access.envelope.recipient_slot
449        && reference.envelope.control_hash == access.envelope.control_hash
450        && reference.envelope.leaf_id == access.envelope.leaf_id
451        && reference.envelope.leaf_hash == access.envelope.leaf_hash
452        && reference.leaf.owner_pubkey == access.leaf.value.owner_pubkey
453        && reference.leaf.epoch_id == access.leaf.value.epoch_id
454        && reference.leaf.recipient_slot == access.leaf.value.recipient_slot
455        && reference.leaf.leaf_id == access.leaf.value.leaf_id
456        && reference.leaf.leaf_hash == access.leaf.leaf_hash
457        && reference.leaf.object.stored_hash() == access.leaf.leaf_hash
458        && u64::try_from(access.leaf.bytes.len())
459            .is_ok_and(|size| reference.leaf.object.stored_size() == size)
460        && reference.bootstrap
461            == match &access.leaf.value.disposition {
462                crate::circle::CircleAccessDisposition::Active { bootstrap, .. } => {
463                    bootstrap.as_ref().map(|bootstrap| bootstrap.image.clone())
464                }
465                crate::circle::CircleAccessDisposition::Inactive => None,
466            }
467}