Skip to main content

coven_replication/sync/store/reclaim/
mod.rs

1//! Proof-gated deletion of exact Store packages covered by exact authority.
2//!
3//! # Which devices have to acknowledge a snapshot before reclaim deletes behind it
4//!
5//! Reclaim deletes the Store packages attached to commits at or behind a
6//! snapshot's coverage. The commit announcements stay; only the row payload
7//! goes. A device that has not yet materialized such a commit needs that
8//! package to materialize it — unless it installs the snapshot image instead,
9//! which already holds those rows. So the bar is: delete only what no current
10//! member could still need to fetch. A device proves it needs nothing behind
11//! the snapshot by having already materialized past the snapshot's coverage.
12//!
13//! The proof is an activated acknowledgement naming the snapshot exactly,
14//! stating the snapshot's device state, and whose store cut covers the
15//! snapshot's coverage. That last clause is the whole content: it says the
16//! device stands at or past the coverage, and a device never moves backwards.
17//!
18//! ## The eligible set
19//!
20//! A snapshot `S` is reclaimable when every device in
21//!
22//! ```text
23//!   { d : d is Active in the CURRENT device state
24//!         and d is Active in the device state resolved at S's coverage
25//!         and d's registering principal holds an active grant NOW }
26//! ```
27//!
28//! has supplied that proof. Three conjuncts, and each rules out a shape that
29//! can never supply a proof and never needs to. The third asks membership,
30//! not device status: removing a member ends its grants and rotates the key
31//! without touching the status of the devices it registered — those stay
32//! Active for good, so a rule reading device status alone kept demanding
33//! signatures from devices whose principals could never publish again, and
34//! every snapshot behind them stayed unreclaimable.
35//!
36//! A device excluded after S's coverage is out. It was Active at S's coverage,
37//! so the coverage-time state alone would demand its acknowledgement — but it
38//! was excluded afterwards and will never publish again. An excluded device is
39//! not a member: it cannot pull, cannot publish, and cannot re-enter except
40//! through a fresh join, which bootstraps from a snapshot image at or past S.
41//! There is no history behind S it could still fetch, so requiring its
42//! signature demands a signature that can never exist. This is the shape that
43//! blocks a store permanently — after any exclusion that postdates a snapshot's
44//! coverage, that snapshot and every earlier one become unreclaimable forever.
45//!
46//! A device that joined after S's coverage is also out, and this one the
47//! coverage-time state already handles, since it is absent there. It is worth
48//! stating anyway, because the reason is not that the device is new: it is that
49//! a join installs a snapshot image and materializes only the history past it,
50//! so the device stands at or past S's coverage before it is ever active. It is
51//! already in the position an acknowledgement would have proved.
52//!
53//! A device active at S's coverage and active now is in, with no relaxation. It
54//! may have been idle since; it may hold nothing past S's coverage at all. It
55//! is a current member that could still need what is behind S. It acknowledges,
56//! or the snapshot is not reclaimable.
57//!
58//! A removed member's devices are out, whatever their device status says. A
59//! removed member cannot pull, publish, or re-enter except by a fresh join,
60//! which bootstraps at or past S — nothing behind S is reachable to it, and
61//! its devices' Active status is a statement about the devices' own
62//! lifecycle, not the principal's standing.
63//!
64//! ## What the joined-after leg rests on
65//!
66//! That leg assumes the snapshot a join installs has coverage at or past S's.
67//! A join selects the maximal installable snapshot within its bootstrap cut, so
68//! it normally lands on S or later. It does not have to: if S is rejected as
69//! uninstallable the join falls back to an older generation, and Store snapshot
70//! images are never themselves reclaimed, so an older generation stays
71//! selectable indefinitely. A join that falls back below S's coverage then
72//! needs packages reclaim has already deleted, and cannot complete. The hazard
73//! is not introduced by this set — it is there today — but the leg is only as
74//! sound as it is.
75//!
76//! ## Deliberately not part of the rule
77//!
78//! An acknowledgement of a later snapshot whose coverage is at or past S's
79//! proves the same thing as an acknowledgement of S, but the match is by exact
80//! snapshot reference, so it does not count for S. Accepting it would decouple
81//! reclaim from acknowledgement timing. It is left out because reclaim already
82//! selects the maximal acknowledged snapshot: if every device acknowledged that
83//! later snapshot, that is what gets selected and reclaim proceeds past S
84//! anyway. The narrowing would only ever matter for reclaiming S's own image,
85//! which nothing reclaims.
86//!
87//! ## Where the set is computed
88//!
89//! `build_acknowledged_snapshot` walks the devices active at the coverage and
90//! passes over the ones that are not active in the device state resolved at the
91//! authority's accepted cut — the coverage extended to each device's latest
92//! announcement, which is the newest state the walking device has verified.
93//!
94//! That set is a function of when it is asked, so a reclaim's evidence is
95//! checked against it twice: when this device signs the evidence, and again
96//! before it deletes, which can be a later cycle. The required set can only
97//! shrink between those points — a device leaves it by being excluded, and one
98//! that joins after the coverage was never in it — so the evidence check asks
99//! that the claim carry every acknowledgement now required and permits it to
100//! carry more. Requiring the two to match exactly would mean an exclusion
101//! landing in that window left a signed authorization that could never execute
102//! and, because an existing operation for a target blocks re-authorizing it,
103//! never be replaced.
104//!
105//! ## The empty set, and why it holds
106//!
107//! If no device active at the coverage is still active, the set is empty and
108//! the rule above is vacuously satisfied. That is arguably a licence to
109//! reclaim, and the argument is sound as far as it goes: every current member
110//! must then have joined after the coverage, and a join bootstraps from a
111//! snapshot at or past `S`, so no current member needs anything behind `S` —
112//! there is nobody left to ask because there is nobody left who could want it.
113//!
114//! It is held anyway. The joined-after leg rests on that bootstrap landing at
115//! or past `S`, and the section above says where that can fail: a snapshot
116//! rejected as uninstallable sends a join back to an older generation, and
117//! Store snapshot images are never reclaimed, so an older generation stays
118//! selectable indefinitely. With at least one live witness the acknowledgement
119//! is independent evidence that some current device really is past the
120//! coverage. With none, the only thing standing behind the deletion is that
121//! same bootstrap assumption, which is exactly the one not yet settled. So
122//! until the interaction with old-generation fallback is looked at on its own:
123//! no live witness, no delete.
124//!
125//! This is a choice, not a derived bound, and it costs a reclaim that would
126//! have been safe. It needs every device from the coverage era — the owner's
127//! registration among them — to have been excluded before it can arise.
128
129use coven_database::StoreReclaimJournalError;
130use std::sync::Arc;
131
132mod candidates;
133mod claims;
134mod history;
135
136use crate::sync::store::AuthorizedWriterOperation;
137use coven_database::{
138    DurableStoreReclaimObject, DurableStoreReclaimOperation, ReclaimCommitActivation, StoreDatabase,
139};
140use coven_protocol::circle::{CircleControlCoord, CircleControlState, CircleEpochOrigin, CircleId};
141use coven_protocol::objects::StoreObjectError;
142use coven_protocol::objects::{ProtocolObjectContext, ProtocolObjectDomain, StorageError};
143use coven_protocol::reclaim::*;
144use coven_protocol::store_commit::{
145    snapshot_image_semantic_prefix, CommitFrontier, ObjectHash, StoreAckRef, StoreBatchCommitRef,
146    StoreRootRef, StoreSnapshotLocator, VerifiedStoreBatchCommit,
147};
148use coven_storage::CloudSyncObjectStorage;
149pub(crate) use history::{CircleSnapshotStream, ReclaimHistory, SelectedCircleSnapshot};
150
151#[derive(Debug, PartialEq, Eq)]
152pub struct StoreReclaimResult {
153    pub packages_deleted: u64,
154    pub physical_copies_deleted: u64,
155    /// Operations the journal is left holding for a person: they failed with an
156    /// error running them again cannot change, so every later cycle skips them
157    /// until the host asks for one back.
158    pub stuck: u64,
159    /// What the Store-package leg did, so a run that deleted nothing says which
160    /// step declined instead of reporting a bare zero. The leg is the one whose
161    /// outcome was previously unobservable: its two commonest declines are
162    /// turned into an empty target list on purpose, so that Store trouble does
163    /// not block Circle reclaim, and that swallowed the reason with the error.
164    pub store_packages: StorePackageReclaimReport,
165}
166
167/// What the Store-package leg of one reclaim run considered and what it did.
168///
169/// Counts rather than per-target lines: a store with hundreds of covered
170/// commits would drown a cycle in log spam, and the question a reader has is
171/// which step the targets died at, not which target.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct StorePackageReclaimReport {
174    /// The coverage the leg had to work from, or why it had none.
175    pub coverage: StorePackageReclaimCoverage,
176    /// Package-bearing commits at or behind the coverage.
177    pub targets_considered: u64,
178    /// Targets left alone because a retained materialization still pins them
179    /// for replay. A run where this equals `targets_considered` is one whose
180    /// retained set has not been narrowed by a snapshot image projection.
181    pub retained_for_replay: u64,
182    /// Targets left alone because a pending blob reclaim still has to re-read
183    /// them to prove its binding. They come back into reach once that blob is
184    /// gone.
185    pub retained_for_blob_reclaim: u64,
186    /// Targets that already have a journalled operation, which blocks
187    /// re-authorizing them.
188    pub already_authorized: u64,
189    /// Targets this run signed a fresh authorization for.
190    pub authorized: u64,
191}
192
193/// The coverage the Store-package leg worked from, or why it had none.
194///
195/// A decline is a value here rather than a swallowed error because it is the
196/// leg's ordinary outcome, not a failure: `run` deliberately continues to the
197/// Circle legs when the Store leg has no coverage, and reporting the reason is
198/// the only way a reader can tell that apart from having nothing to delete.
199impl StorePackageReclaimReport {
200    /// A report for a leg that has not looked at any target yet — the shape a
201    /// declined leg keeps, and the starting point for one that proceeds.
202    fn declined(coverage: StorePackageReclaimCoverage) -> Self {
203        Self {
204            coverage,
205            targets_considered: 0,
206            retained_for_replay: 0,
207            retained_for_blob_reclaim: 0,
208            already_authorized: 0,
209            authorized: 0,
210        }
211    }
212}
213
214/// Whether a claim reached the provider or found its target already journalled.
215///
216/// An existing operation for a target blocks re-authorizing it, so the two are
217/// worth telling apart: one is progress, the other is a target this run could
218/// not have acted on however it was configured.
219enum AuthorizationOutcome {
220    Signed,
221    AlreadyJournalled,
222}
223
224/// What one pass over the journal did.
225struct ReclaimPass {
226    packages_deleted: u64,
227    /// Operations the journal holds stuck when the pass ended — the ones it
228    /// marked plus the ones an earlier pass did.
229    stuck: u64,
230}
231
232/// What advancing one journalled operation by one step did.
233enum ReclaimStep {
234    /// The operation moved to its next durable state.
235    Advanced,
236    /// The operation deleted its target.
237    Deleted,
238    /// Nothing to do this pass: the operation is finished, or it waits behind
239    /// a blob reclaim that still has to re-read the package it deletes.
240    Idle,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum StorePackageReclaimCoverage {
245    /// The generation the leg deleted behind.
246    Snapshot { generation: u64 },
247    /// No snapshot every active device has acknowledged.
248    NoSnapshot,
249    /// A device that must acknowledge the snapshot has not, so nothing may be
250    /// deleted behind it.
251    MissingAcknowledgement { member: String, device_id: String },
252    /// This device is not the current owner, so it does not reclaim at all.
253    NotOwner,
254    /// Nothing this evaluation depends on has changed since the last one, so
255    /// its answer is the last one. The steady state of a settled store, and the
256    /// only outcome here that reaches the provider not at all.
257    InputsUnchanged,
258}
259
260#[derive(Debug, thiserror::Error)]
261pub enum StoreReclaimError {
262    #[error(transparent)]
263    Object(#[from] StoreObjectError),
264    #[error(transparent)]
265    Database(#[from] coven_database::DbError),
266    #[error(transparent)]
267    Outbound(#[from] crate::sync::store::StoreError),
268    #[error("Store reclaim journal: {0}")]
269    Journal(#[from] StoreReclaimJournalError),
270    #[error(transparent)]
271    Storage(#[from] StorageError),
272    #[error("no authorized complete Store snapshot is available for reclamation")]
273    NoSnapshot,
274    #[error("snapshot authorization history is invalid: {0}")]
275    Authorization(String),
276    #[error("snapshot authorization Store pull: {0}")]
277    StorePull(#[source] Box<crate::sync::store::pull::StorePullError>),
278    #[error("snapshot authorization Store protocol: {0}")]
279    Protocol(#[from] coven_protocol::store_commit::StoreProtocolError),
280    #[error("snapshot authorization audience package: {0}")]
281    AudiencePackage(#[from] coven_protocol::audience_package::AudiencePackageError),
282    #[error("snapshot authorization snapshot: {0}")]
283    Snapshot(#[source] Box<crate::sync::store::SnapshotError>),
284    #[error("snapshot authorization acknowledgement: {0}")]
285    Acknowledgement(#[source] Box<crate::sync::store::StoreAckError>),
286    #[error("snapshot authorization writer: {0}")]
287    WriterAuthorization(#[source] Box<crate::sync::store::StoreWriterAuthorizationError>),
288    #[error(
289        "active Store device {device_id:?} for member {member:?} has no exact acknowledgement"
290    )]
291    MissingAcknowledgement { member: String, device_id: String },
292    #[error("exact Store ancestry is missing commit {commit_hash}")]
293    MissingAncestry { commit_hash: ObjectHash },
294    #[error("deleting the exact object activated by {activation} failed: {source}")]
295    Delete {
296        activation: ObjectHash,
297        #[source]
298        source: StorageError,
299    },
300}
301
302impl From<crate::sync::store::pull::CommitCoverageError> for StoreReclaimError {
303    fn from(error: crate::sync::store::pull::CommitCoverageError) -> Self {
304        match error {
305            crate::sync::store::pull::CommitCoverageError::Object(error) => Self::Object(error),
306            crate::sync::store::pull::CommitCoverageError::MissingAncestry { commit_hash } => {
307                Self::MissingAncestry { commit_hash }
308            }
309        }
310    }
311}
312
313impl From<crate::sync::store::pull::StorePullError> for StoreReclaimError {
314    fn from(error: crate::sync::store::pull::StorePullError) -> Self {
315        Self::StorePull(Box::new(error))
316    }
317}
318
319impl From<crate::sync::store::SnapshotError> for StoreReclaimError {
320    fn from(error: crate::sync::store::SnapshotError) -> Self {
321        Self::Snapshot(Box::new(error))
322    }
323}
324
325impl From<crate::sync::store::StoreAckError> for StoreReclaimError {
326    fn from(error: crate::sync::store::StoreAckError) -> Self {
327        Self::Acknowledgement(Box::new(error))
328    }
329}
330
331impl From<crate::sync::store::StoreWriterAuthorizationError> for StoreReclaimError {
332    fn from(error: crate::sync::store::StoreWriterAuthorizationError) -> Self {
333        Self::WriterAuthorization(Box::new(error))
334    }
335}
336
337use candidates::*;
338
339pub(crate) struct AuthorizedReclaim<'operation, 'storage> {
340    writer: &'operation mut AuthorizedWriterOperation<'storage>,
341    database: StoreDatabase,
342    storage: Arc<dyn CloudSyncObjectStorage>,
343    root: StoreRootRef,
344    membership: coven_protocol::membership::MembershipChain,
345}
346
347impl<'operation, 'storage> AuthorizedReclaim<'operation, 'storage> {
348    pub(crate) fn new(
349        writer: &'operation mut AuthorizedWriterOperation<'storage>,
350        database: StoreDatabase,
351        storage: Arc<dyn CloudSyncObjectStorage>,
352        root: StoreRootRef,
353        membership: coven_protocol::membership::MembershipChain,
354    ) -> Self {
355        Self {
356            writer,
357            database,
358            storage,
359            root,
360            membership,
361        }
362    }
363
364    fn history(&mut self) -> ReclaimHistory<'_, 'storage> {
365        self.writer.reclaim_history()
366    }
367
368    pub(super) async fn run(
369        &mut self,
370        settled: &crate::sync::store::SettledCycle,
371    ) -> Result<StoreReclaimResult, StoreReclaimError> {
372        let database = self.database.clone();
373        let membership = self.membership.clone();
374        // Blob reclaims are authorized before anything executes. Executing a
375        // blob reclaim re-reads the package that published the blob, so a
376        // package operation must defer to every blob operation that names it —
377        // including one for a blob that only became free since the package
378        // was authorized. Journalling the blob operations first is what lets
379        // the resume below see them.
380        if self.writer.is_current_owner(&membership) {
381            Box::pin(self.prepare_audience_blob_authorizations()).await?;
382        }
383        // Journalled work next, always. An operation this device authorized and
384        // did not finish is durable state waiting on its author, and gating that
385        // behind "did anything change" would leave it waiting on an unrelated
386        // event.
387        let journal = Box::pin(self.resume_operations()).await?;
388        let mut packages_deleted = journal.packages_deleted;
389        if !self.writer.is_current_owner(&membership) {
390            return Ok(StoreReclaimResult {
391                packages_deleted,
392                physical_copies_deleted: packages_deleted,
393                stuck: journal.stuck,
394                store_packages: StorePackageReclaimReport::declined(
395                    StorePackageReclaimCoverage::NotOwner,
396                ),
397            });
398        }
399        // The evaluation below walks every candidate snapshot's stability and
400        // every device's acknowledgement chain. Its answer is a function of
401        // facts this database holds, so running it again against the same ones
402        // spends the provider to reach a conclusion already reached.
403        let inputs = crate::sync::store::CycleInputs::read(&database, &membership)
404            .await
405            .map_err(StoreReclaimError::Database)?;
406        if settled.reclaim_evaluated(&inputs) {
407            return Ok(StoreReclaimResult {
408                packages_deleted,
409                physical_copies_deleted: packages_deleted,
410                stuck: journal.stuck,
411                store_packages: StorePackageReclaimReport::declined(
412                    StorePackageReclaimCoverage::InputsUnchanged,
413                ),
414            });
415        }
416        let registrations = database
417            .activated_store_device_registration_records()
418            .await
419            .map_err(StoreReclaimError::from)?;
420        // A missing or unstable Store snapshot leaves Store packages uncovered but must
421        // not block Circle package reclamation, which carries its own Circle coverage.
422        let mut rollup_claims = Vec::new();
423        let (coverage, store_targets) = match Box::pin(self.choose_snapshot(&registrations)).await {
424            Ok(claim) => {
425                let generation = claim.snapshot.reference.generation;
426                rollup_claims =
427                    self.prepare_store_membership_rollup_claims(&registrations, &claim)?;
428                let targets = self
429                    .history()
430                    .store_package_targets(&claim.snapshot.meta.coverage)
431                    .await
432                    .map_err(StoreReclaimError::from)?
433                    .into_iter()
434                    .map(|(commit, package)| (commit, package, claim.clone()))
435                    .collect::<Vec<_>>();
436                (
437                    StorePackageReclaimCoverage::Snapshot { generation },
438                    targets,
439                )
440            }
441            // Store trouble must not block Circle reclamation, which carries
442            // its own Circle coverage — so these two do not propagate. The
443            // reason travels in the report instead of dying here, which is what
444            // makes a cycle that deleted nothing say why.
445            Err(StoreReclaimError::NoSnapshot) => {
446                (StorePackageReclaimCoverage::NoSnapshot, Vec::new())
447            }
448            Err(StoreReclaimError::MissingAcknowledgement { member, device_id }) => (
449                StorePackageReclaimCoverage::MissingAcknowledgement { member, device_id },
450                Vec::new(),
451            ),
452            Err(error) => return Err(error),
453        };
454        let mut store_packages = StorePackageReclaimReport::declined(coverage);
455        store_packages.targets_considered = store_targets.len() as u64;
456        for (commit, package, snapshot) in store_targets {
457            if database
458                .store_package_is_retained_for_replay(
459                    self.root.clone(),
460                    package.clone(),
461                    commit.clone(),
462                )
463                .await?
464            {
465                store_packages.retained_for_replay += 1;
466                continue;
467            }
468            if database
469                .package_is_retained_by_pending_blob_reclaim(package.object.clone())
470                .await?
471            {
472                store_packages.retained_for_blob_reclaim += 1;
473                continue;
474            }
475            let authorized = Box::pin(self.prepare_authorization(ReclaimClaim::StorePackage(
476                StorePackageReclaimClaim {
477                    target: StorePackageReclaimTarget {
478                        package,
479                        activation: commit,
480                    },
481                    covering_snapshot: StoreSnapshotLocator {
482                        author_registration: snapshot.snapshot.meta.author_registration.clone(),
483                        snapshot: snapshot.snapshot.reference.clone(),
484                    },
485                    acknowledgements: snapshot.acknowledgements.clone(),
486                },
487            )))
488            .await?;
489            match authorized {
490                AuthorizationOutcome::Signed => store_packages.authorized += 1,
491                AuthorizationOutcome::AlreadyJournalled => store_packages.already_authorized += 1,
492            }
493        }
494        for claim in rollup_claims {
495            Box::pin(self.prepare_authorization(claim)).await?;
496        }
497        Box::pin(self.prepare_circle_authorizations(&registrations)).await?;
498        let authorized = Box::pin(self.resume_operations()).await?;
499        packages_deleted = packages_deleted
500            .checked_add(authorized.packages_deleted)
501            .ok_or_else(|| {
502                StoreReclaimError::Authorization("reclaimed package count exceeded u64".to_string())
503            })?;
504        // Recorded only once the evaluation has run all the way through, so a
505        // run that failed partway is re-run rather than remembered as settled.
506        settled.record_reclaim_evaluated(inputs);
507        Ok(StoreReclaimResult {
508            packages_deleted,
509            physical_copies_deleted: packages_deleted,
510            stuck: authorized.stuck,
511            store_packages,
512        })
513    }
514
515    async fn prepare_beyond_cutoff_circle_authorizations(
516        &mut self,
517        circle_id: CircleId,
518        current_control: &CircleControlCoord,
519    ) -> Result<(), StoreReclaimError> {
520        let database = self.database.clone();
521        let root = self.root.clone();
522        let successor = database
523            .verified_circle_activation(root.clone(), circle_id, current_control.clone())
524            .await?
525            .ok_or_else(|| {
526                StoreReclaimError::Authorization(format!(
527                    "Circle {circle_id} current control is not a retained activation"
528                ))
529            })?;
530        // Only a control that closed a predecessor epoch carries a cutoff; a Circle
531        // whose current epoch closed nothing has no beyond-cutoff package to enumerate.
532        if !matches!(
533            successor.control.value.state(),
534            CircleControlState::ActiveEpoch(active)
535                if matches!(active.common.origin, CircleEpochOrigin::Closed { .. })
536        ) {
537            return Ok(());
538        }
539        let frontier = CommitFrontier::from_refs(database.materialized_frontier().await?)
540            .map_err(StoreReclaimError::from)?;
541        let epochs = database.circle_replay_epoch_index(root.clone()).await?;
542        let targets = self
543            .history()
544            .circle_package_targets(circle_id, &frontier)
545            .await
546            .map_err(StoreReclaimError::from)?;
547        for (commit, package) in targets {
548            // `permits` is the same predicate the pull path applies; a package it
549            // accepts is live history. A package whose control it cannot resolve, or
550            // that conflicts with the cutoff, errors rather than being reclaimed.
551            if epochs
552                .permits(&commit, circle_id, &package.control)
553                .map_err(StoreReclaimError::from)?
554            {
555                continue;
556            }
557            if database
558                .circle_package_is_retained_for_replay(
559                    root.clone(),
560                    package.clone(),
561                    commit.clone(),
562                )
563                .await?
564                || database
565                    .package_is_retained_by_pending_blob_reclaim(package.package.object.clone())
566                    .await?
567            {
568                continue;
569            }
570            Box::pin(self.prepare_authorization(ReclaimClaim::CirclePackage(
571                CirclePackageReclaimClaim::BeyondEpochCutoff(CirclePackageBeyondCutoffClaim {
572                    target: CirclePackageReclaimTarget {
573                        package,
574                        activation: commit,
575                    },
576                    successor_control: current_control.clone(),
577                }),
578            )))
579            .await?;
580        }
581        Ok(())
582    }
583
584    async fn prepare_audience_blob_authorizations(&mut self) -> Result<(), StoreReclaimError> {
585        let database = self.database.clone();
586        for (blob, owners) in database.stored_blob_reclaim_candidates().await? {
587            if !database.stored_blob_is_row_orphaned(blob.clone()).await? {
588                continue;
589            }
590            if database
591                .audience_blob_is_retained_for_replay(blob.clone())
592                .await?
593            {
594                continue;
595            }
596            // Which owning commit's package carries the binding is the one thing no
597            // local state records — the audience picks the package within a commit, but
598            // not which commit. Probe only that dimension.
599            let mut binding = None;
600            for owner in &owners {
601                let commit = self
602                    .history()
603                    .load_ref(owner)
604                    .await
605                    .map_err(StoreReclaimError::from)?;
606                if let Some(package) =
607                    audience_blob_binding_package(commit.value(), blob.locator().audience())
608                {
609                    binding = Some((package, owner.clone()));
610                    break;
611                }
612            }
613            let Some((package, activation)) = binding else {
614                tracing::debug!(
615                    blob = %coven_protocol::remote_object::remote_object_id(blob.object()),
616                    "skip orphaned blob whose owning commits name no package for its audience",
617                );
618                continue;
619            };
620            let target = AudienceBlobReclaimTarget {
621                blob,
622                package,
623                activation,
624            };
625            Box::pin(self.prepare_authorization(ReclaimClaim::AudienceBlob(
626                AudienceBlobReclaimClaim { target },
627            )))
628            .await?;
629        }
630        Ok(())
631    }
632
633    /// Authorize deleting the membership rollup of every Store snapshot
634    /// generation the acknowledged one supersedes.
635    ///
636    /// A rollup is only ever reached through the generation that names it, and
637    /// only the newest generation's is ever read — a joining device takes the
638    /// newest listed snapshot and follows its `membership_rollup`. Nothing lists
639    /// `store-v1/membership-rollups/`, so an older generation's rollup is not
640    /// merely unused but unfindable, and it stayed at the provider forever.
641    ///
642    /// `selected` is the snapshot the package leg already proved every device
643    /// that matters has acknowledged, and `selected.authorized` is the stream it
644    /// was chosen from — so this asks the provider for nothing. A generation is
645    /// superseded when that same author published the selected one later, over a
646    /// strictly greater cut, naming a different rollup.
647    fn prepare_store_membership_rollup_claims(
648        &self,
649        registrations: &[coven_protocol::store_commit::ReferencedStoreDeviceRegistration],
650        selected: &VerifiedReclaimSnapshot,
651    ) -> Result<Vec<ReclaimClaim>, StoreReclaimError> {
652        let author = &selected.snapshot.meta.author_registration;
653        let Some(registration) = registrations
654            .iter()
655            .find(|candidate| candidate.reference() == author)
656        else {
657            return Ok(Vec::new());
658        };
659        let activation = registration
660            .value()
661            .store_snapshot_activation(registration.reference())
662            .map_err(StoreReclaimError::from)?
663            .activation_id();
664        let mut claims = Vec::new();
665        for generation in &selected.authorized {
666            if generation.meta.author_registration != *author
667                || generation.reference.generation >= selected.snapshot.reference.generation
668                || generation.meta.membership_rollup.object
669                    == selected.snapshot.meta.membership_rollup.object
670                || !snapshot_supersedes_seed(
671                    &selected.snapshot.meta.coverage,
672                    &generation.meta.coverage,
673                )
674            {
675                continue;
676            }
677            claims.push(ReclaimClaim::StoreMembershipRollup(
678                StoreMembershipRollupReclaimClaim {
679                    target: StoreMembershipRollupReclaimTarget {
680                        snapshot_author: author.clone(),
681                        activation,
682                        snapshot: generation.reference.clone(),
683                        rollup: generation.meta.membership_rollup.clone(),
684                    },
685                    superseding: selected.snapshot.reference.clone(),
686                },
687            ));
688        }
689        Ok(claims)
690    }
691
692    async fn prepare_circle_snapshot_image_authorizations(
693        &mut self,
694        circle_id: CircleId,
695        streams: &[CircleSnapshotStream],
696        stable: &[SelectedCircleSnapshot],
697    ) -> Result<(), StoreReclaimError> {
698        let database = self.database.clone();
699        for stream in streams {
700            for (reference, meta) in &stream.generations {
701                let Some(superseding) = stable.iter().find(|candidate| {
702                    candidate.author_registration == stream.author_registration
703                        && candidate.reference.generation > reference.generation
704                        && snapshot_supersedes_seed(
705                            &candidate.meta.bootstrap.coverage,
706                            &meta.bootstrap.coverage,
707                        )
708                }) else {
709                    continue;
710                };
711                let target = CircleSnapshotImageReclaimTarget {
712                    circle_id,
713                    snapshot_author: stream.author_registration.clone(),
714                    control: meta.control.clone(),
715                    snapshot: reference.clone(),
716                    image: meta.bootstrap.image.clone(),
717                };
718                if database
719                    .circle_image_is_retained_for_replay(circle_id, target.image.clone())
720                    .await?
721                {
722                    continue;
723                }
724                Box::pin(
725                    self.prepare_authorization(ReclaimClaim::CircleSnapshotImage(
726                        CircleSnapshotImageReclaimClaim {
727                            target,
728                            superseding: superseding.reference.clone(),
729                        },
730                    )),
731                )
732                .await?;
733            }
734        }
735        Ok(())
736    }
737
738    async fn prepare_circle_authorizations(
739        &mut self,
740        registrations: &[coven_protocol::store_commit::ReferencedStoreDeviceRegistration],
741    ) -> Result<(), StoreReclaimError> {
742        let database = self.database.clone();
743        for input in database.circle_acknowledgement_publication_inputs().await? {
744            let circle_id = input.circle_id();
745            let control = input.control().clone();
746            // A package beyond its epoch's accepted cutoff never materializes anywhere,
747            // so it needs no snapshot coverage and is enumerated whether or not this
748            // Circle has a stable snapshot.
749            Box::pin(self.prepare_beyond_cutoff_circle_authorizations(circle_id, &control)).await?;
750            // Both remaining passes read the same evidence: every device's snapshot
751            // stream and which of its generations every active-access device has
752            // acknowledged. Read it once.
753            let streams = self
754                .history()
755                .load_circle_snapshot_streams(circle_id, &control, registrations)
756                .await?;
757            let stable = self
758                .history()
759                .stable_circle_snapshots(circle_id, &streams)
760                .await?;
761            let selected = maximal_stable_circle_snapshot(&stable);
762            Box::pin(self.prepare_circle_bootstrap_authorizations(circle_id, &control, selected))
763                .await?;
764            // A superseded snapshot generation's image is reclaimable on its own
765            // stream's evidence, independent of which snapshot covers the packages.
766            Box::pin(
767                self.prepare_circle_snapshot_image_authorizations(circle_id, &streams, &stable),
768            )
769            .await?;
770            let Some(selected) = selected else {
771                continue;
772            };
773            let targets = self
774                .history()
775                .circle_package_targets(circle_id, &selected.meta.bootstrap.coverage)
776                .await
777                .map_err(StoreReclaimError::from)?;
778            for (commit, package) in targets {
779                if database
780                    .circle_package_is_retained_for_replay(
781                        self.root.clone(),
782                        package.clone(),
783                        commit.clone(),
784                    )
785                    .await?
786                    || database
787                        .package_is_retained_by_pending_blob_reclaim(package.package.object.clone())
788                        .await?
789                {
790                    continue;
791                }
792                Box::pin(self.prepare_authorization(ReclaimClaim::CirclePackage(
793                    CirclePackageReclaimClaim::SnapshotCovered(
794                        CirclePackageSnapshotCoverageClaim {
795                            target: CirclePackageReclaimTarget {
796                                package,
797                                activation: commit,
798                            },
799                            covering_snapshot: CircleSnapshotLocator {
800                                author_registration: selected.author_registration.clone(),
801                                circle_id,
802                                control: selected.meta.control.clone(),
803                                snapshot: selected.reference.clone(),
804                            },
805                            acknowledgements: selected.acknowledgements.clone(),
806                        },
807                    ),
808                )))
809                .await?;
810            }
811        }
812        Ok(())
813    }
814
815    async fn prepare_circle_bootstrap_authorizations(
816        &mut self,
817        circle_id: CircleId,
818        current_control: &CircleControlCoord,
819        selected: Option<&SelectedCircleSnapshot>,
820    ) -> Result<(), StoreReclaimError> {
821        let database = self.database.clone();
822        let root = self.root.clone();
823        let roster = database.circle_current_roster_members(circle_id).await?;
824        // The maximal acknowledgement-stable Circle snapshot cut, if any. A seed a
825        // still-active recipient holds is superseded only when this cut strictly
826        // dominates it — a later sufficient snapshot every active device acknowledged.
827        let stable_cut = selected.map(|selected| &selected.meta.bootstrap.coverage);
828        for acknowledgement in database.activated_circle_acks(circle_id).await? {
829            let ack = match self
830                .history()
831                .load_circle_acknowledgement(&acknowledgement)
832                .await
833            {
834                Ok(ack) => ack,
835                Err(error) => {
836                    tracing::debug!(
837                        circle_id = %circle_id,
838                        "skip Circle acknowledgement for bootstrap reclaim: {error}"
839                    );
840                    continue;
841                }
842            };
843            let Some(coverage) = ack.seeded_from.clone() else {
844                // A founder/source device never seeded from an image — nothing to reclaim.
845                continue;
846            };
847            let recipient = database
848                .activated_store_device_registration(acknowledgement.registration.clone())
849                .await?;
850            let recipient_active = roster.contains(&recipient.value().author_pubkey);
851            let seed = &coverage.bootstrap.coverage;
852            let superseded_by_snapshot = stable_cut
853                .as_ref()
854                .is_some_and(|cut| snapshot_supersedes_seed(cut, seed));
855            let proof = if recipient_active {
856                if superseded_by_snapshot {
857                    CircleBootstrapReclaimProof::RecipientCoverage {
858                        acknowledgement: acknowledgement.clone(),
859                    }
860                } else {
861                    // No later sufficient snapshot supersedes the recipient's live seed.
862                    continue;
863                }
864            } else if database
865                .circle_control_covers_strictly(
866                    root.clone(),
867                    circle_id,
868                    current_control,
869                    &coverage.control,
870                )
871                .await?
872            {
873                CircleBootstrapReclaimProof::LostAuthority {
874                    acknowledgement: acknowledgement.clone(),
875                    successor_control: current_control.clone(),
876                }
877            } else {
878                continue;
879            };
880            let target = CircleBootstrapImageReclaimTarget { coverage };
881            if database
882                .circle_bootstrap_image_is_retained_for_replay(target.coverage.clone())
883                .await?
884            {
885                continue;
886            }
887            Box::pin(
888                self.prepare_authorization(ReclaimClaim::CircleBootstrapImage(
889                    CircleBootstrapImageReclaimClaim { target, proof },
890                )),
891            )
892            .await?;
893        }
894        Ok(())
895    }
896
897    async fn prepare_authorization(
898        &mut self,
899        claim: ReclaimClaim,
900    ) -> Result<AuthorizationOutcome, StoreReclaimError> {
901        let database = self.database.clone();
902        let root = self.root.clone();
903        let target = claim.target();
904        if database
905            .store_reclaim_operations()
906            .await?
907            .iter()
908            .any(|operation| operation.authorization().target() == &target)
909        {
910            return Ok(AuthorizationOutcome::AlreadyJournalled);
911        }
912        let plan = self.writer.prepare_plan().await?;
913        let owner_grant = plan.owner_grant().cloned().ok_or_else(|| {
914            StoreReclaimError::Authorization(
915                "Store reclaim authorization requires an active Owner grant".to_string(),
916            )
917        })?;
918        let evidence = plan
919            .sign_reclaim_evidence(claim)
920            .map_err(StoreReclaimError::from)?;
921        self.verify_evidence(&evidence).await?;
922        let evidence_context = ProtocolObjectContext::store_encrypted(
923            root.store_root_hash,
924            ProtocolObjectDomain::StoreReclaimEvidence,
925        );
926        let evidence_prefix = reclaim_evidence_semantic_prefix(evidence.evidence_hash());
927        let evidence_slot = self
928            .storage
929            .allocate_protocol_slot(&evidence_context, &evidence_prefix, ".json")
930            .await?;
931        let evidence_prepared = self.storage.prepare_protocol_object(
932            &evidence_context,
933            evidence_slot,
934            &evidence_prefix,
935            evidence.to_bytes(),
936        )?;
937        let evidence_ref =
938            ReclaimEvidenceRef::from_evidence(&evidence, evidence_prepared.reference().clone());
939        let authorization = plan.sign_reclaim_authorization(
940            evidence.claim.target(),
941            evidence_ref.clone(),
942            StoreReclaimAuthority {
943                membership: plan.membership_state().clone(),
944                owner_grant,
945            },
946        );
947        let authorization_context = ProtocolObjectContext::signed_plaintext(
948            root.store_root_hash,
949            ProtocolObjectDomain::StoreReclaimAuthorization,
950        );
951        let authorization_prefix =
952            reclaim_authorization_semantic_prefix(authorization.authorization_hash());
953        let authorization_slot = self
954            .storage
955            .allocate_protocol_slot(&authorization_context, &authorization_prefix, ".json")
956            .await?;
957        let authorization_prepared = self.storage.prepare_protocol_object(
958            &authorization_context,
959            authorization_slot,
960            &authorization_prefix,
961            authorization.to_bytes(),
962        )?;
963        let authorization_ref = ReclaimAuthorizationRef::from_authorization(
964            &authorization,
965            authorization_prepared.reference().clone(),
966        );
967        let candidate = self
968            .writer
969            .prepare_candidate(
970                plan,
971                crate::sync::store::commit_publication::operation::commit_plan::StoreOperationBatch::ReclaimAuthorization(Box::new(
972                    authorization_ref.clone(),
973                )),
974            )
975            .await?;
976        let operation = DurableStoreReclaimOperation::AuthorizationCandidate {
977            object: Box::new(DurableStoreReclaimObject::Authorization {
978                evidence_ref,
979                evidence,
980                evidence_prepared,
981                authorization_ref,
982                authorization,
983                authorization_prepared,
984            }),
985            candidate: Box::new(candidate),
986        };
987        Box::pin(database.begin_store_reclaim_operation(operation)).await?;
988        Ok(AuthorizationOutcome::Signed)
989    }
990
991    /// Run every operation the journal holds that a cycle may still run.
992    ///
993    /// A deterministic failure — anything whose error chain carries no
994    /// transport fault — is final for that one operation: running it again
995    /// reaches the same refusal, so it is marked stuck and the pass carries on
996    /// with the operations behind it. A transport failure is the opposite: it
997    /// says nothing about the operation, so the pass ends and the loop's
998    /// backoff brings the whole thing round again.
999    async fn resume_operations(&mut self) -> Result<ReclaimPass, StoreReclaimError> {
1000        let database = self.database.clone();
1001        let mut packages_deleted = 0_u64;
1002        loop {
1003            let operations = database.runnable_store_reclaim_operations().await?;
1004            let mut progressed = false;
1005            for operation in operations {
1006                let operation_id = operation.operation_id();
1007                match Box::pin(self.run_operation(operation)).await {
1008                    Ok(ReclaimStep::Deleted) => {
1009                        packages_deleted = packages_deleted.checked_add(1).ok_or_else(|| {
1010                            StoreReclaimError::Authorization(
1011                                "reclaimed package count exceeded u64".to_string(),
1012                            )
1013                        })?;
1014                        progressed = true;
1015                    }
1016                    Ok(ReclaimStep::Advanced) => progressed = true,
1017                    Ok(ReclaimStep::Idle) => {}
1018                    Err(error) if crate::sync::error::error_chain_contains_transport(&error) => {
1019                        return Err(error)
1020                    }
1021                    Err(error) => {
1022                        tracing::warn!(
1023                            operation = %operation_id,
1024                            "Store reclaim operation is stuck until the host asks for it: {error}"
1025                        );
1026                        database
1027                            .mark_store_reclaim_operation_stuck(operation_id, error.to_string())
1028                            .await?;
1029                    }
1030                }
1031            }
1032            if !progressed {
1033                let stuck = database.stuck_reclaim_operations().await?.len() as u64;
1034                return Ok(ReclaimPass {
1035                    packages_deleted,
1036                    stuck,
1037                });
1038            }
1039        }
1040    }
1041
1042    /// Advance one journalled operation by one durable step.
1043    async fn run_operation(
1044        &mut self,
1045        operation: DurableStoreReclaimOperation,
1046    ) -> Result<ReclaimStep, StoreReclaimError> {
1047        match &operation {
1048            DurableStoreReclaimOperation::AuthorizationCandidate { .. }
1049            | DurableStoreReclaimOperation::ReceiptCandidate { .. } => {
1050                Box::pin(self.drive_candidate(operation)).await?;
1051                Ok(ReclaimStep::Advanced)
1052            }
1053            DurableStoreReclaimOperation::AuthorizationReplacing { .. }
1054            | DurableStoreReclaimOperation::ReceiptReplacing { .. } => {
1055                Box::pin(self.finish_candidate_replacement(operation)).await?;
1056                Ok(ReclaimStep::Advanced)
1057            }
1058            DurableStoreReclaimOperation::Authorized { .. } => {
1059                // A package a pending blob reclaim still has to re-read waits
1060                // its turn: the blob operation runs in this same pass or a
1061                // later one, and the package goes after it. Not an error — the
1062                // journal holds both, and the order between them is the only
1063                // thing being decided.
1064                if self.deferred_to_blob_reclaim(&operation).await? {
1065                    return Ok(ReclaimStep::Idle);
1066                }
1067                Box::pin(self.execute_delete(operation)).await?;
1068                Ok(ReclaimStep::Deleted)
1069            }
1070            DurableStoreReclaimOperation::AbsentVerified { .. } => {
1071                Box::pin(self.prepare_receipt(operation)).await?;
1072                Ok(ReclaimStep::Advanced)
1073            }
1074            DurableStoreReclaimOperation::Completed { .. } => Ok(ReclaimStep::Idle),
1075        }
1076    }
1077
1078    /// Whether `operation` deletes a package that a pending blob reclaim still
1079    /// names as the one that published its blob.
1080    async fn deferred_to_blob_reclaim(
1081        &self,
1082        operation: &DurableStoreReclaimOperation,
1083    ) -> Result<bool, StoreReclaimError> {
1084        let package = match operation.authorization().target() {
1085            ReclaimTarget::StorePackage(target) => target.package.object.clone(),
1086            ReclaimTarget::CirclePackage(target) => target.package.package.object.clone(),
1087            _ => return Ok(false),
1088        };
1089        Ok(self
1090            .database
1091            .package_is_retained_by_pending_blob_reclaim(package)
1092            .await?)
1093    }
1094
1095    async fn execute_delete(
1096        &mut self,
1097        operation: DurableStoreReclaimOperation,
1098    ) -> Result<(), StoreReclaimError> {
1099        let database = self.database.clone();
1100        let DurableStoreReclaimOperation::Authorized {
1101            authorization,
1102            activation,
1103        } = &operation
1104        else {
1105            return Err(StoreReclaimError::Authorization(
1106                "only an authorized reclaim can delete its target".to_string(),
1107            ));
1108        };
1109        let target = self.verify_authorized(authorization, activation).await?;
1110        if self.target_is_retained(&target).await? {
1111            return Err(StoreReclaimError::Authorization(
1112                "reclaim target remains retained for accepted replay".to_string(),
1113            ));
1114        }
1115        // A row blob has no protocol domain: it is addressed by its locator, so its
1116        // exact delete goes through the blob primitive rather than the protocol one.
1117        match &target {
1118            ReclaimTarget::AudienceBlob(blob) => self.storage.delete_blob_object(&blob.blob).await,
1119            _ => self.storage.delete_protocol_object(target.object()).await,
1120        }
1121        .map_err(|source| StoreReclaimError::Delete {
1122            activation: target.activation().object().stored_hash(),
1123            source,
1124        })?;
1125        self.verify_target_absent(&target).await?;
1126        database
1127            .mark_store_reclaim_target_absent(operation, target)
1128            .await?;
1129        Ok(())
1130    }
1131}
1132
1133#[cfg(test)]
1134mod audience_blob_order_tests;
1135#[cfg(test)]
1136mod tests;
1137
1138pub(crate) async fn create_reclaim_exact_objects(
1139    object: &coven_database::DurableStoreReclaimObject,
1140    storage: &dyn CloudSyncObjectStorage,
1141) -> Result<(), StoreReclaimJournalError> {
1142    match object {
1143        coven_database::DurableStoreReclaimObject::Authorization {
1144            evidence,
1145            evidence_prepared,
1146            authorization,
1147            authorization_prepared,
1148            ..
1149        } => {
1150            storage
1151                .create_verified_protocol_object(
1152                    &ProtocolObjectContext::store_encrypted(
1153                        evidence.store_root_hash,
1154                        ProtocolObjectDomain::StoreReclaimEvidence,
1155                    ),
1156                    evidence_prepared,
1157                    &reclaim_evidence_semantic_prefix(evidence.evidence_hash()),
1158                    &evidence.to_bytes(),
1159                )
1160                .await?;
1161            storage
1162                .create_verified_protocol_object(
1163                    &ProtocolObjectContext::signed_plaintext(
1164                        authorization.store_root_hash,
1165                        ProtocolObjectDomain::StoreReclaimAuthorization,
1166                    ),
1167                    authorization_prepared,
1168                    &reclaim_authorization_semantic_prefix(authorization.authorization_hash()),
1169                    &authorization.to_bytes(),
1170                )
1171                .await
1172                .map_err(StoreReclaimJournalError::Storage)
1173        }
1174        coven_database::DurableStoreReclaimObject::Receipt {
1175            receipt,
1176            receipt_prepared,
1177            ..
1178        } => storage
1179            .create_verified_protocol_object(
1180                &ProtocolObjectContext::signed_plaintext(
1181                    receipt.store_root_hash,
1182                    ProtocolObjectDomain::StoreReclaimReceipt,
1183                ),
1184                receipt_prepared,
1185                &reclaim_receipt_semantic_prefix(receipt.receipt_hash()),
1186                &receipt.to_bytes(),
1187            )
1188            .await
1189            .map_err(StoreReclaimJournalError::Storage),
1190    }
1191}