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