Skip to main content

coven_database/store/store_session/
device_exclusion.rs

1use std::future::Future;
2use std::pin::Pin;
3
4use rusqlite::{Connection, OptionalExtension};
5
6use super::*;
7use crate::store::StoreSession;
8use crate::{mark_remote_object_uploaded_on, update_remote_object_on};
9use coven_protocol::device_exclusion_journal::{
10    DurableStoreDeviceExclusionObject, DurableStoreDeviceExclusionOperation,
11    StoreDeviceExclusionCompletion, StoreDeviceExclusionJournalError,
12};
13use coven_protocol::remote_object::{
14    remote_object_id, ClosedRemoteObject, RemoteObjectRecord, RetainedAuthorityObjectState,
15};
16use coven_protocol::store_commit::ObjectHash;
17
18pub(crate) fn store_device_exclusion_journal_error(
19    error: StoreDeviceExclusionJournalError,
20) -> DbError {
21    DbError::from(error)
22}
23
24pub(crate) fn parse_store_device_exclusion_operation(
25    operation_id: ObjectHash,
26    raw: &str,
27) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
28    let operation: DurableStoreDeviceExclusionOperation =
29        serde_json::from_str(raw).map_err(|error| {
30            DbError::context(
31                format!(
32                    "Store-device exclusion operation {operation_id} has invalid durable state"
33                ),
34                error,
35            )
36        })?;
37    operation
38        .validate()
39        .map_err(store_device_exclusion_journal_error)?;
40    if operation.operation_id() != operation_id {
41        return Err(DbError::Message(format!(
42            "Store-device exclusion operation key {operation_id} differs from its signed object {}",
43            operation.operation_id()
44        )));
45    }
46    Ok(operation)
47}
48
49pub(crate) fn load_store_device_exclusion_on(
50    conn: &Connection,
51    operation_id: ObjectHash,
52) -> Result<Option<DurableStoreDeviceExclusionOperation>, DbError> {
53    conn.query_row(
54        "SELECT state FROM outbound_store_device_exclusion WHERE operation_id = ?1",
55        [operation_id.to_string()],
56        |row| row.get::<_, String>(0),
57    )
58    .optional()
59    .map_err(DbError::from)?
60    .map(|raw| parse_store_device_exclusion_operation(operation_id, &raw))
61    .transpose()
62}
63
64pub(crate) fn load_active_store_device_exclusion_on(
65    conn: &Connection,
66) -> Result<Option<DurableStoreDeviceExclusionOperation>, DbError> {
67    conn.query_row(
68        "SELECT operation_id, state FROM outbound_store_device_exclusion WHERE active_key = 1",
69        [],
70        |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
71    )
72    .optional()
73    .map_err(DbError::from)?
74    .map(|(raw_id, raw)| {
75        let operation_id = raw_id
76            .parse::<ObjectHash>()
77            .map_err(|error| DbError::context("Store-device exclusion operation id", error))?;
78        let operation = parse_store_device_exclusion_operation(operation_id, &raw)?;
79        if operation.is_completed() {
80            return Err(DbError::Message(
81                "completed Store-device exclusion remains active".to_string(),
82            ));
83        }
84        Ok(operation)
85    })
86    .transpose()
87}
88
89pub(crate) fn insert_store_device_exclusion_on(
90    conn: &Connection,
91    operation: &DurableStoreDeviceExclusionOperation,
92    active: bool,
93) -> Result<(), DbError> {
94    operation
95        .validate()
96        .map_err(store_device_exclusion_journal_error)?;
97    if active == operation.is_completed() {
98        return Err(DbError::Message(
99            "Store-device exclusion active marker differs from its closed state".to_string(),
100        ));
101    }
102    let encoded = serde_json::to_string(operation)
103        .map_err(|error| DbError::context("serialize Store-device exclusion operation", error))?;
104    conn.execute(
105        "INSERT INTO outbound_store_device_exclusion (operation_id, active_key, state)
106         VALUES (?1, ?2, ?3)",
107        rusqlite::params![
108            operation.operation_id().to_string(),
109            active.then_some(1_i64),
110            encoded,
111        ],
112    )
113    .map(|_| ())
114    .map_err(DbError::from)
115}
116
117pub(crate) fn require_store_device_exclusion_transition_on(
118    conn: &Connection,
119    expected: &DurableStoreDeviceExclusionOperation,
120    next: &DurableStoreDeviceExclusionOperation,
121) -> Result<(), DbError> {
122    if !expected.allows_transition_to(next) {
123        return Err(DbError::Message(
124            "invalid Store-device exclusion journal transition".to_string(),
125        ));
126    }
127    let expected_state = serde_json::to_string(expected)
128        .map_err(|error| DbError::context("serialize expected Store-device exclusion", error))?;
129    let current = conn
130        .query_row(
131            "SELECT state FROM outbound_store_device_exclusion WHERE operation_id = ?1",
132            [expected.operation_id().to_string()],
133            |row| row.get::<_, String>(0),
134        )
135        .optional()
136        .map_err(DbError::from)?
137        .ok_or_else(|| {
138            DbError::Message("Store-device exclusion journal disappeared".to_string())
139        })?;
140    if current != expected_state {
141        return Err(DbError::Message(
142            "Store-device exclusion journal changed during transition".to_string(),
143        ));
144    }
145    Ok(())
146}
147
148pub(crate) fn update_store_device_exclusion_on(
149    conn: &Connection,
150    expected: &DurableStoreDeviceExclusionOperation,
151    next: &DurableStoreDeviceExclusionOperation,
152    active: bool,
153) -> Result<(), DbError> {
154    require_store_device_exclusion_transition_on(conn, expected, next)?;
155    if active == next.is_completed() {
156        return Err(DbError::Message(
157            "Store-device exclusion active marker differs from its next state".to_string(),
158        ));
159    }
160    let expected_state = serde_json::to_string(expected)
161        .map_err(|error| DbError::context("serialize expected Store-device exclusion", error))?;
162    let next_state = serde_json::to_string(next)
163        .map_err(|error| DbError::context("serialize next Store-device exclusion", error))?;
164    let updated = conn
165        .execute(
166            "UPDATE outbound_store_device_exclusion
167             SET active_key = ?3, state = ?4
168             WHERE operation_id = ?1 AND state = ?2",
169            rusqlite::params![
170                expected.operation_id().to_string(),
171                expected_state,
172                active.then_some(1_i64),
173                next_state,
174            ],
175        )
176        .map_err(DbError::from)?;
177    if updated != 1 {
178        return Err(DbError::Message(
179            "Store-device exclusion journal disappeared during transition".to_string(),
180        ));
181    }
182    Ok(())
183}
184
185impl StoreSession<'_> {
186    fn begin_outbound_store_device_exclusion(
187        &mut self,
188        operation: DurableStoreDeviceExclusionOperation,
189        remotes: Vec<ClosedRemoteObject>,
190    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
191        let conn = self.conn;
192        let tx = conn.unchecked_transaction().map_err(DbError::from)?;
193        if let Some(active) = load_active_store_device_exclusion_on(&tx)? {
194            if active.operation_id() != operation.operation_id() {
195                return Err(DbError::Message(format!(
196                    "Store-device exclusion operation {} remains active",
197                    active.operation_id()
198                )));
199            }
200            return Ok(active);
201        }
202        let operation_id = operation.operation_id();
203        if let Some(existing) = load_store_device_exclusion_on(&tx, operation_id)? {
204            if existing != operation || !existing.is_completed() {
205                return Err(DbError::Message(format!(
206                    "Store-device exclusion operation {operation_id} already has different durable state"
207                )));
208            }
209            return Ok(existing);
210        }
211        for remote in &remotes {
212            persist_exact_remote_object_on(
213                &tx,
214                self.store_dir,
215                remote,
216                "Store-device exclusion candidate object",
217            )?;
218        }
219        insert_store_device_exclusion_on(&tx, &operation, true)?;
220        tx.commit().map_err(DbError::from)?;
221        Ok(operation)
222    }
223
224    fn active_outbound_store_device_exclusion(
225        &mut self,
226    ) -> Result<Option<DurableStoreDeviceExclusionOperation>, DbError> {
227        load_active_store_device_exclusion_on(self.conn)
228    }
229
230    fn replace_outbound_store_device_exclusion_candidate(
231        &mut self,
232        expected: DurableStoreDeviceExclusionOperation,
233        next: DurableStoreDeviceExclusionOperation,
234        candidate: coven_protocol::prepared_commit::PreparedStoreOperationCommit,
235    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
236        let conn = self.conn;
237        let tx = conn.unchecked_transaction().map_err(DbError::from)?;
238        require_store_device_exclusion_transition_on(&tx, &expected, &next)?;
239        let next_candidate = next.candidate().expect("validated candidate state");
240        match (candidate.head_ref(), next_candidate.head_ref()) {
241            (current, replacement) if current != replacement => {
242                let (winner, prepared) = next_candidate.publication();
243                replace_prepared_merge_head_remote_on(
244                    &tx,
245                    self.store_dir,
246                    &current.object,
247                    winner,
248                    prepared,
249                    &candidate.reference,
250                )?;
251            }
252            _ => {}
253        }
254        update_store_device_exclusion_on(&tx, &expected, &next, true)?;
255        tx.commit().map_err(DbError::from)?;
256        Ok(next)
257    }
258
259    fn complete_outbound_store_device_exclusion_activation(
260        &mut self,
261        expected: Box<DurableStoreDeviceExclusionOperation>,
262        next: Box<DurableStoreDeviceExclusionOperation>,
263    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
264        let conn = self.conn;
265        let tx = conn.unchecked_transaction().map_err(DbError::from)?;
266        require_store_device_exclusion_transition_on(&tx, expected.as_ref(), next.as_ref())?;
267        let candidate = expected
268            .candidate()
269            .expect("candidate-prepared exclusion has a candidate");
270        let stream = candidate.reference.coord.stream_id.to_string();
271        if crate::store::materialized_commit_index::materialized_commit_ref_on(
272            &tx,
273            &stream,
274            candidate.reference.coord.sequence(),
275        )? != Some(candidate.reference.clone())
276        {
277            return Err(DbError::Message(
278                "Store-device exclusion completion is not materialized at its exact position"
279                    .to_string(),
280            ));
281        }
282        update_store_device_exclusion_on(&tx, expected.as_ref(), next.as_ref(), false)?;
283        tx.commit().map_err(DbError::from)?;
284        Ok(*next)
285    }
286
287    fn complete_outbound_store_device_exclusion_slot_loss(
288        &mut self,
289        expected: DurableStoreDeviceExclusionOperation,
290        next: DurableStoreDeviceExclusionOperation,
291        remotes: Vec<ClosedRemoteObject>,
292    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
293        let conn = self.conn;
294        let tx = conn.unchecked_transaction().map_err(DbError::from)?;
295        require_store_device_exclusion_transition_on(&tx, &expected, &next)?;
296        for remote in &remotes {
297            let object_id = remote.object_id();
298            let current = load_remote_object_on(&tx, object_id)?;
299            let unuploaded = matches!(
300                &current,
301                RemoteObjectRecord::CandidateCommit(record)
302                    if matches!(record.state, coven_protocol::remote_object::CandidateCommitState::Prepared)
303            ) || matches!(
304                &current,
305                RemoteObjectRecord::RetainedAuthority(record)
306                    if matches!(
307                        record.state,
308                        coven_protocol::remote_object::RetainedAuthorityObjectState::Prepared { .. }
309                    )
310            );
311            if current != **remote || !unuploaded {
312                return Err(DbError::Message(format!(
313                    "outcome-slot loss cannot discard uploaded exclusion object {object_id}"
314                )));
315            }
316            if !crate::remote_object_records::delete_remote_object_on(&tx, object_id)? {
317                return Err(DbError::Message(format!(
318                    "unuploaded exclusion object {object_id} disappeared during slot resolution"
319                )));
320            }
321        }
322        update_store_device_exclusion_on(&tx, &expected, &next, false)?;
323        tx.commit().map_err(DbError::from)?;
324        Ok(next)
325    }
326
327    fn begin_outbound_store_device_exclusion_nonactivation(
328        &mut self,
329        expected: DurableStoreDeviceExclusionOperation,
330        next: DurableStoreDeviceExclusionOperation,
331        candidate: coven_protocol::prepared_commit::PreparedStoreOperationCommit,
332        nonactivation: coven_protocol::remote_object::CandidateNonactivation,
333    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
334        let conn = self.conn;
335        let tx = conn.unchecked_transaction().map_err(DbError::from)?;
336        require_store_device_exclusion_transition_on(&tx, &expected, &next)?;
337        let authority_id = remote_object_id(expected.object().object());
338        if begin_remote_candidate_nonactivation_on(&tx, authority_id, nonactivation.clone())?
339            .is_some()
340        {
341            return Err(DbError::Message(
342                "uploaded exclusion authority became a deletion target".to_string(),
343            ));
344        }
345        let head = candidate.head_ref();
346        if begin_remote_candidate_nonactivation_on(
347            &tx,
348            remote_object_id(&head.object),
349            nonactivation.clone(),
350        )?
351        .is_some()
352        {
353            return Err(DbError::Message(
354                "Store-device exclusion activation head became a deletion target".to_string(),
355            ));
356        }
357        if begin_remote_candidate_nonactivation_on(
358            &tx,
359            remote_object_id(&candidate.reference.object),
360            nonactivation,
361        )?
362        .is_none()
363        {
364            return Err(DbError::Message(
365                "losing Store-device exclusion commit has no deletion target".to_string(),
366            ));
367        }
368        update_store_device_exclusion_on(&tx, &expected, &next, true)?;
369        tx.commit().map_err(DbError::from)?;
370        Ok(next)
371    }
372
373    fn begin_outbound_store_device_exclusion_replacement(
374        &mut self,
375        expected: DurableStoreDeviceExclusionOperation,
376        next: DurableStoreDeviceExclusionOperation,
377        replacement_candidate: coven_protocol::prepared_commit::PreparedStoreOperationCommit,
378        losing_candidate: coven_protocol::prepared_commit::PreparedStoreOperationCommit,
379        authority_id: ObjectHash,
380        replacement_remotes: Vec<ClosedRemoteObject>,
381        nonactivation: coven_protocol::remote_object::CandidateNonactivation,
382    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
383        let conn = self.conn;
384        let tx = conn.unchecked_transaction().map_err(DbError::from)?;
385        require_store_device_exclusion_transition_on(&tx, &expected, &next)?;
386        for remote in replacement_remotes
387            .iter()
388            .filter(|remote| remote.object_id() != authority_id)
389        {
390            persist_exact_remote_object_on(
391                &tx,
392                self.store_dir,
393                remote,
394                "replacement Store-device exclusion candidate object",
395            )?;
396        }
397        let mut authority = load_remote_object_on(&tx, authority_id)?;
398        authority
399            .add_retained_authority_candidate(replacement_candidate.reference.clone())
400            .map_err(|error| {
401                DbError::context("attach replacement exclusion candidate authority", error)
402            })?;
403        update_remote_object_on(&tx, authority_id, &authority)?;
404        if begin_remote_candidate_nonactivation_on(&tx, authority_id, nonactivation.clone())?
405            .is_some()
406        {
407            return Err(DbError::Message(
408                "reusable exclusion outcome became a deletion target".to_string(),
409            ));
410        }
411        let head = losing_candidate.head_ref();
412        if begin_remote_candidate_nonactivation_on(
413            &tx,
414            remote_object_id(&head.object),
415            nonactivation.clone(),
416        )?
417        .is_some()
418        {
419            return Err(DbError::Message(
420                "losing exclusion activation head became a deletion target".to_string(),
421            ));
422        }
423        if begin_remote_candidate_nonactivation_on(
424            &tx,
425            remote_object_id(&losing_candidate.reference.object),
426            nonactivation,
427        )?
428        .is_none()
429        {
430            return Err(DbError::Message(
431                "losing exclusion candidate has no exact deletion target".to_string(),
432            ));
433        }
434        update_store_device_exclusion_on(&tx, &expected, &next, true)?;
435        tx.commit().map_err(DbError::from)?;
436        Ok(next)
437    }
438
439    fn nonactivating_store_device_exclusion_cleanup_targets(
440        &mut self,
441        expected: &DurableStoreDeviceExclusionOperation,
442    ) -> Result<Vec<CandidateCleanupObject>, DbError> {
443        let conn = self.conn;
444        let current =
445            load_store_device_exclusion_on(conn, expected.operation_id())?.ok_or_else(|| {
446                DbError::Message("Store-device exclusion journal is absent".to_string())
447            })?;
448        if current != *expected {
449            return Err(DbError::Message(
450                "Store-device exclusion is not awaiting candidate cleanup".to_string(),
451            ));
452        }
453        let candidate = match &current {
454            DurableStoreDeviceExclusionOperation::CandidateNonactivating { candidate, .. } => {
455                candidate
456            }
457            DurableStoreDeviceExclusionOperation::ReplacingCandidate { losing, .. } => {
458                &losing.candidate
459            }
460            _ => {
461                return Err(DbError::Message(
462                    "Store-device exclusion is not awaiting candidate cleanup".to_string(),
463                ));
464            }
465        };
466        super::candidate_records::candidate_cleanup_targets_on(
467            conn,
468            &candidate.reference,
469            std::slice::from_ref(&candidate.reference.object),
470        )
471    }
472
473    fn complete_store_device_exclusion_replacement_cleanup(
474        &mut self,
475        expected: DurableStoreDeviceExclusionOperation,
476        next: DurableStoreDeviceExclusionOperation,
477    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
478        let DurableStoreDeviceExclusionOperation::ReplacingCandidate { object, losing, .. } =
479            &expected
480        else {
481            unreachable!("validated replacement state")
482        };
483        let conn = self.conn;
484        let tx = conn.unchecked_transaction().map_err(DbError::from)?;
485        require_store_device_exclusion_transition_on(&tx, &expected, &next)?;
486        let commit_id = remote_object_id(&losing.candidate.reference.object);
487        let head = losing.candidate.head_ref();
488        let head_id = remote_object_id(&head.object);
489        super::candidate_records::require_candidate_cleanup_complete_on(
490            &tx,
491            &losing.candidate.reference,
492            &[
493                losing.candidate.reference.object.clone(),
494                object.object().clone(),
495                head.object.clone(),
496            ],
497            "replaced exclusion cleanup is incomplete",
498        )?;
499        let commit = load_remote_object_on(&tx, commit_id)?;
500        if commit
501            .candidate_nonactivation_proof(&losing.candidate.reference)
502            .map_err(DbError::from)?
503            != Some(&losing.proof)
504        {
505            return Err(DbError::Message(
506                "replaced exclusion commit lacks complete nonactivation evidence".to_string(),
507            ));
508        }
509        let authority = load_remote_object_on(&tx, remote_object_id(object.object()))?;
510        if authority
511            .candidate_nonactivation_proof(&losing.candidate.reference)
512            .map_err(DbError::from)?
513            != Some(&losing.proof)
514        {
515            return Err(DbError::Message(
516                "reusable exclusion outcome lacks its former candidate proof".to_string(),
517            ));
518        }
519        let mut removable = vec![commit_id];
520        let remote = load_remote_object_on(&tx, head_id)?;
521        if remote
522            .candidate_nonactivation_proof(&losing.candidate.reference)
523            .map_err(DbError::from)?
524            != Some(&losing.proof)
525        {
526            return Err(DbError::Message(
527                "replaced exclusion head lacks complete nonactivation evidence".to_string(),
528            ));
529        }
530        removable.push(head_id);
531        super::candidate_records::delete_remote_objects_on(&tx, removable, "replaced exclusion")?;
532        update_store_device_exclusion_on(&tx, &expected, &next, true)?;
533        tx.commit().map_err(DbError::from)?;
534        Ok(next)
535    }
536
537    fn complete_nonactivating_store_device_exclusion(
538        &mut self,
539        expected: DurableStoreDeviceExclusionOperation,
540        next: DurableStoreDeviceExclusionOperation,
541    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
542        let DurableStoreDeviceExclusionOperation::CandidateNonactivating {
543            object,
544            candidate,
545            proof,
546        } = &expected
547        else {
548            unreachable!("validated nonactivating state")
549        };
550        let conn = self.conn;
551        let tx = conn.unchecked_transaction().map_err(DbError::from)?;
552        require_store_device_exclusion_transition_on(&tx, &expected, &next)?;
553        let commit_id = remote_object_id(&candidate.reference.object);
554        let head = candidate.head_ref();
555        let head_id = remote_object_id(&head.object);
556        super::candidate_records::require_candidate_cleanup_complete_on(
557            &tx,
558            &candidate.reference,
559            &[candidate.reference.object.clone(), head.object.clone()],
560            "nonactivating exclusion cleanup is incomplete",
561        )?;
562        let commit = load_remote_object_on(&tx, commit_id)?;
563        if commit
564            .candidate_nonactivation_proof(&candidate.reference)
565            .map_err(DbError::from)?
566            != Some(proof)
567        {
568            return Err(DbError::Message(
569                "losing exclusion commit lacks complete exact nonactivation evidence".to_string(),
570            ));
571        }
572        let inert = load_protocol_inert_object_on(&tx, remote_object_id(object.object()))?;
573        if inert
574            .candidate_nonactivation_proof(&candidate.reference)
575            .map_err(DbError::from)?
576            != Some(proof)
577        {
578            return Err(DbError::Message(
579                "protocol-inert exclusion object lacks its candidate proof".to_string(),
580            ));
581        }
582        let mut removable = vec![commit_id];
583        let head_remote = load_remote_object_on(&tx, head_id)?;
584        if head_remote
585            .candidate_nonactivation_proof(&candidate.reference)
586            .map_err(DbError::from)?
587            != Some(proof)
588        {
589            return Err(DbError::Message(
590                "losing exclusion head lacks complete nonactivation evidence".to_string(),
591            ));
592        }
593        removable.push(head_id);
594        super::candidate_records::delete_remote_objects_on(
595            &tx,
596            removable,
597            "nonactivating exclusion",
598        )?;
599        update_store_device_exclusion_on(&tx, &expected, &next, false)?;
600        tx.commit().map_err(DbError::from)?;
601        Ok(next)
602    }
603
604    fn mark_store_device_exclusion_authority_uploaded(
605        &mut self,
606        expected: ClosedRemoteObject,
607        candidate: StoreBatchCommitRef,
608    ) -> Result<(), DbError> {
609        let conn = self.conn;
610        let object_id = expected.object_id();
611        let current = load_remote_object_on(conn, object_id)?;
612        let (
613            RemoteObjectRecord::RetainedAuthority(expected_record),
614            RemoteObjectRecord::RetainedAuthority(current_record),
615        ) = (expected.record(), &current)
616        else {
617            return Err(DbError::Message(
618                "Store-device exclusion authority is not retained authority".to_string(),
619            ));
620        };
621        if expected_record.identity != current_record.identity
622            || expected_record.payloads != current_record.payloads
623        {
624            return Err(DbError::Message(
625                "Store-device exclusion authority changed before upload completion".to_string(),
626            ));
627        }
628        match &current_record.state {
629            RetainedAuthorityObjectState::Prepared { ownership }
630                if ownership.pending.contains(&candidate) =>
631            {
632                mark_remote_object_uploaded_on(conn, current)?;
633            }
634            RetainedAuthorityObjectState::UploadedVerified { ownership }
635                if ownership.pending.contains(&candidate) => {}
636            _ => {
637                return Err(DbError::Message(
638                    "Store-device exclusion authority does not belong to its current candidate"
639                        .to_string(),
640                ));
641            }
642        }
643        Ok(())
644    }
645
646    #[cfg(any(test, feature = "test-utils"))]
647    fn outbound_store_device_exclusion_operations(
648        &mut self,
649    ) -> Result<Vec<DurableStoreDeviceExclusionOperation>, DbError> {
650        let conn = self.conn;
651        let mut statement = conn
652            .prepare(
653                "SELECT operation_id, state
654                 FROM outbound_store_device_exclusion
655                 ORDER BY operation_id",
656            )
657            .map_err(DbError::from)?;
658        let operations = statement
659            .query_map([], |row| {
660                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
661            })
662            .map_err(DbError::from)?
663            .map(|row| {
664                let (raw_id, raw) = row.map_err(DbError::from)?;
665                let operation_id = raw_id.parse::<ObjectHash>().map_err(|error| {
666                    DbError::context("Store-device exclusion operation id", error)
667                })?;
668                parse_store_device_exclusion_operation(operation_id, &raw)
669            })
670            .collect();
671        operations
672    }
673}
674
675impl StoreDatabase {
676    pub async fn begin_outbound_store_device_exclusion(
677        &self,
678        operation: DurableStoreDeviceExclusionOperation,
679    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
680        operation
681            .validate()
682            .map_err(store_device_exclusion_journal_error)?;
683        if !matches!(
684            operation,
685            DurableStoreDeviceExclusionOperation::CandidatePrepared { .. }
686        ) {
687            return Err(DbError::Message(
688                "a new Store-device exclusion journal must own its exact activation candidate"
689                    .to_string(),
690            ));
691        }
692        let remotes = operation
693            .remote_objects()
694            .map_err(store_device_exclusion_journal_error)?;
695        Box::pin(self.call_store(move |session| {
696            session.begin_outbound_store_device_exclusion(operation, remotes)
697        }))
698        .await
699    }
700
701    pub async fn active_outbound_store_device_exclusion(
702        &self,
703    ) -> Result<Option<DurableStoreDeviceExclusionOperation>, DbError> {
704        Box::pin(self.call_store(|session| session.active_outbound_store_device_exclusion())).await
705    }
706
707    pub async fn replace_outbound_store_device_exclusion_candidate(
708        &self,
709        expected: DurableStoreDeviceExclusionOperation,
710        replacement: coven_protocol::prepared_commit::PreparedStoreOperationCommit,
711    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
712        let DurableStoreDeviceExclusionOperation::CandidatePrepared { object, candidate } =
713            expected.clone()
714        else {
715            return Err(DbError::Message(
716                "Store-device exclusion has no replaceable activation candidate".to_string(),
717            ));
718        };
719        let next = DurableStoreDeviceExclusionOperation::CandidatePrepared {
720            object,
721            candidate: replacement,
722        };
723        next.validate()
724            .map_err(store_device_exclusion_journal_error)?;
725        if !expected.allows_transition_to(&next) {
726            return Err(DbError::Message(
727                "replacement Store-device exclusion candidate changes its signed commit"
728                    .to_string(),
729            ));
730        }
731        Box::pin(self.call_store(move |session| {
732            session.replace_outbound_store_device_exclusion_candidate(expected, next, candidate)
733        }))
734        .await
735    }
736
737    pub fn complete_outbound_store_device_exclusion_activation<'a>(
738        &'a self,
739        expected: DurableStoreDeviceExclusionOperation,
740    ) -> Pin<
741        Box<dyn Future<Output = Result<DurableStoreDeviceExclusionOperation, DbError>> + Send + 'a>,
742    > {
743        Box::pin(async move {
744            let next = match &expected {
745                DurableStoreDeviceExclusionOperation::CandidatePrepared { object, candidate } => {
746                    DurableStoreDeviceExclusionOperation::Completed(
747                        StoreDeviceExclusionCompletion::Activated {
748                            object: object.clone(),
749                            candidate: candidate.clone(),
750                        },
751                    )
752                }
753                _ => {
754                    return Err(DbError::Message(
755                        "Store-device exclusion has no activated candidate".to_string(),
756                    ));
757                }
758            };
759            let expected = Box::new(expected);
760            let next = Box::new(next);
761            Box::pin(self.call_store(move |session| {
762                session.complete_outbound_store_device_exclusion_activation(expected, next)
763            }))
764            .await
765        })
766    }
767
768    pub async fn complete_outbound_store_device_exclusion_slot_loss(
769        &self,
770        expected: DurableStoreDeviceExclusionOperation,
771        winner: DurableStoreDeviceExclusionObject,
772    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
773        let next = DurableStoreDeviceExclusionOperation::Completed(
774            StoreDeviceExclusionCompletion::OutcomeSlotOccupied {
775                intended: expected.object().clone(),
776                winner,
777            },
778        );
779        next.validate()
780            .map_err(store_device_exclusion_journal_error)?;
781        let remotes = expected
782            .remote_objects()
783            .map_err(store_device_exclusion_journal_error)?;
784        Box::pin(self.call_store(move |session| {
785            session.complete_outbound_store_device_exclusion_slot_loss(expected, next, remotes)
786        }))
787        .await
788    }
789
790    pub async fn begin_outbound_store_device_exclusion_nonactivation(
791        &self,
792        expected: DurableStoreDeviceExclusionOperation,
793        nonactivation: coven_protocol::remote_object::VerifiedCandidateNonactivation,
794    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
795        let candidate = expected.candidate().cloned().ok_or_else(|| {
796            DbError::Message("Store-device exclusion has no losing candidate".to_string())
797        })?;
798        if nonactivation.candidate_reference().map_err(DbError::from)? != candidate.reference {
799            return Err(DbError::Message(
800                "verified nonactivation names another Store-device exclusion candidate".to_string(),
801            ));
802        }
803        let nonactivation = nonactivation.into_durable();
804        let (next, nonactivation) = expected
805            .begin_nonactivation(nonactivation)
806            .map_err(store_device_exclusion_journal_error)?;
807        if nonactivation.candidate().canonical_signed_bytes != candidate.commit.to_bytes() {
808            return Err(DbError::Message(
809                "verified nonactivation bytes differ from the Store-device exclusion candidate"
810                    .to_string(),
811            ));
812        }
813        Box::pin(self.call_store(move |session| {
814            session.begin_outbound_store_device_exclusion_nonactivation(
815                expected,
816                next,
817                candidate,
818                nonactivation,
819            )
820        }))
821        .await
822    }
823
824    pub async fn begin_outbound_store_device_exclusion_replacement(
825        &self,
826        expected: DurableStoreDeviceExclusionOperation,
827        replacement: coven_protocol::prepared_commit::PreparedStoreOperationCommit,
828        nonactivation: coven_protocol::remote_object::VerifiedCandidateNonactivation,
829    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
830        let expected_candidate = expected.candidate().cloned().ok_or_else(|| {
831            DbError::Message("Store-device exclusion has no losing candidate".to_string())
832        })?;
833        if nonactivation.candidate_reference().map_err(DbError::from)?
834            != expected_candidate.reference
835        {
836            return Err(DbError::Message(
837                "verified nonactivation names another Store-device exclusion candidate".to_string(),
838            ));
839        }
840        let nonactivation = nonactivation.into_durable();
841        let (next, nonactivation) = expected
842            .begin_replacement(replacement, nonactivation)
843            .map_err(store_device_exclusion_journal_error)?;
844        if nonactivation.candidate().canonical_signed_bytes != expected_candidate.commit.to_bytes()
845        {
846            return Err(DbError::Message(
847                "verified nonactivation bytes differ from the Store-device exclusion candidate"
848                    .to_string(),
849            ));
850        }
851        let DurableStoreDeviceExclusionOperation::ReplacingCandidate {
852            candidate, losing, ..
853        } = &next
854        else {
855            unreachable!("begin_replacement returns replacement state")
856        };
857        let replacement_candidate = candidate.clone();
858        let losing_candidate = losing.candidate.clone();
859        let authority_id = remote_object_id(expected.object().object());
860        let replacement_remotes = DurableStoreDeviceExclusionOperation::CandidatePrepared {
861            object: expected.object().clone(),
862            candidate: replacement_candidate.clone(),
863        }
864        .remote_objects()
865        .map_err(store_device_exclusion_journal_error)?;
866        Box::pin(self.call_store(move |session| {
867            session.begin_outbound_store_device_exclusion_replacement(
868                expected,
869                next,
870                replacement_candidate,
871                losing_candidate,
872                authority_id,
873                replacement_remotes,
874                nonactivation,
875            )
876        }))
877        .await
878    }
879
880    pub async fn nonactivating_store_device_exclusion_cleanup_targets(
881        &self,
882        expected: DurableStoreDeviceExclusionOperation,
883    ) -> Result<Vec<CandidateCleanupObject>, DbError> {
884        Box::pin(self.call_store(move |session| {
885            session.nonactivating_store_device_exclusion_cleanup_targets(&expected)
886        }))
887        .await
888    }
889
890    pub async fn complete_store_device_exclusion_replacement_cleanup(
891        &self,
892        expected: DurableStoreDeviceExclusionOperation,
893    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
894        let DurableStoreDeviceExclusionOperation::ReplacingCandidate {
895            object,
896            candidate,
897            losing: _,
898        } = expected.clone()
899        else {
900            return Err(DbError::Message(
901                "Store-device exclusion has no replacement cleanup".to_string(),
902            ));
903        };
904        let next = DurableStoreDeviceExclusionOperation::CandidatePrepared {
905            object: object.clone(),
906            candidate,
907        };
908        next.validate()
909            .map_err(store_device_exclusion_journal_error)?;
910        Box::pin(self.call_store(move |session| {
911            session.complete_store_device_exclusion_replacement_cleanup(expected, next)
912        }))
913        .await
914    }
915
916    pub async fn complete_nonactivating_store_device_exclusion(
917        &self,
918        expected: DurableStoreDeviceExclusionOperation,
919    ) -> Result<DurableStoreDeviceExclusionOperation, DbError> {
920        let DurableStoreDeviceExclusionOperation::CandidateNonactivating {
921            object,
922            candidate,
923            proof,
924        } = expected.clone()
925        else {
926            return Err(DbError::Message(
927                "Store-device exclusion is not nonactivating".to_string(),
928            ));
929        };
930        let next = DurableStoreDeviceExclusionOperation::Completed(
931            StoreDeviceExclusionCompletion::CandidateNonactivated {
932                object: object.clone(),
933                candidate: candidate.clone(),
934                proof: proof.clone(),
935            },
936        );
937        next.validate()
938            .map_err(store_device_exclusion_journal_error)?;
939        Box::pin(self.call_store(move |session| {
940            session.complete_nonactivating_store_device_exclusion(expected, next)
941        }))
942        .await
943    }
944
945    pub async fn mark_store_device_exclusion_authority_uploaded(
946        &self,
947        operation: DurableStoreDeviceExclusionOperation,
948    ) -> Result<(), DbError> {
949        let expected = operation
950            .authority_remote_object()
951            .map_err(store_device_exclusion_journal_error)?;
952        let candidate = operation
953            .candidate()
954            .ok_or_else(|| {
955                DbError::Message(
956                    "Store-device exclusion authority has no current candidate".to_string(),
957                )
958            })?
959            .reference
960            .clone();
961        self.call_store(move |session| {
962            session.mark_store_device_exclusion_authority_uploaded(expected, candidate)
963        })
964        .await
965    }
966
967    #[cfg(any(test, feature = "test-utils"))]
968    pub async fn outbound_store_device_exclusion_operations(
969        &self,
970    ) -> Result<Vec<DurableStoreDeviceExclusionOperation>, DbError> {
971        Box::pin(self.call_store(|session| session.outbound_store_device_exclusion_operations()))
972            .await
973    }
974}