1mod 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
85pub(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#[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 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;