Skip to main content

coven_database/store/store_session/
reclaim.rs

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