Skip to main content

coven_replication/sync/store/device_exclusion/
mod.rs

1//! Durable publication of Store-device exclusion proposals and outcomes.
2
3mod history;
4pub(crate) use history::DeviceExclusionHistory;
5
6use coven_protocol::device_exclusion_journal::{
7    DurableStoreDeviceExclusionObject, DurableStoreDeviceExclusionOperation,
8    StoreDeviceExclusionCompletion, StoreDeviceExclusionJournalError,
9};
10
11use super::{AuthorizedWriterOperation, StoreError};
12use crate::sync::store::commit_publication::operation::commit_plan::StoreOperationBatch;
13use crate::sync::store::commit_verification::merge_history::MergeHistoryVerifier;
14use coven_database::DbError;
15use coven_database::StoreDatabase;
16use coven_protocol::objects::{ProtocolObjectContext, ProtocolObjectDomain};
17use coven_protocol::store_commit::{
18    device_exclusion_outcome_semantic_prefix, device_exclusion_proposal_semantic_prefix,
19    ObjectHash, StoreBatchCommitRef, StoreDeviceExclusionOutcome, StoreDeviceExclusionOutcomeRef,
20    StoreDeviceExclusionProposalId, StoreDeviceExclusionProposalRef, StoreDeviceProposalState,
21    StoreDeviceStatus, StoreProtocolError,
22};
23use coven_storage::CloudSyncObjectStorage;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum StoreDeviceExclusionResult {
27    ProposalActivated {
28        proposal: StoreDeviceExclusionProposalRef,
29        commit: StoreBatchCommitRef,
30    },
31    OutcomeActivated {
32        outcome: StoreDeviceExclusionOutcomeRef,
33        commit: StoreBatchCommitRef,
34    },
35    OutcomeSlotOccupied {
36        intended: StoreDeviceExclusionOutcomeRef,
37        winner: StoreDeviceExclusionOutcomeRef,
38    },
39}
40
41#[cfg(any(test, feature = "test-utils"))]
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct StoreDeviceExclusionOperationInfo {
44    pub operation_id: ObjectHash,
45    pub status: StoreDeviceExclusionOperationStatus,
46}
47
48#[cfg(any(test, feature = "test-utils"))]
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum StoreDeviceExclusionOperationStatus {
51    Pending,
52    Completed(StoreDeviceExclusionResult),
53}
54
55#[derive(Debug, thiserror::Error)]
56pub enum StoreDeviceExclusionError {
57    #[error("Store-device exclusion operation {0} remains active")]
58    OperationActive(ObjectHash),
59    #[error("the local Store device has no active Owner authority")]
60    OwnerAuthorityRequired,
61    #[error("the target Store device is not active at the exact predecessor state")]
62    TargetNotActive,
63    #[error("the active Owner device cannot exclude its own registration")]
64    CannotExcludeLocalDevice,
65    #[error("Store-device exclusion database state: {0}")]
66    Database(#[from] DbError),
67    #[error("Store-device exclusion object: {0}")]
68    Object(#[from] coven_protocol::objects::StoreObjectError),
69    #[error("Store-device exclusion protocol: {0}")]
70    Protocol(#[from] StoreProtocolError),
71    #[error("Store-device exclusion JSON: {0}")]
72    Json(#[from] serde_json::Error),
73    #[error("Store-device exclusion publication: {0}")]
74    Outbound(#[from] StoreError),
75    #[error("Store-device exclusion storage: {0}")]
76    Storage(#[from] coven_protocol::objects::StorageError),
77    #[error("Store-device exclusion authority: {0}")]
78    Membership(#[from] crate::sync::store::membership::MembershipMutationError),
79    #[error("Store-device exclusion journal: {0}")]
80    Journal(#[from] StoreDeviceExclusionJournalError),
81    #[error("Store-device exclusion state is invalid: {0}")]
82    InvalidState(String),
83}
84
85/// Propose exclusion of the active registration a Store device id names.
86pub(crate) async fn propose_for_device(
87    database: &StoreDatabase,
88    writer: &mut AuthorizedWriterOperation<'_>,
89    device_id: coven_protocol::store_commit::StoreDeviceId,
90) -> Result<StoreDeviceExclusionProposalRef, StoreDeviceExclusionError> {
91    let target = database
92        .activated_store_device_registration_for_device(device_id)
93        .await?
94        .ok_or(StoreDeviceExclusionError::TargetNotActive)?;
95    match writer
96        .device_exclusion()
97        .propose(target.reference())
98        .await?
99    {
100        StoreDeviceExclusionResult::ProposalActivated { proposal, .. } => Ok(proposal),
101        other => Err(StoreDeviceExclusionError::InvalidState(format!(
102            "proposal did not activate: {other:?}"
103        ))),
104    }
105}
106
107pub(crate) async fn cancel_proposal(
108    writer: &mut AuthorizedWriterOperation<'_>,
109    proposal: &StoreDeviceExclusionProposalRef,
110) -> Result<(), StoreDeviceExclusionError> {
111    match writer.device_exclusion().cancel(proposal).await? {
112        StoreDeviceExclusionResult::OutcomeActivated { .. } => Ok(()),
113        other => Err(StoreDeviceExclusionError::InvalidState(format!(
114            "cancellation did not activate: {other:?}"
115        ))),
116    }
117}
118
119pub(crate) async fn finalize_proposal(
120    writer: &mut AuthorizedWriterOperation<'_>,
121    proposal: &StoreDeviceExclusionProposalRef,
122) -> Result<(), StoreDeviceExclusionError> {
123    match writer.device_exclusion().exclude(proposal).await? {
124        StoreDeviceExclusionResult::OutcomeActivated { .. } => Ok(()),
125        other => Err(StoreDeviceExclusionError::InvalidState(format!(
126            "exclusion did not activate: {other:?}"
127        ))),
128    }
129}
130
131#[cfg(any(test, feature = "test-utils"))]
132pub(crate) async fn operations_for_test(
133    database: &StoreDatabase,
134) -> Result<Vec<StoreDeviceExclusionOperationInfo>, StoreDeviceExclusionError> {
135    database
136        .outbound_store_device_exclusion_operations()
137        .await?
138        .into_iter()
139        .map(|operation| {
140            let operation_id = operation.operation_id();
141            let status = if operation.is_completed() {
142                StoreDeviceExclusionOperationStatus::Completed(completion_result(&operation)?)
143            } else {
144                StoreDeviceExclusionOperationStatus::Pending
145            };
146            Ok(StoreDeviceExclusionOperationInfo {
147                operation_id,
148                status,
149            })
150        })
151        .collect()
152}
153
154/// Stage and upload one exclusion proposal against this device's own
155/// registration, stopping before activation so a restart resumes it. The
156/// target is the local device — which [`AuthorizedDeviceExclusion::propose`]
157/// refuses — so the test enters the production pipeline one step below that
158/// gate, at [`AuthorizedDeviceExclusion::stage_proposal`], under a fixed
159/// proposal id.
160#[cfg(any(test, feature = "test-utils"))]
161pub(crate) async fn stage_uploaded_proposal_for_test(
162    database: &StoreDatabase,
163    writer: &mut AuthorizedWriterOperation<'_>,
164) -> Result<StoreDeviceExclusionProposalRef, StoreDeviceExclusionError> {
165    let plan = Box::new(writer.prepare_plan().await?);
166    let target = plan.local_registration_reference_for_test();
167    let proposal_id = StoreDeviceExclusionProposalId::from_hash(ObjectHash::digest(
168        b"restart exclusion proposal",
169    ));
170    let mut exclusion = writer.device_exclusion();
171    let durable = exclusion.stage_proposal(plan, &target, proposal_id).await?;
172    let DurableStoreDeviceExclusionObject::Proposal { reference, .. } = durable.object() else {
173        return Err(StoreDeviceExclusionError::InvalidState(
174            "staged exclusion operation is not a proposal".to_string(),
175        ));
176    };
177    let reference = reference.clone();
178    exclusion.create_exact_object(&durable).await?;
179    database
180        .mark_store_device_exclusion_authority_uploaded(durable)
181        .await?;
182    Ok(reference)
183}
184
185pub(crate) struct AuthorizedDeviceExclusion<'operation, 'storage> {
186    writer: &'operation mut AuthorizedWriterOperation<'storage>,
187    database: StoreDatabase,
188    storage: std::sync::Arc<dyn CloudSyncObjectStorage>,
189}
190
191impl<'operation, 'storage> AuthorizedDeviceExclusion<'operation, 'storage> {
192    pub(crate) fn new(
193        writer: &'operation mut AuthorizedWriterOperation<'storage>,
194        database: StoreDatabase,
195        storage: std::sync::Arc<dyn CloudSyncObjectStorage>,
196    ) -> Self {
197        Self {
198            writer,
199            database,
200            storage,
201        }
202    }
203
204    async fn create_exact_object(
205        &self,
206        operation: &DurableStoreDeviceExclusionOperation,
207    ) -> Result<(), StoreDeviceExclusionJournalError> {
208        let context = operation.object().context();
209        let prefix = operation.object().semantic_prefix()?;
210        self.storage
211            .create_verified_protocol_object(
212                &context,
213                operation.object().prepared(),
214                prefix,
215                &operation.object().semantic_bytes(),
216            )
217            .await
218            .map_err(StoreDeviceExclusionJournalError::Storage)
219    }
220
221    pub(crate) async fn resume(
222        &mut self,
223    ) -> Result<Option<StoreDeviceExclusionResult>, StoreDeviceExclusionError> {
224        let database = self.database.clone();
225        let _lock = database.device_exclusion_permit().await;
226        let Some(operation) = database.active_outbound_store_device_exclusion().await? else {
227            return Ok(None);
228        };
229        self.drive(Box::new(operation)).await.map(Some)
230    }
231
232    async fn reject_active_operation(&self) -> Result<(), StoreDeviceExclusionError> {
233        if let Some(operation) = self
234            .database
235            .active_outbound_store_device_exclusion()
236            .await?
237        {
238            return Err(StoreDeviceExclusionError::OperationActive(
239                operation.operation_id(),
240            ));
241        }
242        Ok(())
243    }
244
245    pub(crate) async fn propose(
246        &mut self,
247        target: &coven_protocol::store_commit::StoreDeviceRegistrationRef,
248    ) -> Result<StoreDeviceExclusionResult, StoreDeviceExclusionError> {
249        let database = self.database.clone();
250        let _lock = database.device_exclusion_permit().await;
251        self.reject_active_operation().await?;
252        let durable = self.prepare_proposal(target).await?;
253        self.drive(Box::new(durable)).await
254    }
255
256    pub(crate) async fn cancel(
257        &mut self,
258        proposal: &StoreDeviceExclusionProposalRef,
259    ) -> Result<StoreDeviceExclusionResult, StoreDeviceExclusionError> {
260        self.publish_outcome(proposal, OutcomeIntent::Cancel).await
261    }
262
263    pub(crate) async fn exclude(
264        &mut self,
265        proposal: &StoreDeviceExclusionProposalRef,
266    ) -> Result<StoreDeviceExclusionResult, StoreDeviceExclusionError> {
267        self.publish_outcome(proposal, OutcomeIntent::Exclude).await
268    }
269
270    async fn prepare_proposal(
271        &mut self,
272        target: &coven_protocol::store_commit::StoreDeviceRegistrationRef,
273    ) -> Result<DurableStoreDeviceExclusionOperation, StoreDeviceExclusionError> {
274        let database = self.database.clone();
275        let plan = Box::new(self.writer.prepare_plan().await?);
276        if plan.is_local_registration(target) {
277            return Err(StoreDeviceExclusionError::CannotExcludeLocalDevice);
278        }
279        let state = Box::new(
280            database
281                .resolved_store_device_state(plan.device_state())
282                .await?,
283        );
284        require_active_target(&state, target)?;
285        let proposal_id = StoreDeviceExclusionProposalId::from_hash(ObjectHash::digest(
286            database.new_store_write_id().as_str().as_bytes(),
287        ));
288        self.stage_proposal(plan, target, proposal_id).await
289    }
290
291    /// Sign one exclusion proposal against `target`, reserve its exact slots,
292    /// and journal the candidate that activates it. The caller has already
293    /// established that `target` is an excludable active device and chosen the
294    /// proposal's identity.
295    async fn stage_proposal(
296        &mut self,
297        plan: Box<crate::sync::store::commit_publication::operation::commit_plan::StoreOperationCommitPlan>,
298        target: &coven_protocol::store_commit::StoreDeviceRegistrationRef,
299        proposal_id: StoreDeviceExclusionProposalId,
300    ) -> Result<DurableStoreDeviceExclusionOperation, StoreDeviceExclusionError> {
301        let database = self.database.clone();
302        let target_registration = database
303            .activated_store_device_registration(target.clone())
304            .await?;
305        let owner_grant = plan
306            .owner_grant()
307            .cloned()
308            .ok_or(StoreDeviceExclusionError::OwnerAuthorityRequired)?;
309        let outcome_prefix =
310            device_exclusion_outcome_semantic_prefix(target.device_id, proposal_id);
311        let outcome_context = ProtocolObjectContext::signed_plaintext(
312            plan.root().store_root_hash,
313            ProtocolObjectDomain::StoreDeviceExclusionOutcome,
314        );
315        let outcome_slot = self
316            .storage
317            .allocate_protocol_slot(&outcome_context, &outcome_prefix, ".json")
318            .await?;
319        let proposal = plan.sign_device_exclusion_proposal(
320            proposal_id,
321            target.clone(),
322            target_registration.value(),
323            outcome_slot,
324            owner_grant,
325        )?;
326        let proposal_prefix = device_exclusion_proposal_semantic_prefix(
327            target.device_id,
328            proposal_id,
329            proposal.proposal_hash(),
330        );
331        let proposal_context = ProtocolObjectContext::signed_plaintext(
332            plan.root().store_root_hash,
333            ProtocolObjectDomain::StoreDeviceExclusionProposal,
334        );
335        let proposal_slot = self
336            .storage
337            .allocate_protocol_slot(&proposal_context, &proposal_prefix, ".json")
338            .await?;
339        let prepared = self.storage.prepare_protocol_object(
340            &proposal_context,
341            proposal_slot,
342            &proposal_prefix,
343            proposal.to_bytes(),
344        )?;
345        let reference = StoreDeviceExclusionProposalRef::from_proposal(
346            &proposal,
347            prepared.reference().clone(),
348        )?;
349        let retained = plan.retain_device_exclusion_proposal(
350            reference.clone(),
351            &proposal,
352            target_registration.value(),
353        )?;
354        let transition = self
355            .writer
356            .prepare_authority_change(
357                plan.membership(),
358                coven_protocol::membership::StoreAuthorityChange::DeviceExclusionProposal {
359                    proposal: reference.clone(),
360                },
361            )
362            .await?;
363        let mut candidate = Box::pin(self.writer.prepare_candidate(
364            &plan,
365            StoreOperationBatch::DeviceExclusionProposal {
366                proposal: retained,
367                transition: transition.transition.clone(),
368            },
369        ))
370        .await?;
371        let publication = self
372            .writer
373            .finish_store_membership_transition(transition, candidate.reference.clone())
374            .await?;
375        candidate
376            .attach_merge_membership_proof(&publication)
377            .map_err(StoreError::from)?;
378        let operation = DurableStoreDeviceExclusionOperation::prepared(
379            DurableStoreDeviceExclusionObject::Proposal {
380                reference,
381                value: proposal,
382                prepared,
383            },
384            candidate,
385        )?;
386        let durable = Box::pin(database.begin_outbound_store_device_exclusion(operation)).await?;
387        drop(plan);
388        #[cfg(any(test, feature = "test-utils"))]
389        database
390            .reach_test_point(
391                coven_database::DatabaseTestPoint::StoreDeviceExclusionCandidateStaged,
392            )
393            .await;
394        Ok(durable)
395    }
396
397    async fn publish_outcome(
398        &mut self,
399        proposal_ref: &StoreDeviceExclusionProposalRef,
400        intent: OutcomeIntent,
401    ) -> Result<StoreDeviceExclusionResult, StoreDeviceExclusionError> {
402        let database = self.database.clone();
403        let _lock = database.device_exclusion_permit().await;
404        self.reject_active_operation().await?;
405        let durable = self.prepare_outcome(proposal_ref, intent).await?;
406        self.drive(Box::new(durable)).await
407    }
408
409    async fn prepare_outcome(
410        &mut self,
411        proposal_ref: &StoreDeviceExclusionProposalRef,
412        intent: OutcomeIntent,
413    ) -> Result<DurableStoreDeviceExclusionOperation, StoreDeviceExclusionError> {
414        let database = self.database.clone();
415        let plan = self.writer.prepare_plan().await?;
416        let owner_grant = plan
417            .owner_grant()
418            .cloned()
419            .ok_or(StoreDeviceExclusionError::OwnerAuthorityRequired)?;
420        let proposal = self
421            .writer
422            .device_exclusion_history()
423            .load_proposal(proposal_ref)
424            .await?;
425        let state = database
426            .resolved_store_device_state(plan.device_state())
427            .await?;
428        require_pending_proposal(&state, proposal_ref)?;
429        let outcome = match intent {
430            OutcomeIntent::Cancel => {
431                StoreDeviceExclusionOutcome::Cancelled(plan.sign_device_exclusion_cancellation(
432                    proposal_ref.clone(),
433                    &proposal.object.value,
434                    owner_grant,
435                )?)
436            }
437            OutcomeIntent::Exclude => {
438                StoreDeviceExclusionOutcome::Excluded(plan.sign_device_exclusion(
439                    proposal_ref.clone(),
440                    &proposal.object.value,
441                    proposal_ref.target.clone(),
442                    &proposal.target,
443                    owner_grant,
444                )?)
445            }
446        };
447        let prefix = device_exclusion_outcome_semantic_prefix(
448            proposal_ref.target.device_id,
449            proposal_ref.proposal_id,
450        );
451        let context = ProtocolObjectContext::signed_plaintext(
452            plan.root().store_root_hash,
453            ProtocolObjectDomain::StoreDeviceExclusionOutcome,
454        );
455        let prepared = self.storage.prepare_protocol_object(
456            &context,
457            proposal.object.value.outcome_slot.clone(),
458            &prefix,
459            outcome.to_bytes(),
460        )?;
461        let reference = StoreDeviceExclusionOutcomeRef::from_outcome(
462            &outcome,
463            &proposal.object.value,
464            prepared.reference().clone(),
465        )?;
466        let retained_proposal =
467            coven_protocol::store_commit::RetainedStoreDeviceExclusionProposal::from_verified(
468                &proposal,
469            );
470        let retained =
471            plan.retain_device_exclusion_outcome(&reference, retained_proposal, &outcome)?;
472        let transition = self
473            .writer
474            .prepare_authority_change(
475                plan.membership(),
476                coven_protocol::membership::StoreAuthorityChange::DeviceExclusionOutcome {
477                    outcome: reference.clone(),
478                },
479            )
480            .await?;
481        let mut candidate = Box::pin(self.writer.prepare_candidate(
482            &plan,
483            StoreOperationBatch::DeviceExclusionOutcome {
484                outcome: retained,
485                transition: transition.transition.clone(),
486            },
487        ))
488        .await?;
489        let publication = self
490            .writer
491            .finish_store_membership_transition(transition, candidate.reference.clone())
492            .await?;
493        candidate
494            .attach_merge_membership_proof(&publication)
495            .map_err(StoreError::from)?;
496        let operation = DurableStoreDeviceExclusionOperation::prepared(
497            DurableStoreDeviceExclusionObject::Outcome {
498                reference,
499                value: outcome,
500                prepared,
501            },
502            candidate,
503        )?;
504        let durable = Box::pin(database.begin_outbound_store_device_exclusion(operation)).await?;
505        drop(plan);
506        #[cfg(any(test, feature = "test-utils"))]
507        database
508            .reach_test_point(
509                coven_database::DatabaseTestPoint::StoreDeviceExclusionCandidateStaged,
510            )
511            .await;
512        Ok(durable)
513    }
514
515    async fn drive(
516        &mut self,
517        operation: Box<DurableStoreDeviceExclusionOperation>,
518    ) -> Result<StoreDeviceExclusionResult, StoreDeviceExclusionError> {
519        if operation.is_completed() {
520            return completion_result(&operation);
521        }
522        if let Some(result) = self.ensure_authority_uploaded(&operation).await? {
523            return Ok(result);
524        }
525        self.publish_candidate(&operation).await
526    }
527
528    async fn publish_candidate(
529        &mut self,
530        operation: &DurableStoreDeviceExclusionOperation,
531    ) -> Result<StoreDeviceExclusionResult, StoreDeviceExclusionError> {
532        let candidate = operation.candidate().cloned().ok_or_else(|| {
533            StoreDeviceExclusionError::InvalidState(
534                "active exclusion operation has no activation candidate".to_string(),
535            )
536        })?;
537        let remote_objects = operation.remote_objects()?;
538        self.writer
539            .publish_membership_authority(&candidate, &remote_objects)
540            .await?;
541        let completion = coven_protocol::membership_mutation::StoreMembershipJournalCompletion::DeviceExclusion {
542            operation: Box::new(operation.clone()),
543            remote_objects: remote_objects.into_iter().map(|object| object.into_record()).collect(),
544        };
545        self.writer
546            .publish_membership_activation(Box::new(candidate), completion)
547            .await?;
548        completion_result(&operation.activated()?)
549    }
550
551    async fn ensure_authority_uploaded(
552        &mut self,
553        operation: &DurableStoreDeviceExclusionOperation,
554    ) -> Result<Option<StoreDeviceExclusionResult>, StoreDeviceExclusionError> {
555        let database = self.database.clone();
556        match Box::pin(self.create_exact_object(operation)).await {
557            Ok(()) => {}
558            Err(StoreDeviceExclusionJournalError::Storage(
559                coven_protocol::objects::StorageError::SlotCollision(_),
560            )) => {
561                if let Some(completed) = self.resolve_object_collision(operation.clone()).await? {
562                    return completion_result(&completed).map(Some);
563                }
564            }
565            Err(error) => return Err(error.into()),
566        }
567        Box::pin(database.mark_store_device_exclusion_authority_uploaded(operation.clone()))
568            .await?;
569        Ok(None)
570    }
571
572    async fn resolve_object_collision(
573        &mut self,
574        operation: DurableStoreDeviceExclusionOperation,
575    ) -> Result<Option<DurableStoreDeviceExclusionOperation>, StoreDeviceExclusionError> {
576        let database = self.database.clone();
577        let intended = operation.object();
578        let (bytes, prepared) = self
579            .storage
580            .read_prepared_protocol_slot(
581                &intended.context(),
582                intended.object().slot(),
583                intended.semantic_prefix()?,
584            )
585            .await?;
586        if bytes == intended.semantic_bytes() {
587            if prepared.reference() != intended.object() {
588                return Err(StoreDeviceExclusionError::InvalidState(
589                    "identical exclusion bytes produced a different exact object reference"
590                        .to_string(),
591                ));
592            }
593            return Ok(None);
594        }
595        let DurableStoreDeviceExclusionObject::Outcome {
596            reference: intended_ref,
597            ..
598        } = intended
599        else {
600            return Err(StoreDeviceExclusionError::InvalidState(
601                "proposal hash slot contains different signed bytes".to_string(),
602            ));
603        };
604        let proposal = self
605            .writer
606            .device_exclusion_history()
607            .load_proposal(intended_ref.proposal())
608            .await?;
609        let unverified: StoreDeviceExclusionOutcome = serde_json::from_slice(&bytes)?;
610        let winner_ref = StoreDeviceExclusionOutcomeRef::from_outcome(
611            &unverified,
612            &proposal.object.value,
613            prepared.reference().clone(),
614        )?;
615        let winner = self
616            .writer
617            .device_exclusion_history()
618            .load_outcome(&winner_ref, &proposal)
619            .await?;
620        if winner.object.value != unverified || winner.object.bytes != bytes {
621            return Err(StoreDeviceExclusionError::InvalidState(
622                "occupied exclusion outcome changed during exact verification".to_string(),
623            ));
624        }
625        let completed = Box::pin(database.complete_outbound_store_device_exclusion_slot_loss(
626            operation,
627            DurableStoreDeviceExclusionObject::Outcome {
628                reference: winner_ref,
629                value: unverified,
630                prepared,
631            },
632        ))
633        .await?;
634        Ok(Some(completed))
635    }
636}
637
638#[derive(Clone, Copy)]
639enum OutcomeIntent {
640    Exclude,
641    Cancel,
642}
643
644fn completion_result(
645    operation: &DurableStoreDeviceExclusionOperation,
646) -> Result<StoreDeviceExclusionResult, StoreDeviceExclusionError> {
647    let DurableStoreDeviceExclusionOperation::Completed(completion) = operation else {
648        return Err(StoreDeviceExclusionError::InvalidState(
649            "Store-device exclusion operation is not complete".to_string(),
650        ));
651    };
652    Ok(match completion {
653        StoreDeviceExclusionCompletion::Activated { object, candidate } => match object {
654            DurableStoreDeviceExclusionObject::Proposal { reference, .. } => {
655                StoreDeviceExclusionResult::ProposalActivated {
656                    proposal: reference.clone(),
657                    commit: candidate.reference.clone(),
658                }
659            }
660            DurableStoreDeviceExclusionObject::Outcome { reference, .. } => {
661                StoreDeviceExclusionResult::OutcomeActivated {
662                    outcome: reference.clone(),
663                    commit: candidate.reference.clone(),
664                }
665            }
666        },
667        StoreDeviceExclusionCompletion::OutcomeSlotOccupied { intended, winner } => {
668            let (
669                DurableStoreDeviceExclusionObject::Outcome {
670                    reference: intended,
671                    ..
672                },
673                DurableStoreDeviceExclusionObject::Outcome {
674                    reference: winner, ..
675                },
676            ) = (intended, winner)
677            else {
678                return Err(StoreDeviceExclusionError::InvalidState(
679                    "outcome-slot completion contains a non-outcome object".to_string(),
680                ));
681            };
682            StoreDeviceExclusionResult::OutcomeSlotOccupied {
683                intended: intended.clone(),
684                winner: winner.clone(),
685            }
686        }
687    })
688}
689
690fn require_active_target(
691    state: &coven_protocol::store_commit::ResolvedStoreDeviceState,
692    target: &coven_protocol::store_commit::StoreDeviceRegistrationRef,
693) -> Result<(), StoreDeviceExclusionError> {
694    if !matches!(
695        state.devices.get(&target.device_id),
696        Some(record)
697            if record.registration == *target && matches!(record.status, StoreDeviceStatus::Active)
698    ) {
699        return Err(StoreDeviceExclusionError::TargetNotActive);
700    }
701    Ok(())
702}
703
704fn require_pending_proposal(
705    state: &coven_protocol::store_commit::ResolvedStoreDeviceState,
706    proposal: &StoreDeviceExclusionProposalRef,
707) -> Result<(), StoreDeviceExclusionError> {
708    require_active_target(state, &proposal.target)?;
709    if !matches!(
710        state.devices
711            .get(&proposal.target.device_id)
712            .and_then(|record| record.proposals.get(&proposal.proposal_id)),
713        Some(StoreDeviceProposalState::Pending { proposal: current }) if current == proposal
714    ) {
715        return Err(StoreDeviceExclusionError::InvalidState(
716            "exclusion proposal is not pending at the exact candidate predecessor".to_string(),
717        ));
718    }
719    Ok(())
720}
721
722#[cfg(test)]
723mod completion_tests;
724#[cfg(test)]
725mod recovery_authority_tests;
726#[cfg(test)]
727mod snapshot_authority_tests;
728#[cfg(test)]
729mod tests;