Skip to main content

coven_protocol/
device_exclusion_journal.rs

1//! Durable Store-device exclusion state: the exact proposal/outcome objects,
2//! prepared candidates, and completion outcomes one exclusion operation
3//! persists, validated against the slots and commits they bind.
4
5use serde::{Deserialize, Serialize};
6
7use crate::objects::{PreparedExactObject, ProtocolObjectContext, ProtocolObjectDomain};
8use crate::prepared_commit::PreparedStoreOperationCommit;
9use crate::remote_object::{
10    CandidateNonactivation, CandidateNonactivationProof, RemoteObjectRecord,
11    RemoteObjectRecordError,
12};
13use crate::store_commit::{
14    ObjectHash, StoreDeviceExclusionOutcome, StoreDeviceExclusionOutcomeRef,
15    StoreDeviceExclusionProposal, StoreDeviceExclusionProposalRef,
16};
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case", deny_unknown_fields)]
20pub enum DurableStoreDeviceExclusionObject {
21    Proposal {
22        reference: StoreDeviceExclusionProposalRef,
23        value: StoreDeviceExclusionProposal,
24        prepared: PreparedExactObject,
25    },
26    Outcome {
27        reference: StoreDeviceExclusionOutcomeRef,
28        value: StoreDeviceExclusionOutcome,
29        prepared: PreparedExactObject,
30    },
31}
32
33impl DurableStoreDeviceExclusionObject {
34    fn store_root_hash(&self) -> ObjectHash {
35        match self {
36            Self::Proposal { value, .. } => value.store_root_hash,
37            Self::Outcome { value, .. } => match value {
38                StoreDeviceExclusionOutcome::Excluded(value) => value.store_root_hash,
39                StoreDeviceExclusionOutcome::Cancelled(value) => value.store_root_hash,
40            },
41        }
42    }
43
44    pub fn context(&self) -> ProtocolObjectContext {
45        let domain = match self {
46            Self::Proposal { .. } => ProtocolObjectDomain::StoreDeviceExclusionProposal,
47            Self::Outcome { .. } => ProtocolObjectDomain::StoreDeviceExclusionOutcome,
48        };
49        ProtocolObjectContext::signed_plaintext(self.store_root_hash(), domain)
50    }
51
52    pub fn semantic_prefix(&self) -> Result<&str, StoreDeviceExclusionJournalError> {
53        self.object()
54            .slot()
55            .logical_key()
56            .strip_suffix(".json")
57            .ok_or_else(|| {
58                StoreDeviceExclusionJournalError::Invalid(
59                    "exclusion exact object does not use its JSON semantic path".to_string(),
60                )
61            })
62    }
63
64    pub fn operation_id(&self) -> ObjectHash {
65        match self {
66            Self::Proposal { reference, .. } => reference.proposal_hash,
67            Self::Outcome { reference, .. } => reference.outcome_hash(),
68        }
69    }
70
71    pub fn object(&self) -> &crate::objects::ExactObjectRef {
72        match self {
73            Self::Proposal { reference, .. } => &reference.object,
74            Self::Outcome { reference, .. } => reference.object(),
75        }
76    }
77
78    pub fn prepared(&self) -> &PreparedExactObject {
79        match self {
80            Self::Proposal { prepared, .. } | Self::Outcome { prepared, .. } => prepared,
81        }
82    }
83
84    pub fn semantic_bytes(&self) -> Vec<u8> {
85        match self {
86            Self::Proposal { value, .. } => value.to_bytes(),
87            Self::Outcome { value, .. } => value.to_bytes(),
88        }
89    }
90
91    fn commit_names_exact_object(&self, candidate: &PreparedStoreOperationCommit) -> bool {
92        match self {
93            Self::Proposal { reference, .. } => {
94                candidate.commit.device_exclusion_proposals() == [reference.clone()]
95                    && candidate.commit.device_exclusion_outcomes().is_empty()
96            }
97            Self::Outcome { reference, .. } => {
98                candidate.commit.device_exclusion_proposals().is_empty()
99                    && candidate.commit.device_exclusion_outcomes() == [reference.clone()]
100            }
101        }
102    }
103
104    pub(crate) fn remote_record(
105        &self,
106        candidate: &PreparedStoreOperationCommit,
107    ) -> Result<crate::remote_object::ClosedRemoteObject, StoreDeviceExclusionJournalError> {
108        let bytes = self.semantic_bytes();
109        let stored = self.prepared().stored_bytes();
110        match self {
111            Self::Proposal { reference, .. } => {
112                RemoteObjectRecord::candidate_activated_device_exclusion_proposal(
113                    reference.clone(),
114                    &bytes,
115                    stored,
116                    candidate.reference.clone(),
117                )
118            }
119            Self::Outcome { reference, .. } => {
120                RemoteObjectRecord::candidate_activated_device_exclusion_outcome(
121                    reference.clone(),
122                    &bytes,
123                    stored,
124                    candidate.reference.clone(),
125                )
126            }
127        }
128        .map_err(StoreDeviceExclusionJournalError::RemoteObject)
129    }
130
131    fn validate(&self) -> Result<(), StoreDeviceExclusionJournalError> {
132        if self.prepared().reference() != self.object() {
133            return Err(StoreDeviceExclusionJournalError::Invalid(
134                "prepared exclusion object differs from its exact reference".to_string(),
135            ));
136        }
137        match self {
138            Self::Proposal {
139                reference, value, ..
140            } => reference.verify_proposal(value)?,
141            Self::Outcome {
142                reference, value, ..
143            } => {
144                if reference.proposal() != value.proposal()
145                    || reference.outcome_hash() != value.outcome_hash()
146                {
147                    return Err(StoreDeviceExclusionJournalError::Invalid(
148                        "exclusion outcome differs from its exact reference".to_string(),
149                    ));
150                }
151            }
152        }
153        Ok(())
154    }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case", deny_unknown_fields)]
159pub enum StoreDeviceExclusionCompletion {
160    Activated {
161        object: DurableStoreDeviceExclusionObject,
162        candidate: PreparedStoreOperationCommit,
163    },
164    OutcomeSlotOccupied {
165        intended: DurableStoreDeviceExclusionObject,
166        winner: DurableStoreDeviceExclusionObject,
167    },
168    CandidateNonactivated {
169        object: DurableStoreDeviceExclusionObject,
170        candidate: PreparedStoreOperationCommit,
171        proof: CandidateNonactivationProof,
172    },
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case", deny_unknown_fields)]
177pub enum DurableStoreDeviceExclusionOperation {
178    CandidatePrepared {
179        object: DurableStoreDeviceExclusionObject,
180        candidate: PreparedStoreOperationCommit,
181    },
182    CandidateNonactivating {
183        object: DurableStoreDeviceExclusionObject,
184        candidate: PreparedStoreOperationCommit,
185        proof: CandidateNonactivationProof,
186    },
187    ReplacingCandidate {
188        object: DurableStoreDeviceExclusionObject,
189        candidate: PreparedStoreOperationCommit,
190        losing: StoreDeviceExclusionCandidateLoss,
191    },
192    Completed(StoreDeviceExclusionCompletion),
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct StoreDeviceExclusionCandidateLoss {
198    pub candidate: PreparedStoreOperationCommit,
199    pub proof: CandidateNonactivationProof,
200}
201
202impl DurableStoreDeviceExclusionOperation {
203    pub fn prepared(
204        object: DurableStoreDeviceExclusionObject,
205        candidate: PreparedStoreOperationCommit,
206    ) -> Result<Self, StoreDeviceExclusionJournalError> {
207        let operation = Self::CandidatePrepared { object, candidate };
208        operation.validate()?;
209        Ok(operation)
210    }
211
212    pub fn operation_id(&self) -> ObjectHash {
213        self.object().operation_id()
214    }
215
216    pub fn is_completed(&self) -> bool {
217        matches!(self, Self::Completed(_))
218    }
219
220    pub fn allows_transition_to(&self, next: &Self) -> bool {
221        let current_id = self.operation_id();
222        let next_id = next.operation_id();
223        if current_id != next_id {
224            return false;
225        }
226        match (self, next) {
227            (
228                Self::CandidatePrepared { object, candidate },
229                Self::CandidatePrepared {
230                    object: next_object,
231                    candidate: next_candidate,
232                },
233            ) => {
234                object == next_object
235                    && candidate.reference == next_candidate.reference
236                    && candidate.commit.to_bytes() == next_candidate.commit.to_bytes()
237            }
238            (Self::CandidatePrepared { .. }, Self::CandidateNonactivating { .. }) => true,
239            (Self::CandidatePrepared { .. }, Self::ReplacingCandidate { .. }) => true,
240            (
241                Self::CandidatePrepared { .. },
242                Self::Completed(StoreDeviceExclusionCompletion::OutcomeSlotOccupied { .. }),
243            ) => true,
244            (
245                Self::CandidatePrepared { object, candidate },
246                Self::Completed(StoreDeviceExclusionCompletion::Activated {
247                    object: next_object,
248                    candidate: next_candidate,
249                }),
250            ) => object == next_object && candidate.has_same_durable_activation_as(next_candidate),
251            (
252                Self::ReplacingCandidate {
253                    object, candidate, ..
254                },
255                Self::CandidatePrepared {
256                    object: next_object,
257                    candidate: next_candidate,
258                },
259            ) => object == next_object && candidate.has_same_durable_activation_as(next_candidate),
260            (
261                Self::CandidateNonactivating {
262                    object,
263                    candidate,
264                    proof,
265                },
266                Self::Completed(StoreDeviceExclusionCompletion::CandidateNonactivated {
267                    object: next_object,
268                    candidate: next_candidate,
269                    proof: next_proof,
270                }),
271            ) => {
272                object == next_object
273                    && candidate.has_same_durable_activation_as(next_candidate)
274                    && proof == next_proof
275            }
276            _ => false,
277        }
278    }
279
280    pub fn object(&self) -> &DurableStoreDeviceExclusionObject {
281        match self {
282            Self::CandidatePrepared { object, .. }
283            | Self::CandidateNonactivating { object, .. }
284            | Self::ReplacingCandidate { object, .. } => object,
285            Self::Completed(StoreDeviceExclusionCompletion::Activated { object, .. }) => object,
286            Self::Completed(StoreDeviceExclusionCompletion::OutcomeSlotOccupied {
287                intended,
288                ..
289            }) => intended,
290            Self::Completed(StoreDeviceExclusionCompletion::CandidateNonactivated {
291                object,
292                ..
293            }) => object,
294        }
295    }
296
297    pub fn candidate(&self) -> Option<&PreparedStoreOperationCommit> {
298        match self {
299            Self::CandidatePrepared { candidate, .. }
300            | Self::CandidateNonactivating { candidate, .. }
301            | Self::ReplacingCandidate { candidate, .. } => Some(candidate),
302            Self::Completed(StoreDeviceExclusionCompletion::Activated { candidate, .. }) => {
303                Some(candidate)
304            }
305            Self::Completed(StoreDeviceExclusionCompletion::CandidateNonactivated {
306                candidate,
307                ..
308            }) => Some(candidate),
309            Self::Completed(StoreDeviceExclusionCompletion::OutcomeSlotOccupied { .. }) => None,
310        }
311    }
312
313    pub fn remote_objects(
314        &self,
315    ) -> Result<Vec<crate::remote_object::ClosedRemoteObject>, StoreDeviceExclusionJournalError>
316    {
317        let candidate = self.candidate().ok_or_else(|| {
318            StoreDeviceExclusionJournalError::Invalid(
319                "Store-device exclusion has no prepared activation candidate".to_string(),
320            )
321        })?;
322        let authority = self.object().remote_record(candidate)?;
323        candidate
324            .retained_authority_remote_objects(vec![authority])
325            .map_err(StoreDeviceExclusionJournalError::Outbound)
326    }
327
328    pub fn authority_remote_object(
329        &self,
330    ) -> Result<crate::remote_object::ClosedRemoteObject, StoreDeviceExclusionJournalError> {
331        let candidate = self.candidate().ok_or_else(|| {
332            StoreDeviceExclusionJournalError::Invalid(
333                "Store-device exclusion has no authority owner candidate".to_string(),
334            )
335        })?;
336        self.object().remote_record(candidate)
337    }
338
339    pub fn begin_nonactivation(
340        &self,
341        nonactivation: CandidateNonactivation,
342    ) -> Result<(Self, CandidateNonactivation), StoreDeviceExclusionJournalError> {
343        let Self::CandidatePrepared { object, candidate } = self else {
344            return Err(StoreDeviceExclusionJournalError::Invalid(
345                "only a prepared exclusion candidate can become nonactivating".to_string(),
346            ));
347        };
348        if nonactivation
349            .reference()
350            .map_err(StoreDeviceExclusionJournalError::RemoteObject)?
351            != candidate.reference
352        {
353            return Err(StoreDeviceExclusionJournalError::Invalid(
354                "exclusion nonactivation names another candidate".to_string(),
355            ));
356        }
357        let proof = nonactivation.proof().clone();
358        let operation = Self::CandidateNonactivating {
359            object: object.clone(),
360            candidate: candidate.clone(),
361            proof,
362        };
363        operation.validate()?;
364        Ok((operation, nonactivation))
365    }
366
367    pub fn begin_replacement(
368        &self,
369        replacement: PreparedStoreOperationCommit,
370        nonactivation: CandidateNonactivation,
371    ) -> Result<(Self, CandidateNonactivation), StoreDeviceExclusionJournalError> {
372        let Self::CandidatePrepared { object, candidate } = self else {
373            return Err(StoreDeviceExclusionJournalError::Invalid(
374                "only a prepared exclusion candidate can be replaced".to_string(),
375            ));
376        };
377        if !matches!(object, DurableStoreDeviceExclusionObject::Outcome { .. }) {
378            return Err(StoreDeviceExclusionJournalError::Invalid(
379                "an exclusion proposal cannot move to another predecessor".to_string(),
380            ));
381        }
382        if nonactivation
383            .reference()
384            .map_err(StoreDeviceExclusionJournalError::RemoteObject)?
385            != candidate.reference
386        {
387            return Err(StoreDeviceExclusionJournalError::Invalid(
388                "replacement exclusion nonactivation names another candidate".to_string(),
389            ));
390        }
391        let proof = nonactivation.proof().clone();
392        let operation = Self::ReplacingCandidate {
393            object: object.clone(),
394            candidate: replacement,
395            losing: StoreDeviceExclusionCandidateLoss {
396                candidate: candidate.clone(),
397                proof,
398            },
399        };
400        operation.validate()?;
401        Ok((operation, nonactivation))
402    }
403
404    pub fn validate(&self) -> Result<(), StoreDeviceExclusionJournalError> {
405        self.object().validate()?;
406        let Some(candidate) = self.candidate() else {
407            if let Self::Completed(StoreDeviceExclusionCompletion::OutcomeSlotOccupied {
408                intended,
409                winner,
410            }) = self
411            {
412                winner.validate()?;
413                if !matches!(intended, DurableStoreDeviceExclusionObject::Outcome { .. })
414                    || !matches!(winner, DurableStoreDeviceExclusionObject::Outcome { .. })
415                    || intended.object().slot() != winner.object().slot()
416                    || intended.object() == winner.object()
417                {
418                    return Err(StoreDeviceExclusionJournalError::Invalid(
419                        "occupied exclusion outcome slot lacks a distinct exact winner".to_string(),
420                    ));
421                }
422            }
423            return Ok(());
424        };
425        candidate.reference.verify_commit(&candidate.commit)?;
426        if !self.object().commit_names_exact_object(candidate)
427            || candidate.commit.acknowledgement().is_some()
428        {
429            return Err(StoreDeviceExclusionJournalError::Invalid(
430                "exclusion journal candidate does not activate its one exact object".to_string(),
431            ));
432        }
433        if let Self::CandidateNonactivating {
434            candidate, proof, ..
435        }
436        | Self::Completed(StoreDeviceExclusionCompletion::CandidateNonactivated {
437            candidate,
438            proof,
439            ..
440        }) = self
441        {
442            CandidateNonactivation::validate_durable_shape(
443                &candidate.reference,
444                &candidate.commit,
445                proof.clone(),
446            )
447            .map_err(StoreDeviceExclusionJournalError::RemoteObject)?;
448        }
449        if let Self::ReplacingCandidate {
450            object,
451            candidate,
452            losing,
453        } = self
454        {
455            if !matches!(object, DurableStoreDeviceExclusionObject::Outcome { .. })
456                || candidate.reference == losing.candidate.reference
457                || !object.commit_names_exact_object(candidate)
458                || !object.commit_names_exact_object(&losing.candidate)
459            {
460                return Err(StoreDeviceExclusionJournalError::Invalid(
461                    "replacement exclusion candidate changes its exact outcome".to_string(),
462                ));
463            }
464            CandidateNonactivation::validate_durable_shape(
465                &losing.candidate.reference,
466                &losing.candidate.commit,
467                losing.proof.clone(),
468            )
469            .map_err(StoreDeviceExclusionJournalError::RemoteObject)?;
470        }
471        Ok(())
472    }
473}
474
475#[derive(Debug, thiserror::Error)]
476pub enum StoreDeviceExclusionJournalError {
477    #[error("invalid durable Store-device exclusion: {0}")]
478    Invalid(String),
479    #[error("Store-device exclusion protocol: {0}")]
480    Protocol(#[from] crate::store_commit::StoreProtocolError),
481    #[error("Store-device exclusion remote ownership: {0}")]
482    RemoteObject(#[from] RemoteObjectRecordError),
483    #[error("Store-device exclusion activation: {0}")]
484    Outbound(#[from] crate::prepared_commit::PreparedCommitError),
485    #[error("Store-device exclusion storage: {0}")]
486    Storage(#[from] crate::objects::StorageError),
487}