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::{RemoteObjectRecord, RemoteObjectRecordError};
10use crate::store_commit::{
11    ObjectHash, StoreDeviceExclusionOutcome, StoreDeviceExclusionOutcomeRef,
12    StoreDeviceExclusionProposal, StoreDeviceExclusionProposalRef,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case", deny_unknown_fields)]
17pub enum DurableStoreDeviceExclusionObject {
18    Proposal {
19        reference: StoreDeviceExclusionProposalRef,
20        value: StoreDeviceExclusionProposal,
21        prepared: PreparedExactObject,
22    },
23    Outcome {
24        reference: StoreDeviceExclusionOutcomeRef,
25        value: StoreDeviceExclusionOutcome,
26        prepared: PreparedExactObject,
27    },
28}
29
30impl DurableStoreDeviceExclusionObject {
31    fn store_root_hash(&self) -> ObjectHash {
32        match self {
33            Self::Proposal { value, .. } => value.store_root_hash,
34            Self::Outcome { value, .. } => match value {
35                StoreDeviceExclusionOutcome::Excluded(value) => value.store_root_hash,
36                StoreDeviceExclusionOutcome::Cancelled(value) => value.store_root_hash,
37            },
38        }
39    }
40
41    pub fn context(&self) -> ProtocolObjectContext {
42        let domain = match self {
43            Self::Proposal { .. } => ProtocolObjectDomain::StoreDeviceExclusionProposal,
44            Self::Outcome { .. } => ProtocolObjectDomain::StoreDeviceExclusionOutcome,
45        };
46        ProtocolObjectContext::signed_plaintext(self.store_root_hash(), domain)
47    }
48
49    pub fn semantic_prefix(&self) -> Result<&str, StoreDeviceExclusionJournalError> {
50        self.object()
51            .slot()
52            .logical_key()
53            .strip_suffix(".json")
54            .ok_or_else(|| {
55                StoreDeviceExclusionJournalError::Invalid(
56                    "exclusion exact object does not use its JSON semantic path".to_string(),
57                )
58            })
59    }
60
61    pub fn operation_id(&self) -> ObjectHash {
62        match self {
63            Self::Proposal { reference, .. } => reference.proposal_hash,
64            Self::Outcome { reference, .. } => reference.outcome_hash(),
65        }
66    }
67
68    pub fn object(&self) -> &crate::objects::ExactObjectRef {
69        match self {
70            Self::Proposal { reference, .. } => &reference.object,
71            Self::Outcome { reference, .. } => reference.object(),
72        }
73    }
74
75    pub fn prepared(&self) -> &PreparedExactObject {
76        match self {
77            Self::Proposal { prepared, .. } | Self::Outcome { prepared, .. } => prepared,
78        }
79    }
80
81    pub fn semantic_bytes(&self) -> Vec<u8> {
82        match self {
83            Self::Proposal { value, .. } => value.to_bytes(),
84            Self::Outcome { value, .. } => value.to_bytes(),
85        }
86    }
87
88    fn commit_names_exact_object(&self, candidate: &PreparedStoreOperationCommit) -> bool {
89        match self {
90            Self::Proposal { reference, .. } => {
91                candidate.commit.device_exclusion_proposals() == [reference.clone()]
92                    && candidate.commit.device_exclusion_outcomes().is_empty()
93            }
94            Self::Outcome { reference, .. } => {
95                candidate.commit.device_exclusion_proposals().is_empty()
96                    && candidate.commit.device_exclusion_outcomes() == [reference.clone()]
97            }
98        }
99    }
100
101    pub(crate) fn remote_record(
102        &self,
103        candidate: &PreparedStoreOperationCommit,
104    ) -> Result<crate::remote_object::ClosedRemoteObject, StoreDeviceExclusionJournalError> {
105        let bytes = self.semantic_bytes();
106        let stored = self.prepared().stored_bytes();
107        match self {
108            Self::Proposal { reference, .. } => {
109                RemoteObjectRecord::candidate_activated_device_exclusion_proposal(
110                    reference.clone(),
111                    &bytes,
112                    stored,
113                    candidate.reference.clone(),
114                )
115            }
116            Self::Outcome { reference, .. } => {
117                RemoteObjectRecord::candidate_activated_device_exclusion_outcome(
118                    reference.clone(),
119                    &bytes,
120                    stored,
121                    candidate.reference.clone(),
122                )
123            }
124        }
125        .map_err(StoreDeviceExclusionJournalError::RemoteObject)
126    }
127
128    fn validate(&self) -> Result<(), StoreDeviceExclusionJournalError> {
129        if self.prepared().reference() != self.object() {
130            return Err(StoreDeviceExclusionJournalError::Invalid(
131                "prepared exclusion object differs from its exact reference".to_string(),
132            ));
133        }
134        match self {
135            Self::Proposal {
136                reference, value, ..
137            } => reference.verify_proposal(value)?,
138            Self::Outcome {
139                reference, value, ..
140            } => {
141                if reference.proposal() != value.proposal()
142                    || reference.outcome_hash() != value.outcome_hash()
143                {
144                    return Err(StoreDeviceExclusionJournalError::Invalid(
145                        "exclusion outcome differs from its exact reference".to_string(),
146                    ));
147                }
148            }
149        }
150        Ok(())
151    }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case", deny_unknown_fields)]
156pub enum StoreDeviceExclusionCompletion {
157    Activated {
158        object: DurableStoreDeviceExclusionObject,
159        candidate: PreparedStoreOperationCommit,
160    },
161    OutcomeSlotOccupied {
162        intended: DurableStoreDeviceExclusionObject,
163        winner: DurableStoreDeviceExclusionObject,
164    },
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case", deny_unknown_fields)]
169pub enum DurableStoreDeviceExclusionOperation {
170    CandidatePrepared {
171        object: DurableStoreDeviceExclusionObject,
172        candidate: PreparedStoreOperationCommit,
173    },
174    Completed(StoreDeviceExclusionCompletion),
175}
176
177impl DurableStoreDeviceExclusionOperation {
178    pub fn prepared(
179        object: DurableStoreDeviceExclusionObject,
180        candidate: PreparedStoreOperationCommit,
181    ) -> Result<Self, StoreDeviceExclusionJournalError> {
182        let operation = Self::CandidatePrepared { object, candidate };
183        operation.validate()?;
184        Ok(operation)
185    }
186
187    pub fn activated(&self) -> Result<Self, StoreDeviceExclusionJournalError> {
188        self.validate()?;
189        let Self::CandidatePrepared { object, candidate } = self else {
190            return Err(StoreDeviceExclusionJournalError::Invalid(
191                "Store-device exclusion has no pending activation candidate".into(),
192            ));
193        };
194        Ok(Self::Completed(StoreDeviceExclusionCompletion::Activated {
195            object: object.clone(),
196            candidate: candidate.clone(),
197        }))
198    }
199
200    pub fn operation_id(&self) -> ObjectHash {
201        self.object().operation_id()
202    }
203
204    pub fn is_completed(&self) -> bool {
205        matches!(self, Self::Completed(_))
206    }
207
208    pub fn allows_transition_to(&self, next: &Self) -> bool {
209        let current_id = self.operation_id();
210        let next_id = next.operation_id();
211        if current_id != next_id {
212            return false;
213        }
214        match (self, next) {
215            (
216                Self::CandidatePrepared { object, candidate },
217                Self::CandidatePrepared {
218                    object: next_object,
219                    candidate: next_candidate,
220                },
221            ) => {
222                object == next_object
223                    && candidate.reference == next_candidate.reference
224                    && candidate.commit.to_bytes() == next_candidate.commit.to_bytes()
225            }
226            (
227                Self::CandidatePrepared { .. },
228                Self::Completed(StoreDeviceExclusionCompletion::OutcomeSlotOccupied { .. }),
229            ) => true,
230            (
231                Self::CandidatePrepared { object, candidate },
232                Self::Completed(StoreDeviceExclusionCompletion::Activated {
233                    object: next_object,
234                    candidate: next_candidate,
235                }),
236            ) => object == next_object && candidate.has_same_durable_activation_as(next_candidate),
237            _ => false,
238        }
239    }
240
241    pub fn object(&self) -> &DurableStoreDeviceExclusionObject {
242        match self {
243            Self::CandidatePrepared { object, .. } => object,
244            Self::Completed(StoreDeviceExclusionCompletion::Activated { object, .. }) => object,
245            Self::Completed(StoreDeviceExclusionCompletion::OutcomeSlotOccupied {
246                intended,
247                ..
248            }) => intended,
249        }
250    }
251
252    pub fn candidate(&self) -> Option<&PreparedStoreOperationCommit> {
253        match self {
254            Self::CandidatePrepared { candidate, .. } => Some(candidate),
255            Self::Completed(StoreDeviceExclusionCompletion::Activated { candidate, .. }) => {
256                Some(candidate)
257            }
258            Self::Completed(StoreDeviceExclusionCompletion::OutcomeSlotOccupied { .. }) => None,
259        }
260    }
261
262    pub fn remote_objects(
263        &self,
264    ) -> Result<Vec<crate::remote_object::ClosedRemoteObject>, StoreDeviceExclusionJournalError>
265    {
266        let candidate = self.candidate().ok_or_else(|| {
267            StoreDeviceExclusionJournalError::Invalid(
268                "Store-device exclusion has no prepared activation candidate".to_string(),
269            )
270        })?;
271        let authority = self.object().remote_record(candidate)?;
272        candidate
273            .retained_control_remote_objects(vec![authority])
274            .map_err(StoreDeviceExclusionJournalError::Outbound)
275    }
276
277    pub fn authority_remote_object(
278        &self,
279    ) -> Result<crate::remote_object::ClosedRemoteObject, StoreDeviceExclusionJournalError> {
280        let candidate = self.candidate().ok_or_else(|| {
281            StoreDeviceExclusionJournalError::Invalid(
282                "Store-device exclusion has no authority owner candidate".to_string(),
283            )
284        })?;
285        self.object().remote_record(candidate)
286    }
287
288    pub fn validate(&self) -> Result<(), StoreDeviceExclusionJournalError> {
289        self.object().validate()?;
290        let Some(candidate) = self.candidate() else {
291            if let Self::Completed(StoreDeviceExclusionCompletion::OutcomeSlotOccupied {
292                intended,
293                winner,
294            }) = self
295            {
296                winner.validate()?;
297                if !matches!(intended, DurableStoreDeviceExclusionObject::Outcome { .. })
298                    || !matches!(winner, DurableStoreDeviceExclusionObject::Outcome { .. })
299                    || intended.object().slot() != winner.object().slot()
300                    || intended.object() == winner.object()
301                {
302                    return Err(StoreDeviceExclusionJournalError::Invalid(
303                        "occupied exclusion outcome slot lacks a distinct exact winner".to_string(),
304                    ));
305                }
306            }
307            return Ok(());
308        };
309        candidate.reference.verify_commit(&candidate.commit)?;
310        let publication = candidate.prepared_membership_publication()?;
311        let change_matches = match (self.object(), &publication.entry.change) {
312            (
313                DurableStoreDeviceExclusionObject::Proposal { reference, .. },
314                crate::membership::StoreAuthorityChange::DeviceExclusionProposal { proposal },
315            ) => reference == proposal,
316            (
317                DurableStoreDeviceExclusionObject::Outcome { reference, .. },
318                crate::membership::StoreAuthorityChange::DeviceExclusionOutcome { outcome },
319            ) => reference == outcome,
320            _ => false,
321        };
322        if !change_matches
323            || !self.object().commit_names_exact_object(candidate)
324            || candidate.commit.acknowledgement().is_some()
325        {
326            return Err(StoreDeviceExclusionJournalError::Invalid(
327                "exclusion journal candidate does not activate its one exact object".to_string(),
328            ));
329        }
330        Ok(())
331    }
332}
333
334#[derive(Debug, thiserror::Error)]
335pub enum StoreDeviceExclusionJournalError {
336    #[error("invalid durable Store-device exclusion: {0}")]
337    Invalid(String),
338    #[error("Store-device exclusion protocol: {0}")]
339    Protocol(#[from] crate::store_commit::StoreProtocolError),
340    #[error("Store-device exclusion remote ownership: {0}")]
341    RemoteObject(#[from] RemoteObjectRecordError),
342    #[error("Store-device exclusion activation: {0}")]
343    Outbound(#[from] crate::prepared_commit::PreparedCommitError),
344    #[error("Store-device exclusion storage: {0}")]
345    Storage(#[from] crate::objects::StorageError),
346}