Skip to main content

coven_database/store/store_session/
reclaim.rs

1use rusqlite::OptionalExtension;
2
3use super::*;
4use crate::{
5    insert_store_reclaim_operation_on, load_remote_object_on, load_store_reclaim_operation_on,
6    parse_store_reclaim_operation, persist_exact_remote_object_on,
7    record_reclaimed_store_package_on, store_reclaim_journal_error,
8    update_store_reclaim_operation_on, ActiveStorePublication, ActiveStorePublicationOwner,
9};
10use coven_protocol::remote_object::{remote_object_id, RetainedReplayOwner};
11use coven_protocol::store_commit::{ObjectHash, StoreBatchCommitRef, StorePackageRef};
12
13pub mod journal;
14
15impl VerifiedStoreTransaction<'_, '_, '_, '_> {
16    /// Decide, reconstruct and install a replay baseline in this transaction.
17    /// The returned boolean says whether unpublished writes need a new publication base.
18    pub(super) fn advance_snapshot_replay_baseline(
19        &mut self,
20        root: &coven_protocol::store_commit::StoreRootRef,
21        snapshot_authority: &coven_protocol::store_commit::RetainedReplaySnapshotAuthority,
22        schema_version: u32,
23        routing_hash: ObjectHash,
24        routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
25    ) -> Result<Option<(crate::AdvancedReplayBaseline, bool)>, DbError> {
26        let cut = &snapshot_authority.metadata.coverage;
27        if !crate::store::store_session::replay_baseline_advances_on(
28            crate::store::store_session::StoreRecords::new(
29                self.store.transaction,
30                self.store.store_dir,
31            ),
32            &snapshot_authority.snapshot,
33            cut,
34        )? {
35            return Ok(None);
36        }
37        let current_cut = coven_protocol::store_commit::CommitFrontier::from_refs(
38            crate::store::materialized_commit_index::materialized_frontier_on(
39                self.store.transaction,
40                None,
41            )?,
42        )
43        .map_err(DbError::from)?;
44        if !current_cut.covers(cut) {
45            return Err(DbError::Message(
46                "accepted Store snapshot is ahead of the locally reconstructable frontier"
47                    .to_string(),
48            ));
49        }
50        let installed = super::retained_replay::load_replay_baseline_metadata_on(
51            StoreRecords::new(self.store.transaction, self.store.store_dir),
52        )?
53        .ok_or_else(|| {
54            DbError::Message("snapshot advance has no installed baseline".to_string())
55        })?;
56        let changes_publication_base = !matches!(&installed.authority,
57            crate::RetainedReplayAuthority::InstalledSnapshot(previous) if previous.snapshot == snapshot_authority.snapshot);
58        let (image, folded) = self.capture_replay_baseline_at_cut(
59            root,
60            cut,
61            &current_cut,
62            snapshot_authority.snapshot.snapshot_hash,
63            routing_encryption,
64        )?;
65        let advanced = self.store.install_advanced_replay_baseline(
66            self.authority,
67            root,
68            schema_version,
69            routing_hash,
70            snapshot_authority.clone(),
71            image,
72            &folded,
73            self.blob_decls,
74            self.synced_tables,
75        )?;
76        self.authority.forget_superseded_replay_baseline();
77        Ok(Some((advanced, changes_publication_base)))
78    }
79}
80
81impl StoreSession<'_> {
82    fn begin_store_reclaim_operation(
83        &mut self,
84        operation: DurableStoreReclaimOperation,
85        remotes: Vec<coven_protocol::remote_object::ClosedRemoteObject>,
86    ) -> Result<DurableStoreReclaimOperation, DbError> {
87        let conn = self.conn;
88        let tx = conn.unchecked_transaction().map_err(DbError::from)?;
89        let operation_id = operation.operation_id();
90        let candidate = operation.candidate().ok_or_else(|| {
91            DbError::Message("Store reclaim operation has no publication candidate".to_string())
92        })?;
93        let active_publication = ActiveStorePublication::for_commit(
94            ActiveStorePublicationOwner::Reclaim(operation_id),
95            candidate,
96        )?;
97        if let Some(existing) = load_store_reclaim_operation_on(&tx, operation_id)? {
98            if existing != operation {
99                return Err(DbError::Message(format!(
100                    "Store reclaim operation {operation_id} already has different durable state"
101                )));
102            }
103            if !super::active_store_publication::load_active_store_publication_on(&tx)?
104                .is_some_and(|existing| existing.same_commit_reservation(&active_publication))
105            {
106                return Err(DbError::Message(format!(
107                    "Store reclaim operation {operation_id} differs from its active publication"
108                )));
109            }
110            return Ok(existing);
111        }
112        match super::active_store_publication::claim_active_store_publication_on(
113            &tx,
114            &active_publication,
115        )? {
116            super::active_store_publication::ActiveStorePublicationClaim::Acquired => {}
117            super::active_store_publication::ActiveStorePublicationClaim::AlreadyOwned => {
118                return Err(DbError::Message(
119                    "Store reclaim owns publication before its journal".to_string(),
120                ));
121            }
122            super::active_store_publication::ActiveStorePublicationClaim::Occupied(owner) => {
123                return Err(DbError::Message(format!(
124                    "another local Store operation owns publication: {owner:?}"
125                )));
126            }
127        }
128        for remote in &remotes {
129            persist_exact_remote_object_on(
130                &tx,
131                self.store_dir,
132                remote,
133                "Store reclaim candidate object",
134            )?;
135        }
136        insert_store_reclaim_operation_on(&tx, &operation)?;
137        tx.commit().map_err(DbError::from)?;
138        Ok(operation)
139    }
140
141    /// Adopt an accepted snapshot as this device's replay baseline.
142    fn advance_snapshot_replay_baseline(
143        &mut self,
144        root: &coven_protocol::store_commit::StoreRootRef,
145        snapshot_authority: coven_protocol::store_commit::RetainedReplaySnapshotAuthority,
146        routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
147        membership: coven_protocol::membership::LocalStoreMembership,
148    ) -> Result<Option<crate::AdvancedReplayBaseline>, DbError> {
149        let schema_version = self.schema_version;
150        let routing_hash = self.sync_routing_hash;
151        self.verified_store_transaction(|transaction| {
152            let current = super::observed_store_publication::load_store_current_publication_on(
153                transaction.store.transaction,
154            )?;
155            let accepted = current
156                .record()
157                .latest_snapshot()
158                .filter(|accepted| accepted.snapshot == snapshot_authority.snapshot)
159                .ok_or_else(|| {
160                    DbError::Message(
161                        "replay baseline must adopt the current accepted snapshot".to_string(),
162                    )
163                })?;
164            let Some((advanced, changes_publication_base)) = transaction
165                .advance_snapshot_replay_baseline(
166                    root,
167                    &snapshot_authority,
168                    schema_version,
169                    routing_hash,
170                    routing_encryption,
171                )?
172            else {
173                return Ok(StoreTransactionOutcome::Rollback(None));
174            };
175            let routing_key = if transaction.gates.has_scoped_graph() {
176                let encryption = routing_encryption.ok_or_else(|| {
177                    DbError::Message(
178                        "scoped write rebase requires Store routing encryption".to_string(),
179                    )
180                })?;
181                Some(coven_protocol::circle::derive_row_routing_key(
182                    encryption,
183                    root.store_root_hash,
184                )?)
185            } else {
186                None
187            };
188            if changes_publication_base {
189                transaction.rebase_unpublished_store_writes(
190                    accepted,
191                    routing_key.as_ref(),
192                    membership,
193                )?;
194            }
195            super::observed_store_publication::retire_store_publication_prefix_before_snapshot_on(
196                transaction.store,
197                transaction.authority,
198                &accepted.publication,
199            )?;
200            Ok(StoreTransactionOutcome::Commit(Some(advanced)))
201        })
202    }
203
204    fn store_package_is_retained_for_replay(
205        &mut self,
206        root: &coven_protocol::store_commit::StoreRootRef,
207        target: &StorePackageRef,
208        activation: &StoreBatchCommitRef,
209    ) -> Result<bool, DbError> {
210        let object_id = remote_object_id(&target.object);
211        let exists: bool = self
212            .conn
213            .query_row(
214                "SELECT EXISTS(SELECT 1 FROM remote_objects WHERE object_id = ?1)",
215                [object_id.to_string()],
216                |row| row.get(0),
217            )
218            .map_err(DbError::from)?;
219        if !exists {
220            return Ok(false);
221        }
222        let remote = load_remote_object_on(self.conn, object_id)?;
223        let retained = remote
224            .store_package_is_retained_for_replay(target, activation)
225            .map_err(|error| {
226                DbError::context(
227                    format!("validate Store package {object_id} replay ownership"),
228                    error,
229                )
230            })?;
231        if !retained {
232            return Ok(false);
233        }
234        for owner in remote.retained_replay_owners() {
235            let RetainedReplayOwner::Commit { commit, input_hash } = owner;
236            let retained = self
237                .verified_store_authority
238                .validate_retained_materialization_by_ref_on(
239                    crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
240                    commit,
241                )?;
242            if retained.root() != root || retained.input_hash() != *input_hash {
243                return Err(DbError::Message(
244                    "Store package replay owner differs from retained materialization".to_string(),
245                ));
246            }
247        }
248        Ok(true)
249    }
250
251    fn circle_package_is_retained_for_replay(
252        &mut self,
253        root: &coven_protocol::store_commit::StoreRootRef,
254        target: &coven_protocol::store_commit::CirclePackageRef,
255        activation: &StoreBatchCommitRef,
256    ) -> Result<bool, DbError> {
257        let object_id = remote_object_id(&target.package.object);
258        let exists: bool = self
259            .conn
260            .query_row(
261                "SELECT EXISTS(SELECT 1 FROM remote_objects WHERE object_id = ?1)",
262                [object_id.to_string()],
263                |row| row.get(0),
264            )
265            .map_err(DbError::from)?;
266        if !exists {
267            return Ok(false);
268        }
269        let remote = load_remote_object_on(self.conn, object_id)?;
270        let retained = remote
271            .circle_package_is_retained_for_replay(target, activation)
272            .map_err(|error| {
273                DbError::context(
274                    format!("validate Circle package {object_id} replay ownership"),
275                    error,
276                )
277            })?;
278        if !retained {
279            return Ok(false);
280        }
281        for owner in remote.retained_replay_owners() {
282            let RetainedReplayOwner::Commit { commit, input_hash } = owner;
283            let retained = self
284                .verified_store_authority
285                .validate_retained_materialization_by_ref_on(
286                    crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
287                    commit,
288                )?;
289            if retained.root() != root || retained.input_hash() != *input_hash {
290                return Err(DbError::Message(
291                    "Circle package replay owner differs from retained materialization".to_string(),
292                ));
293            }
294        }
295        Ok(true)
296    }
297
298    fn circle_image_is_retained_for_replay(
299        &self,
300        circle_id: coven_protocol::circle::CircleId,
301        image: &coven_protocol::store_commit::SnapshotImageRef,
302    ) -> Result<bool, DbError> {
303        let row: Option<Vec<u8>> = self
304            .conn
305            .query_row(
306                "SELECT bootstrap_ref FROM circle_bootstrap_coverage WHERE circle_id = ?1",
307                [circle_id.to_string()],
308                |row| row.get(0),
309            )
310            .optional()
311            .map_err(DbError::from)?;
312        let Some(bootstrap_ref) = row else {
313            return Ok(false);
314        };
315        let bootstrap: coven_protocol::circle::CircleBootstrapRef =
316            serde_json::from_slice(&bootstrap_ref).map_err(|error| {
317                DbError::context("parse retained Circle bootstrap reference", error)
318            })?;
319        Ok(bootstrap.image == *image)
320    }
321
322    fn stored_blob_reclaim_candidates(
323        &self,
324    ) -> Result<
325        Vec<(
326            coven_protocol::blob::locator::StoredBlobRef,
327            Vec<StoreBatchCommitRef>,
328        )>,
329        DbError,
330    > {
331        let conn = self.conn;
332        let mut statement = conn
333            .prepare("SELECT remote_object_id FROM blob_locators ORDER BY remote_object_id")
334            .map_err(DbError::from)?;
335        let object_ids = statement
336            .query_map([], |row| row.get::<_, String>(0))
337            .map_err(DbError::from)?
338            .collect::<Result<Vec<_>, _>>()
339            .map_err(DbError::from)?;
340        drop(statement);
341        let mut candidates = Vec::new();
342        for object_id in object_ids {
343            let parsed = object_id.parse().map_err(|error| {
344                DbError::context(format!("stored blob object id {object_id:?}"), error)
345            })?;
346            let remote = load_remote_object_on(conn, parsed)?;
347            if !remote.is_activated_stored_blob() {
348                continue;
349            }
350            let Some(locator_bytes) = remote.payloads().carried_locator_bytes() else {
351                return Err(DbError::Message(format!(
352                    "stored blob {object_id} carries no locator"
353                )));
354            };
355            let locator = coven_protocol::blob::locator::BlobLocator::parse(locator_bytes)
356                .map_err(|error| {
357                    DbError::context(format!("stored blob {object_id} locator"), error)
358                })?;
359            let stored =
360                coven_protocol::blob::locator::StoredBlobRef::new(locator, remote.object().clone())
361                    .map_err(|error| {
362                        DbError::context(format!("stored blob {object_id} reference"), error)
363                    })?;
364            candidates.push((stored, remote.stored_blob_commit_owners()));
365        }
366        Ok(candidates)
367    }
368
369    fn stored_blob_is_row_orphaned(
370        &self,
371        stored: &coven_protocol::blob::locator::StoredBlobRef,
372    ) -> Result<bool, DbError> {
373        match crate::Database::stored_blob_reference_state_on(
374            self.conn,
375            self.gates,
376            self.synced_tables,
377            stored,
378        )? {
379            crate::StoredBlobReferenceState::NotLiveRemote => Ok(true),
380            crate::StoredBlobReferenceState::LiveRemote => Ok(false),
381            crate::StoredBlobReferenceState::Unresolved => Err(DbError::Message(format!(
382                "stored blob {} has a live reference whose locality is unresolved",
383                remote_object_id(stored.object())
384            ))),
385        }
386    }
387
388    fn audience_blob_is_retained_for_replay(
389        &self,
390        stored: &coven_protocol::blob::locator::StoredBlobRef,
391    ) -> Result<bool, DbError> {
392        let conn = self.conn;
393        let object_id = remote_object_id(stored.object());
394        let exists: bool = conn
395            .query_row(
396                "SELECT EXISTS(SELECT 1 FROM remote_objects WHERE object_id = ?1)",
397                [object_id.to_string()],
398                |row| row.get(0),
399            )
400            .map_err(DbError::from)?;
401        if exists {
402            let remote = load_remote_object_on(conn, object_id)?;
403            if remote.snapshot_owners().next().is_some()
404                || remote.retained_replay_owners().next().is_some()
405            {
406                return Ok(true);
407            }
408        }
409        let mut statement = conn
410            .prepare("SELECT bootstrap_ref FROM circle_bootstrap_coverage")
411            .map_err(DbError::from)?;
412        let coverages = statement
413            .query_map([], |row| row.get::<_, Vec<u8>>(0))
414            .map_err(DbError::from)?
415            .collect::<Result<Vec<_>, _>>()
416            .map_err(DbError::from)?;
417        drop(statement);
418        for bytes in coverages {
419            let bootstrap: coven_protocol::circle::CircleBootstrapRef =
420                serde_json::from_slice(&bytes).map_err(|error| {
421                    DbError::context("parse retained Circle bootstrap reference", error)
422                })?;
423            if bootstrap
424                .blobs
425                .iter()
426                .any(|blob| blob.stored() == Some(stored))
427            {
428                return Ok(true);
429            }
430        }
431        Ok(false)
432    }
433
434    /// Whether a pending audience-blob reclaim still names this package as the
435    /// one that published its blob.
436    ///
437    /// Executing a blob reclaim re-reads that package from the provider to
438    /// confirm the binding, so the package has to outlive the blob operation:
439    /// a package reclaim that deleted it first would strand the blob operation
440    /// at a read that can never succeed. Completed operations hold nothing; a
441    /// stuck one holds its package like any other unfinished operation,
442    /// because stuck means waiting on a person, not gone.
443    fn package_is_retained_by_pending_blob_reclaim(
444        &self,
445        package: &coven_protocol::objects::ExactObjectRef,
446    ) -> Result<bool, DbError> {
447        let package_id = remote_object_id(package);
448        Ok(self.store_reclaim_operations()?.iter().any(|operation| {
449            if matches!(operation, DurableStoreReclaimOperation::Completed { .. }) {
450                return false;
451            }
452            match operation.authorization().target() {
453                coven_protocol::reclaim::ReclaimTarget::AudienceBlob(
454                    coven_protocol::reclaim::AudienceBlobReclaimTarget::Circle { source, .. },
455                ) => remote_object_id(&source.package.package.object) == package_id,
456                _ => false,
457            }
458        }))
459    }
460
461    /// Every journalled reclaim operation paired with the error that made it
462    /// stuck, if any. The three questions the journal answers — what exists,
463    /// what a cycle may still run, and what is waiting on a person — all come
464    /// off this one read.
465    fn store_reclaim_journal(
466        &self,
467    ) -> Result<Vec<(DurableStoreReclaimOperation, Option<String>)>, DbError> {
468        let mut statement = self
469            .conn
470            .prepare(
471                "SELECT authorization_hash, state, stuck_error FROM store_reclaim_operations
472                 ORDER BY authorization_hash",
473            )
474            .map_err(DbError::from)?;
475        let rows = statement
476            .query_map([], |row| {
477                Ok((
478                    row.get::<_, String>(0)?,
479                    row.get::<_, String>(1)?,
480                    row.get::<_, Option<String>>(2)?,
481                ))
482            })
483            .map_err(DbError::from)?
484            .collect::<Result<Vec<_>, _>>()
485            .map_err(DbError::from)?;
486        rows.into_iter()
487            .map(|(raw_id, raw, stuck_error)| {
488                let id = raw_id
489                    .parse()
490                    .map_err(|error| DbError::context("Store reclaim operation id", error))?;
491                Ok((parse_store_reclaim_operation(id, &raw)?, stuck_error))
492            })
493            .collect()
494    }
495
496    fn store_reclaim_operations(&self) -> Result<Vec<DurableStoreReclaimOperation>, DbError> {
497        Ok(self
498            .store_reclaim_journal()?
499            .into_iter()
500            .map(|(operation, _)| operation)
501            .collect())
502    }
503
504    fn runnable_store_reclaim_operations(
505        &self,
506    ) -> Result<Vec<DurableStoreReclaimOperation>, DbError> {
507        Ok(self
508            .store_reclaim_journal()?
509            .into_iter()
510            .filter_map(|(operation, stuck_error)| stuck_error.is_none().then_some(operation))
511            .collect())
512    }
513
514    fn stuck_reclaim_operations(&self) -> Result<Vec<StuckReclaimOperation>, DbError> {
515        Ok(self
516            .store_reclaim_journal()?
517            .into_iter()
518            .filter_map(|(operation, stuck_error)| {
519                stuck_error.map(|error| StuckReclaimOperation {
520                    operation_id: operation.operation_id(),
521                    target: operation.authorization().target().clone(),
522                    error,
523                })
524            })
525            .collect())
526    }
527
528    fn mark_store_reclaim_operation_stuck(
529        &mut self,
530        operation_id: ObjectHash,
531        error: String,
532    ) -> Result<(), DbError> {
533        crate::mark_store_reclaim_operation_stuck_on(self.conn, operation_id, &error)
534    }
535
536    fn retry_stuck_reclaim_operation(&mut self, operation_id: ObjectHash) -> Result<(), DbError> {
537        crate::clear_store_reclaim_operation_stuck_on(self.conn, operation_id)
538    }
539
540    fn begin_store_reclaim_receipt(
541        &mut self,
542        expected: DurableStoreReclaimOperation,
543        next: DurableStoreReclaimOperation,
544        remotes: Vec<coven_protocol::remote_object::ClosedRemoteObject>,
545    ) -> Result<DurableStoreReclaimOperation, DbError> {
546        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
547        let current = load_store_reclaim_operation_on(&tx, expected.operation_id())?
548            .ok_or_else(|| DbError::Message("Store reclaim operation disappeared".to_string()))?;
549        if current != expected {
550            return Err(DbError::Message(
551                "Store reclaim operation changed before receipt preparation".to_string(),
552            ));
553        }
554        let candidate = next.candidate().ok_or_else(|| {
555            DbError::Message("Store reclaim receipt has no publication candidate".to_string())
556        })?;
557        let active_publication = ActiveStorePublication::for_commit(
558            ActiveStorePublicationOwner::Reclaim(next.operation_id()),
559            candidate,
560        )?;
561        match super::active_store_publication::claim_active_store_publication_on(
562            &tx,
563            &active_publication,
564        )? {
565            super::active_store_publication::ActiveStorePublicationClaim::Acquired => {}
566            super::active_store_publication::ActiveStorePublicationClaim::AlreadyOwned => {
567                return Err(DbError::Message(
568                    "Store reclaim receipt owns publication before its journal".to_string(),
569                ));
570            }
571            super::active_store_publication::ActiveStorePublicationClaim::Occupied(owner) => {
572                return Err(DbError::Message(format!(
573                    "another local Store operation owns publication: {owner:?}"
574                )));
575            }
576        }
577        for remote in &remotes {
578            persist_exact_remote_object_on(
579                &tx,
580                self.store_dir,
581                remote,
582                "Store reclaim receipt candidate",
583            )?;
584        }
585        update_store_reclaim_operation_on(&tx, &expected, &next)?;
586        tx.commit().map_err(DbError::from)?;
587        Ok(next)
588    }
589
590    fn mark_store_reclaim_target_absent(
591        &mut self,
592        expected: DurableStoreReclaimOperation,
593        next: DurableStoreReclaimOperation,
594        reclaimed: ReclaimedStorePackage,
595    ) -> Result<DurableStoreReclaimOperation, DbError> {
596        let root = self.required_root_authority()?;
597        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
598        let current = load_store_reclaim_operation_on(&tx, expected.operation_id())?
599            .ok_or_else(|| DbError::Message("Store reclaim operation disappeared".to_string()))?;
600        if current != expected {
601            return Err(DbError::Message(
602                "Store reclaim operation changed before absence recording".to_string(),
603            ));
604        }
605        record_reclaimed_store_package_on(&tx, Some(root.store_root_hash), &reclaimed)?;
606        update_store_reclaim_operation_on(&tx, &expected, &next)?;
607        tx.commit().map_err(DbError::from)?;
608        Ok(next)
609    }
610
611    #[cfg(any(test, feature = "test-utils"))]
612    fn stored_blob_has_snapshot_owner_for_test(
613        &self,
614        stored: &coven_protocol::blob::locator::StoredBlobRef,
615    ) -> Result<bool, DbError> {
616        let remote = load_remote_object_on(self.conn, remote_object_id(stored.object()))?;
617        let pinned = remote.snapshot_owners().next().is_some();
618        Ok(pinned)
619    }
620}
621
622impl StoreDatabase {
623    /// Whether adopting the snapshot would move the retained replay baseline or fold
624    /// a settled write-journal prefix into it.
625    pub async fn replay_baseline_would_advance(
626        &self,
627        snapshot: coven_protocol::store_commit::StoreSnapshotRef,
628        cut: coven_protocol::store_commit::CommitFrontier,
629    ) -> Result<bool, DbError> {
630        self.call_store(move |session| {
631            crate::store::store_session::replay_baseline_advances_on(
632                crate::store::store_session::StoreRecords::new(session.conn, session.store_dir),
633                &snapshot,
634                &cut,
635            )
636        })
637        .await
638    }
639
640    pub async fn begin_store_reclaim_operation(
641        &self,
642        operation: DurableStoreReclaimOperation,
643    ) -> Result<DurableStoreReclaimOperation, DbError> {
644        operation.validate().map_err(store_reclaim_journal_error)?;
645        let DurableStoreReclaimOperation::AuthorizationCandidate { .. } = &operation else {
646            return Err(DbError::Message(
647                "a new Store reclaim operation must own an activation candidate".to_string(),
648            ));
649        };
650        let remotes = match &operation {
651            DurableStoreReclaimOperation::AuthorizationCandidate { object, candidate } => object
652                .remote_objects(candidate)
653                .map_err(store_reclaim_journal_error)?,
654            _ => unreachable!("matched reclaim candidate"),
655        };
656        self.call_store(move |session| session.begin_store_reclaim_operation(operation, remotes))
657            .await
658    }
659
660    /// Adopt a snapshot admitted for replay retirement and retire the retained
661    /// history it supersedes.
662    ///
663    /// `Ok(None)` means the snapshot does not advance this device's cut, which
664    /// is the ordinary result once a device has caught up to the newest
665    /// acknowledged snapshot.
666    pub async fn advance_snapshot_replay_baseline(
667        &self,
668        root: coven_protocol::store_commit::StoreRootRef,
669        authority: crate::VerifiedStoreSnapshotAuthority,
670        routing_encryption: Option<coven_keys::encryption::EncryptionService>,
671        membership: coven_protocol::membership::LocalStoreMembership,
672    ) -> Result<Option<crate::AdvancedReplayBaseline>, DbError> {
673        let authority = authority.into_authority();
674        self.call_store(move |session| {
675            session.advance_snapshot_replay_baseline(
676                &root,
677                authority,
678                routing_encryption.as_ref(),
679                membership,
680            )
681        })
682        .await
683    }
684
685    pub async fn store_package_is_retained_for_replay(
686        &self,
687        root: coven_protocol::store_commit::StoreRootRef,
688        target: StorePackageRef,
689        activation: StoreBatchCommitRef,
690    ) -> Result<bool, DbError> {
691        self.call_store(move |session| {
692            session.store_package_is_retained_for_replay(&root, &target, &activation)
693        })
694        .await
695    }
696
697    pub async fn circle_package_is_retained_for_replay(
698        &self,
699        root: coven_protocol::store_commit::StoreRootRef,
700        target: coven_protocol::store_commit::CirclePackageRef,
701        activation: StoreBatchCommitRef,
702    ) -> Result<bool, DbError> {
703        self.call_store(move |session| {
704            session.circle_package_is_retained_for_replay(&root, &target, &activation)
705        })
706        .await
707    }
708
709    /// Whether a Circle bootstrap image is still the local device's live seed for
710    /// its Circle: the `circle_bootstrap_coverage` row names the same image. Such a
711    /// bootstrap is a retained replay input and is never eligible for reclamation —
712    /// the per-Circle analogue of the package retained-replay guard, re-checked
713    /// before deletion so a seed installed since authoring fails the delete loud.
714    pub async fn circle_bootstrap_image_is_retained_for_replay(
715        &self,
716        coverage: coven_protocol::circle::CircleBootstrapCoverageRef,
717    ) -> Result<bool, DbError> {
718        self.circle_image_is_retained_for_replay(
719            coverage.circle_id,
720            coverage.bootstrap.image.clone(),
721        )
722        .await
723    }
724
725    /// Whether the local device's live Circle projection was seeded from this exact
726    /// image. One `circle_bootstrap_coverage` row per Circle names whichever image
727    /// the projection came from — a recipient bootstrap installed on pull or a
728    /// standalone snapshot installed on restore — so both kinds of image answer the
729    /// same question against the same row.
730    pub async fn circle_image_is_retained_for_replay(
731        &self,
732        circle_id: coven_protocol::circle::CircleId,
733        image: coven_protocol::store_commit::SnapshotImageRef,
734    ) -> Result<bool, DbError> {
735        self.call_store(move |session| {
736            session.circle_image_is_retained_for_replay(circle_id, &image)
737        })
738        .await
739    }
740
741    /// Every stored row blob this device has an ownership record for, paired with
742    /// the activated Store commits whose package bindings published it.
743    /// `blob_locators` is the stored-blob subset of `remote_objects`, so it is the
744    /// exact candidate set without scanning every remote object.
745    pub async fn stored_blob_reclaim_candidates(
746        &self,
747    ) -> Result<
748        Vec<(
749            coven_protocol::blob::locator::StoredBlobRef,
750            Vec<StoreBatchCommitRef>,
751        )>,
752        DbError,
753    > {
754        self.call_store(|session| session.stored_blob_reclaim_candidates())
755            .await
756    }
757
758    /// Whether no live row in this device's materialized state binds the blob as a
759    /// remote reference — the same predicate the member-signed tombstone path
760    /// applies before deleting a blob body. An unresolved reference is not an
761    /// answer: it means a row's locality cannot be decided yet, so it fails rather
762    /// than counting as an orphan.
763    pub async fn stored_blob_is_row_orphaned(
764        &self,
765        stored: coven_protocol::blob::locator::StoredBlobRef,
766    ) -> Result<bool, DbError> {
767        self.call_store(move |session| session.stored_blob_is_row_orphaned(&stored))
768            .await
769    }
770
771    /// Whether an installable image still pins this row blob.
772    ///
773    /// A snapshot or bootstrap image lists the exact blobs a device installing
774    /// from it must be able to read. Those blobs outlive the rows that published
775    /// them: a device restoring from an image reads its listed blobs before it has
776    /// any rows at all, so "no live row binds this blob" does not mean the blob is
777    /// free. A blob a retained image lists is never eligible, whatever its rows
778    /// say. Re-checked before deletion, so an image published since the
779    /// authorization was signed fails the delete loud rather than removing a blob
780    /// a restore now needs.
781    pub async fn audience_blob_is_retained_for_replay(
782        &self,
783        stored: coven_protocol::blob::locator::StoredBlobRef,
784    ) -> Result<bool, DbError> {
785        self.call_store(move |session| session.audience_blob_is_retained_for_replay(&stored))
786            .await
787    }
788
789    /// Whether a pending audience-blob reclaim still names this package as the
790    /// one that published its blob. See the session method.
791    pub async fn package_is_retained_by_pending_blob_reclaim(
792        &self,
793        package: coven_protocol::objects::ExactObjectRef,
794    ) -> Result<bool, DbError> {
795        self.call_store(move |session| {
796            session.package_is_retained_by_pending_blob_reclaim(&package)
797        })
798        .await
799    }
800
801    /// Every operation the reclaim journal holds, stuck ones included. An
802    /// existing operation for a target is what blocks re-authorizing it, and a
803    /// stuck operation blocks it exactly as a running one does.
804    pub async fn store_reclaim_operations(
805        &self,
806    ) -> Result<Vec<DurableStoreReclaimOperation>, DbError> {
807        self.call_store(|session| session.store_reclaim_operations())
808            .await
809    }
810
811    /// The operations a cycle may still run: everything the journal holds
812    /// except the ones waiting on a person.
813    pub async fn runnable_store_reclaim_operations(
814        &self,
815    ) -> Result<Vec<DurableStoreReclaimOperation>, DbError> {
816        self.call_store(|session| session.runnable_store_reclaim_operations())
817            .await
818    }
819
820    /// The operations that failed with an error retrying cannot change, with
821    /// the target and message the host shows.
822    pub async fn stuck_reclaim_operations(&self) -> Result<Vec<StuckReclaimOperation>, DbError> {
823        self.call_store(|session| session.stuck_reclaim_operations())
824            .await
825    }
826
827    /// Mark one operation stuck, so every later cycle skips it until the host
828    /// asks for it again.
829    pub async fn mark_store_reclaim_operation_stuck(
830        &self,
831        operation_id: ObjectHash,
832        error: String,
833    ) -> Result<(), DbError> {
834        self.call_store(move |session| {
835            session.mark_store_reclaim_operation_stuck(operation_id, error)
836        })
837        .await
838    }
839
840    /// Clear one operation's stuck mark so the next cycle runs it again.
841    /// Refused when the operation is not stuck.
842    pub async fn retry_stuck_reclaim_operation(
843        &self,
844        operation_id: ObjectHash,
845    ) -> Result<(), DbError> {
846        self.call_store(move |session| session.retry_stuck_reclaim_operation(operation_id))
847            .await
848    }
849
850    pub async fn begin_store_reclaim_receipt(
851        &self,
852        expected: DurableStoreReclaimOperation,
853        object: DurableStoreReclaimObject,
854        candidate: coven_protocol::prepared_commit::PreparedStoreOperationCommit,
855    ) -> Result<DurableStoreReclaimOperation, DbError> {
856        let DurableStoreReclaimOperation::AbsentVerified {
857            authorization,
858            authorization_activation,
859            ..
860        } = &expected
861        else {
862            return Err(DbError::Message(
863                "only an authorized reclaim can prepare a receipt".to_string(),
864            ));
865        };
866        let next = DurableStoreReclaimOperation::ReceiptCandidate {
867            authorization: authorization.clone(),
868            authorization_activation: authorization_activation.clone(),
869            object: Box::new(object),
870            candidate: Box::new(candidate),
871        };
872        next.validate().map_err(store_reclaim_journal_error)?;
873        let remotes = match &next {
874            DurableStoreReclaimOperation::ReceiptCandidate {
875                object, candidate, ..
876            } => object
877                .remote_objects(candidate)
878                .map_err(store_reclaim_journal_error)?,
879            _ => unreachable!("constructed receipt candidate"),
880        };
881        self.call_store(move |session| session.begin_store_reclaim_receipt(expected, next, remotes))
882            .await
883    }
884
885    pub async fn mark_store_reclaim_target_absent(
886        &self,
887        expected: DurableStoreReclaimOperation,
888        target: coven_protocol::reclaim::ReclaimTarget,
889    ) -> Result<DurableStoreReclaimOperation, DbError> {
890        let DurableStoreReclaimOperation::Authorized {
891            authorization,
892            activation,
893        } = &expected
894        else {
895            return Err(DbError::Message(
896                "only an authorized reclaim can record target absence".to_string(),
897            ));
898        };
899        if &target != authorization.target() {
900            return Err(DbError::Message(
901                "verified reclaim target differs from its signed exact reference".to_string(),
902            ));
903        }
904        let next = DurableStoreReclaimOperation::AbsentVerified {
905            authorization: authorization.clone(),
906            authorization_activation: activation.clone(),
907            target,
908        };
909        let reclaimed =
910            ReclaimedStorePackage::absent_verified(authorization.clone(), activation.clone())
911                .map_err(store_reclaim_journal_error)?;
912        next.validate().map_err(store_reclaim_journal_error)?;
913        self.call_store(move |session| {
914            session.mark_store_reclaim_target_absent(expected, next, reclaimed)
915        })
916        .await
917    }
918
919    /// Whether a published snapshot generation lists this blob in its image, read
920    /// straight off the ownership record.
921    #[cfg(any(test, feature = "test-utils"))]
922    pub async fn stored_blob_has_snapshot_owner_for_test(
923        &self,
924        stored: coven_protocol::blob::locator::StoredBlobRef,
925    ) -> Result<bool, DbError> {
926        self.call_store(move |session| session.stored_blob_has_snapshot_owner_for_test(&stored))
927            .await
928    }
929
930    #[cfg(any(test, feature = "test-utils"))]
931    pub async fn stored_blob_reclaim_candidates_for_test(
932        &self,
933    ) -> Result<
934        Vec<(
935            coven_protocol::blob::locator::StoredBlobRef,
936            Vec<StoreBatchCommitRef>,
937        )>,
938        DbError,
939    > {
940        self.stored_blob_reclaim_candidates().await
941    }
942}