Skip to main content

coven_replication/sync/
cycle.rs

1//! Sync cycle orchestration.
2//!
3//! Runs a single sync cycle (gate + push local changes, pull remote changes,
4//! manage snapshots) and initializes sync infrastructure. All connection access
5//! goes through the owned [`Database`](coven_database::Database). Local changes are published from the
6//! durable pending-changeset journal, which each host write appends to inside its
7//! own journaled transaction — so a host write landing mid-cycle is captured for
8//! the next outgoing changeset, while the pull's apply is a plain connection write
9//! that is never journaled and so never echoes applied rows.
10
11use tracing::{debug, info, warn};
12
13use crate::blob::DrainOutcome;
14use coven_foundation::changeset::RowChange;
15use coven_foundation::store_dir::StoreDir;
16use coven_protocol::blob::BlobTransitionObserver;
17
18use super::status::DeviceActivity;
19use super::store::HeldStorePosition;
20use super::store::{AuthorizedWriterOperation, Store};
21use coven_foundation::stage_timing::StageTimings;
22use coven_protocol::objects::RotationPending;
23use coven_storage::{
24    BlobPathScheme, CloudSyncCipherStateAccess, CloudSyncConnection, CloudSyncObjectStorage,
25    CloudSyncRotationStateAccess,
26};
27
28/// Result of a single sync cycle.
29#[derive(Debug)]
30pub struct SyncCycleResult {
31    /// Number of remote changesets that were applied.
32    pub changesets_applied: u64,
33    /// Changesets whose present cloud object failed validation or apply. The
34    /// position is held at the bad seq for that device. Carries per-changeset
35    /// detail (device, seq, reason) so a host can say which changesets are
36    /// stalled, not only how many.
37    pub held_positions: Vec<HeldStorePosition>,
38    /// Per-device activity of the other devices seen in the sync storage —
39    /// device id, its member's author key, latest seq, and RFC 3339 last-sync
40    /// time — so a host can render which devices synced and when.
41    pub device_activity: Vec<DeviceActivity>,
42    /// RFC 3339 timestamp of when this cycle completed.
43    pub sync_time: String,
44    /// Blobs needed before apply failed to download; their changesets and positions
45    /// remain pending.
46    /// Post-commit local blob cleanup still has durable filesystem work pending.
47    /// Its corresponding rows and positions are already durable.
48    pub local_blob_cleanup_pending: bool,
49    /// Row changes from applied changesets, for the host to map to domain events.
50    pub row_changes: Vec<RowChange>,
51    /// The outbox drain broke this cycle to publish a just-completed make_remote
52    /// (coven flipped a root's gate the moment its last blob landed), so the loop
53    /// should run the next cycle promptly to drain + publish the rest instead of
54    /// waiting the idle interval.
55    pub resume_drain_promptly: bool,
56    /// Set when an exact local rotation operation or a committed peer rotation
57    /// still blocks sealing. While set, this cycle sealed no changeset, blob,
58    /// tombstone, or snapshot. The state identifies whether the blocker is a
59    /// candidate, a local committed removal, a peer commit, or both.
60    pub rotation_pending: Option<RotationPending>,
61}
62
63mod failure;
64#[cfg(test)]
65pub(crate) use failure::SyncCycleCause;
66pub use failure::SyncCycleFailure;
67
68struct PreparedCycle {
69    sync_time: String,
70    resume_drain_promptly: bool,
71    rotation_pending: Option<RotationPending>,
72}
73
74struct CompletedPullCycle {
75    store_pull: super::store::StorePullResult,
76    local_blob_cleanup_pending: bool,
77    sync_time: String,
78    resume_drain_promptly: bool,
79    rotation_pending: Option<RotationPending>,
80}
81
82struct AuthorizedSyncCycle<'cycle, 'store> {
83    device_id: &'cycle str,
84    snapshot_commit_threshold: std::num::NonZeroU64,
85    clock: &'cycle dyn coven_foundation::clock::Clock,
86    cipher: &'cycle dyn CloudSyncCipherStateAccess,
87    pending_rotation: &'cycle dyn CloudSyncRotationStateAccess,
88    master_keys: Option<&'cycle dyn coven_keys::keys::MasterKeyCustody>,
89    routing_encryption: Option<&'cycle coven_keys::encryption::EncryptionService>,
90    local_blob_access: &'cycle super::store::blob::LocalStoreBlobAccess,
91    observer: Option<&'cycle dyn BlobTransitionObserver>,
92    settled: &'cycle super::store::SettledCycle,
93    authorization: AuthorizedWriterOperation<'store>,
94}
95
96impl AuthorizedSyncCycle<'_, '_> {
97    async fn run(mut self) -> Result<SyncCycleResult, SyncCycleFailure> {
98        // Time the stages whichever way the cycle ends: a cycle that failed
99        // halfway through is exactly the one whose stage breakdown is wanted.
100        let mut timings =
101            StageTimings::counting("sync cycle", self.authorization.provider_requests());
102        let outcome = Box::pin(self.run_stages(&mut timings)).await;
103        timings.report();
104        outcome
105    }
106
107    async fn run_stages(
108        &mut self,
109        timings: &mut StageTimings,
110    ) -> Result<SyncCycleResult, SyncCycleFailure> {
111        timings
112            .stage(
113                "resume operations",
114                self.authorization
115                    .resume_operations(self.routing_encryption),
116            )
117            .await?;
118        let prepared = Box::pin(self.prepare_before_pull(timings)).await?;
119        let store_pull = timings
120            .stage("pull", self.authorization.pull(self.routing_encryption))
121            .await?;
122        let completed = Box::pin(self.complete_after_pull(prepared, store_pull, timings)).await?;
123        if completed.rotation_pending.is_none() {
124            timings
125                .stage(
126                    "publish epoch-close responses",
127                    self.authorization
128                        .circles()
129                        .publish_circle_epoch_close_responses(),
130                )
131                .await
132                .map_err(|error| {
133                    SyncCycleFailure::operation("publish Circle epoch-close responses", error)
134                })?;
135            if let Some(routing_encryption) = self.routing_encryption {
136                timings
137                    .stage(
138                        "finalize epoch closes",
139                        self.authorization
140                            .circles()
141                            .finalize_ready_circle_epoch_closes(routing_encryption),
142                    )
143                    .await
144                    .map_err(|error| {
145                        SyncCycleFailure::operation("finalize Circle epoch closes", error)
146                    })?;
147            }
148            let routing_encryption = self.routing_encryption;
149            timings
150                .stage(
151                    "advance replay baseline",
152                    Box::pin(self.stand_on_accepted_snapshot(routing_encryption)),
153                )
154                .await?;
155            timings
156                .stage(
157                    "publish acknowledgements",
158                    Box::pin(
159                        self.authorization
160                            .acknowledgements()
161                            .stage_and_publish(&completed.sync_time),
162                    ),
163                )
164                .await?;
165            timings
166                .stage(
167                    "retire arrived device joins",
168                    Box::pin(self.authorization.retire_arrived_device_joins()),
169                )
170                .await
171                .map(|retired| {
172                    if retired > 0 {
173                        info!(
174                            retired,
175                            "Device joins reached their arrival and were retired"
176                        );
177                    }
178                })
179                .map_err(|error| {
180                    SyncCycleFailure::operation("retire arrived device joins", error)
181                })?;
182            timings
183                .stage("reclaim packages", Box::pin(self.reclaim_packages()))
184                .await?;
185        }
186        Ok(SyncCycleResult {
187            changesets_applied: completed.store_pull.changesets_applied,
188            held_positions: completed.store_pull.held_positions,
189            device_activity: super::status::other_device_activity(
190                &completed.store_pull.visible_commits,
191                self.device_id,
192            ),
193            sync_time: completed.sync_time,
194            local_blob_cleanup_pending: completed.local_blob_cleanup_pending,
195            row_changes: completed.store_pull.row_changes,
196            resume_drain_promptly: completed.resume_drain_promptly,
197            rotation_pending: completed.rotation_pending,
198        })
199    }
200
201    async fn prepare_before_pull(
202        &mut self,
203        timings: &mut StageTimings,
204    ) -> Result<PreparedCycle, SyncCycleFailure> {
205        // Refresh authorization/decryption state BEFORE anything this cycle pushes,
206        // judges, or decrypts. Membership and the rotatable store key are
207        // per-cycle preconditions, not init-time bootstraps:
208        // re-read them now so a removed member's writes are rejected and a rotated key
209        // is adopted on a running device without a restart. Runs before the blob drain
210        // so the drain (and every push/pull below) uses the current key. A failure here
211        // aborts the cycle and retries next time — a refresh that can't complete must
212        // not also corrupt state. Adoption itself failing is not this kind of failure —
213        // see `rotation_pending` below.
214        timings
215            .stage(
216                "refresh authorization",
217                self.authorization.refresh_authorization_state(
218                    self.cipher,
219                    self.pending_rotation,
220                    self.master_keys,
221                ),
222            )
223            .await?;
224
225        // Whether this device has adopted everything the store has committed. Read
226        // once, right after the refresh that is the one place this cycle could adopt
227        // a rotation, and used below to skip every write that would otherwise seal
228        // new data under a generation the store has already superseded: the blob
229        // upload drain, Store write preparation, the tombstone
230        // write drain, both changeset-push paths, and the snapshot. Pull, local writes,
231        // and delete-only tombstone GC are unaffected — the gate
232        // is on sealing for the cloud, not on using the store. An unadoptable
233        // rotation is marked pending by the refresh and pauses exactly this set; it
234        // never aborts the cycle.
235        let rotation_pending = self
236            .pending_rotation
237            .check(self.cipher.current_generation())
238            .err();
239        if let Some(pending) = &rotation_pending {
240            warn!(
241                rotation_state = ?pending.state,
242                live_generation = pending.live_generation,
243                "sync paused: store-key rotation work is incomplete; sealing nothing new for the cloud"
244            );
245        }
246
247        if rotation_pending.is_none() {
248            let drained = timings
249                .stage(
250                    "drain tombstones",
251                    self.authorization.drain_tombstones(self.clock),
252                )
253                .await
254                .map_err(|error| {
255                    SyncCycleFailure::operation("drain queued blob tombstones", error)
256                })?;
257            if drained > 0 {
258                info!(count = drained, "Drained blob tombstones");
259            }
260        }
261        let reclaimed = timings
262            .stage(
263                "collect tombstones",
264                self.authorization.gc_tombstones(self.clock),
265            )
266            .await
267            .map_err(|error| {
268                SyncCycleFailure::operation("garbage-collect blob tombstones", error)
269            })?;
270        if reclaimed > 0 {
271            info!(count = reclaimed, "Reclaimed tombstoned blobs");
272        }
273
274        let local_seq = timings
275            .stage(
276                "read local position",
277                self.authorization.latest_local_store_position(),
278            )
279            .await
280            .map_err(|error| SyncCycleFailure::operation("read local Store position", error))?
281            .map_or(0, |reference| reference.coord.sequence());
282        timings
283            .stage(
284                "drain blob drop intents",
285                self.local_blob_access
286                    .drain_published_blob_drop_intents(local_seq),
287            )
288            .await
289            .map_err(|error| {
290                SyncCycleFailure::operation("drain published blob drop intents", error)
291            })?;
292
293        // One wall-clock reading for this whole cycle. Store acknowledgements and
294        // the status built at the end record the same instant. Store write commits
295        // carry a separate HLC stamp (`timestamp` below) for causal ordering.
296        let sync_time = self.clock.now().to_rfc3339();
297
298        let mut resume_drain_promptly = false;
299        if rotation_pending.is_none() {
300            let outcome = timings
301                .stage(
302                    "drain blob uploads",
303                    self.authorization.drain_uploads(
304                        self.clock,
305                        self.routing_encryption,
306                        self.observer,
307                    ),
308                )
309                .await
310                .map_err(|error| SyncCycleFailure::operation("drain queued blob uploads", error))?;
311            Self::record_upload_outcome(outcome, &mut resume_drain_promptly)?;
312        }
313
314        if rotation_pending.is_none() {
315            let published = timings
316                .stage(
317                    "publish prepared writes",
318                    self.authorization
319                        .publish_prepared_store_writes(self.routing_encryption),
320                )
321                .await?;
322            if published > 0 {
323                info!(published, "Published queued Store writes");
324            }
325        }
326
327        Ok(PreparedCycle {
328            sync_time,
329            resume_drain_promptly,
330            rotation_pending,
331        })
332    }
333
334    async fn complete_after_pull(
335        &mut self,
336        prepared: PreparedCycle,
337        store_pull: super::store::StorePullResult,
338        timings: &mut StageTimings,
339    ) -> Result<CompletedPullCycle, SyncCycleFailure> {
340        let PreparedCycle {
341            sync_time,
342            mut resume_drain_promptly,
343            rotation_pending,
344        } = prepared;
345        if rotation_pending.is_none() {
346            // Pull updates this cycle's authorized operation to the current
347            // membership state. Split its upload lane from that same operation so
348            // publication and the next make_remote root run concurrently without
349            // loading and verifying the Store authority again.
350            let upload_authorization = self.authorization.blob_upload_lane();
351            let lanes = timings
352                .stage("publish pending writes and drain next blob root", async {
353                    tokio::join!(
354                        self.authorization
355                            .publish_pending_store_writes(self.routing_encryption),
356                        upload_authorization.drain_uploads(
357                            self.clock,
358                            self.routing_encryption,
359                            self.observer,
360                        ),
361                    )
362                })
363                .await;
364            let (published, drained) = match lanes {
365                (Ok(published), Ok(drained)) => (published, drained),
366                (Err(first), Err(second)) => {
367                    return Err(SyncCycleFailure::concurrent(
368                        first,
369                        SyncCycleFailure::operation("drain queued blob uploads", second),
370                    ));
371                }
372                (Err(error), Ok(_)) => return Err(error),
373                (Ok(_), Err(error)) => {
374                    return Err(SyncCycleFailure::operation(
375                        "drain queued blob uploads",
376                        error,
377                    ));
378                }
379            };
380            if published > 0 {
381                info!(published, "Published Store writes");
382            }
383            Self::record_upload_outcome(drained, &mut resume_drain_promptly)?;
384        }
385
386        let local_seq = timings
387            .stage(
388                "read local position",
389                self.authorization.latest_local_store_position(),
390            )
391            .await
392            .map_err(|error| {
393                SyncCycleFailure::operation("read local Store position after publish", error)
394            })?
395            .map_or(0, |position| position.coord.sequence());
396        timings
397            .stage(
398                "drain blob drop intents",
399                self.local_blob_access
400                    .drain_published_blob_drop_intents(local_seq),
401            )
402            .await
403            .map_err(|error| {
404                SyncCycleFailure::operation("drain published blob drop intents", error)
405            })?;
406        let local_blob_cleanup_pending = timings
407            .stage(
408                "drain local blob cleanup",
409                self.authorization.drain_local_blob_cleanup(),
410            )
411            .await
412            .map_err(|error| {
413                SyncCycleFailure::operation(
414                    "drain local blob cleanup after Store publication",
415                    error,
416                )
417            })?
418            || store_pull.local_blob_cleanup_pending;
419
420        // Flush the clock's high-water mark so a restart re-seeds past it. Store pull
421        // advances the clock in the row-and-materialized-position commit closure, so
422        // `high_water` reflects remote commits and host stamps minted this cycle. A
423        // persist error aborts the cycle rather than risking a backward jump.
424        timings
425            .stage(
426                "persist clock high-water",
427                self.authorization.persist_hlc_high_water(),
428            )
429            .await
430            .map_err(|error| SyncCycleFailure::operation("persist HLC high-water mark", error))?;
431
432        timings
433            .stage(
434                "publish snapshots",
435                self.authorization.snapshots().publish_due_snapshots(
436                    &sync_time,
437                    self.routing_encryption,
438                    rotation_pending.is_some(),
439                    self.snapshot_commit_threshold,
440                ),
441            )
442            .await?;
443
444        Ok(CompletedPullCycle {
445            store_pull,
446            local_blob_cleanup_pending,
447            sync_time,
448            resume_drain_promptly,
449            rotation_pending,
450        })
451    }
452
453    fn record_upload_outcome(
454        outcome: DrainOutcome,
455        resume_drain_promptly: &mut bool,
456    ) -> Result<(), SyncCycleFailure> {
457        match outcome {
458            DrainOutcome::Drained {
459                uploaded,
460                yielded_for_publish,
461                failures,
462            } => {
463                if failures.has_transport_failure() {
464                    return Err(SyncCycleFailure::operation("upload queued blobs", failures));
465                }
466                *resume_drain_promptly |= yielded_for_publish;
467                if uploaded > 0 {
468                    info!(count = uploaded, "Drained blob uploads");
469                }
470            }
471            DrainOutcome::QueueEmpty => {}
472            DrainOutcome::AllInBackoff => {
473                debug!("Every queued blob upload is inside its retry backoff");
474            }
475            DrainOutcome::Paused => {
476                debug!("Blob uploads are paused by the host; nothing was admitted");
477            }
478        }
479        Ok(())
480    }
481
482    /// Stand on the latest installed accepted snapshot, and say every cycle
483    /// what that did — including, and especially, when it did nothing.
484    ///
485    /// Read this beside the reclaim line below it. A device whose baseline
486    /// never advances keeps its whole past retained and every package it ever
487    /// wrote pinned for replay, which is what a reclaim run reporting every
488    /// target as retained looks like from the log; without this line there is
489    /// no way to tell that from a reclaim that simply had nothing to do.
490    async fn stand_on_accepted_snapshot(
491        &mut self,
492        routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
493    ) -> Result<(), SyncCycleFailure> {
494        use super::store::{ReplayBaselineAdvance, ReplayBaselineDecline};
495
496        let outcome = self
497            .authorization
498            .acknowledgements()
499            .stand_on_accepted_snapshot(routing_encryption)
500            .await
501            .map_err(|error| {
502                SyncCycleFailure::operation("advance the Store replay baseline", error)
503            })?;
504        match outcome {
505            ReplayBaselineAdvance::Advanced(advanced) => info!(
506                commits = advanced.retired_commits,
507                pins = advanced.released_pins,
508                writes = advanced.folded_writes,
509                "Advanced the replay baseline over an accepted snapshot"
510            ),
511            ReplayBaselineAdvance::Declined(decline) => info!(
512                declined = decline.as_str(),
513                snapshot = ?decline.snapshot(),
514                accepted_snapshot = !matches!(decline, ReplayBaselineDecline::NoAcceptedSnapshot),
515                "Did not advance the replay baseline"
516            ),
517        }
518        Ok(())
519    }
520
521    /// Reclaim, and say what it did every cycle rather than only when it
522    /// deleted something.
523    ///
524    /// A stage that reports only its successes is indistinguishable from one
525    /// that is not running, which is what a store spending seconds here and
526    /// deleting nothing looked like from the log. One line per cycle, counts
527    /// rather than per-target detail: a store with hundreds of covered commits
528    /// would drown the cycle, and the question is which step the targets died
529    /// at, not which target.
530    async fn reclaim_packages(&mut self) -> Result<(), SyncCycleFailure> {
531        use super::store::StorePackageReclaimCoverage;
532
533        let result = match self.authorization.reclaim_packages(self.settled).await {
534            Ok(result) => result,
535            Err(error) => return Err(SyncCycleFailure::operation("reclaim Store packages", error)),
536        };
537        let store = &result.store_packages;
538        let coverage = match &store.coverage {
539            StorePackageReclaimCoverage::Snapshot { snapshot } => {
540                format!(
541                    "accepted snapshot {} at publication {}",
542                    snapshot.snapshot.snapshot_hash,
543                    snapshot.publication.position.get()
544                )
545            }
546            StorePackageReclaimCoverage::NoSnapshot => "no accepted Store snapshot".to_string(),
547            StorePackageReclaimCoverage::NotOwner => {
548                "this device is not the current owner".to_string()
549            }
550            StorePackageReclaimCoverage::InputsUnchanged => {
551                "inputs unchanged since the last evaluation".to_string()
552            }
553        };
554        info!(
555            %coverage,
556            considered = store.targets_considered,
557            retained_for_replay = store.retained_for_replay,
558            already_authorized = store.already_authorized,
559            authorized = store.authorized,
560            packages = result.packages_deleted,
561            copies = result.physical_copies_deleted,
562            stuck = result.stuck,
563            "Reclaimed snapshot-covered Store packages"
564        );
565        Ok(())
566    }
567}
568
569#[derive(Debug, thiserror::Error)]
570pub enum InitSyncError {
571    #[error("no synced tables configured; pass a non-empty synced-table set before sync starts")]
572    NoSyncedTables,
573    #[error("cloud cipher and blob path scheme describe different storage modes")]
574    IncoherentStorageRepresentation,
575    #[error("Store row routing initialization failed: {0}")]
576    RowRouting(coven_database::DbError),
577    #[error("Store initialization failed: {0}")]
578    Initialization(#[from] crate::sync::store::StoreInitializationError),
579    #[error("restoring the persisted pending rotation failed: {0}")]
580    PendingRotationRestore(#[source] coven_database::DbError),
581    #[error("prepared sync identity differs from its storage identity")]
582    StorageIdentityMismatch,
583    #[error("unlock requires an existing Store root")]
584    ExistingStoreRequired,
585}
586
587/// Establish the storage representation and signed owner anchor over an
588/// already-built [`CloudSyncConnection`], returning the only runnable sync session.
589#[derive(Debug, Clone)]
590pub enum StoreInitialization {
591    CreateStore,
592    OpenStore {
593        expected_store_root: coven_protocol::store_commit::StoreRootRef,
594    },
595}
596
597/// One connected Store representation used by an entire sync cycle.
598///
599/// Transport, at-rest protection, and pending key rotation come from one object
600/// so callers cannot assemble a cycle from unrelated storage sessions.
601pub(crate) trait CloudSyncCycleConnection:
602    CloudSyncObjectStorage + CloudSyncCipherStateAccess + CloudSyncRotationStateAccess
603{
604}
605
606impl CloudSyncCycleConnection for CloudSyncConnection {}
607
608/// A sync session whose local and cloud representation has been validated
609/// before Store creation or opening can perform protocol work.
610pub struct PreparedSyncComponents {
611    database: coven_database::StoreDatabase,
612    store_dir: StoreDir,
613    local_blob_access: super::store::blob::LocalStoreBlobAccess,
614    storage: std::sync::Arc<CloudSyncConnection>,
615    identity: coven_keys::keys::UserKeypair,
616    initialization: StoreInitialization,
617    store_id: String,
618    routing_encryption: Option<coven_keys::encryption::EncryptionService>,
619    master_keys: std::sync::Arc<dyn coven_keys::keys::MasterKeyCustody>,
620}
621
622impl PreparedSyncComponents {
623    pub async fn prepare(
624        database: coven_database::StoreDatabase,
625        store_dir: StoreDir,
626        storage: impl Into<std::sync::Arc<CloudSyncConnection>>,
627        identity: coven_keys::keys::UserKeypair,
628        initialization: StoreInitialization,
629        routing_encryption: Option<coven_keys::encryption::EncryptionService>,
630        master_keys: std::sync::Arc<dyn coven_keys::keys::MasterKeyCustody>,
631    ) -> Result<Self, InitSyncError> {
632        #[cfg(any(test, feature = "test-utils"))]
633        database.assert_owns_payload_directory_for_test(&store_dir);
634        let storage = storage.into();
635        if !storage.uses_identity(&identity) {
636            return Err(InitSyncError::StorageIdentityMismatch);
637        }
638        // Integration guard. The host declared its synced tables on the builder; an
639        // empty set means a synced store would attach nothing, every changeset would
640        // come out empty, and sync would silently become snapshot-only. Refuse loudly
641        // instead of pretending to sync.
642        if !database.has_synced_tables() {
643            return Err(InitSyncError::NoSyncedTables);
644        }
645        database
646            .validate_store_write_routing(routing_encryption.as_ref())
647            .map_err(InitSyncError::RowRouting)?;
648
649        let cipher_is_plaintext = storage.is_plaintext();
650        let representation_is_coherent = matches!(
651            (cipher_is_plaintext, storage.blob_path_scheme()),
652            (true, BlobPathScheme::Plain) | (false, BlobPathScheme::Hashed)
653        );
654        if !representation_is_coherent {
655            return Err(InitSyncError::IncoherentStorageRepresentation);
656        }
657
658        // Restore the durable marker before Store creation or opening performs
659        // protocol work, so malformed local rotation state cannot accompany new
660        // remote state from a failed initialization.
661        if !cipher_is_plaintext {
662            let gate = database
663                .load_rotation_gate()
664                .await
665                .map_err(InitSyncError::PendingRotationRestore)?;
666            storage.install_durable_gate(gate);
667        }
668
669        let store_id = storage.store_id().to_string();
670        let local_blob_access = super::store::blob::LocalStoreBlobAccess::new(
671            database.clone(),
672            store_dir.clone(),
673            super::store::blob::StoreBlobCache::new(database.clone(), store_dir.clone()),
674        );
675        Ok(Self {
676            database,
677            store_dir,
678            local_blob_access,
679            storage,
680            identity,
681            initialization,
682            store_id,
683            routing_encryption,
684            master_keys,
685        })
686    }
687
688    pub async fn initialize(
689        self,
690        observer: Option<std::sync::Arc<dyn BlobTransitionObserver>>,
691    ) -> Result<SyncComponents, InitSyncError> {
692        let storage: std::sync::Arc<dyn CloudSyncCycleConnection> = self.storage;
693        let store_storage: std::sync::Arc<dyn coven_storage::CloudSyncObjectStorage> =
694            storage.clone();
695        let initialized = match self.initialization {
696            StoreInitialization::CreateStore => {
697                Store::create(
698                    self.database.clone(),
699                    store_storage.clone(),
700                    self.store_dir.clone(),
701                    &self.database.stamp(),
702                    &self.identity,
703                    self.routing_encryption.clone(),
704                )
705                .await
706            }
707            StoreInitialization::OpenStore {
708                expected_store_root,
709            } => {
710                Store::open(
711                    self.database.clone(),
712                    store_storage.clone(),
713                    self.store_dir.clone(),
714                    &expected_store_root,
715                    &self.identity,
716                    self.routing_encryption.clone(),
717                )
718                .await
719            }
720        }
721        .map_err(InitSyncError::Initialization)?;
722
723        let (store, device_id) = initialized.into_parts();
724        let blob_access = std::sync::Arc::new(super::store::blob::RemoteStoreBlobAccess::new(
725            self.local_blob_access.clone(),
726            super::store::blob::CurrentRemoteBlobSource::current(
727                self.database.clone(),
728                store_storage,
729            ),
730        ));
731        let blob_transitions = crate::blob::transition::ConnectedBlobTransitions::new(
732            crate::blob::transition::LocalBlobTransitions::new(
733                self.database.clone(),
734                self.store_dir.clone(),
735            ),
736            blob_access.clone(),
737            self.routing_encryption.clone(),
738            observer,
739        );
740        info!("Sync initialized (device: {})", device_id);
741        Ok(SyncComponents {
742            store: std::sync::Arc::new(store),
743            database: self.database,
744            local_blob_access: self.local_blob_access,
745            storage,
746            store_id: self.store_id,
747            device_id,
748            routing_encryption: self.routing_encryption,
749            master_keys: self.master_keys,
750            blob_transitions,
751            blob_access,
752            eager_fill_wanted: std::sync::Arc::default(),
753            settled: std::sync::Arc::default(),
754        })
755    }
756
757    pub async fn verify_open_store_key(&self) -> Result<(), InitSyncError> {
758        let StoreInitialization::OpenStore {
759            expected_store_root,
760        } = &self.initialization
761        else {
762            return Err(InitSyncError::ExistingStoreRequired);
763        };
764        super::store::protocol_root::verify_store_key_confirmation(
765            &self.database,
766            self.storage.as_ref(),
767            expected_store_root,
768        )
769        .await
770        .map_err(crate::sync::store::StoreInitializationError::from)
771        .map_err(InitSyncError::Initialization)
772    }
773}
774
775/// Components needed to run sync cycles.
776///
777/// Owns the exact database, storage, register clock, device identity, at-rest
778/// cipher, pending-rotation marker, and signing identity that initialization
779/// checked. Callers cannot replace any of them before running a cycle.
780pub struct SyncComponents {
781    store: std::sync::Arc<Store>,
782    database: coven_database::StoreDatabase,
783    local_blob_access: super::store::blob::LocalStoreBlobAccess,
784    storage: std::sync::Arc<dyn CloudSyncCycleConnection>,
785    /// The store this sync loop is for. Binds the snapshot meta/pointer it
786    /// publishes so a member of two stores can't replay one's catalog as the
787    /// other's.
788    store_id: String,
789    device_id: String,
790    routing_encryption: Option<coven_keys::encryption::EncryptionService>,
791    master_keys: std::sync::Arc<dyn coven_keys::keys::MasterKeyCustody>,
792    blob_transitions: crate::blob::transition::ConnectedBlobTransitions,
793    blob_access: std::sync::Arc<super::store::blob::RemoteStoreBlobAccess>,
794    /// Raised when a cycle materializes rows, so the eager cache fill re-scans
795    /// for the artwork those rows bind. The pull downloads nothing, so this is
796    /// what carries an eager blob from an arriving row to local bytes — off the
797    /// cycle, which never waits for it.
798    eager_fill_wanted: std::sync::Arc<tokio::sync::Notify>,
799    /// What this loop's provider-side evaluations last ran against, so a cycle
800    /// over an unchanged store re-derives none of them. Lives here because it
801    /// is the only thing that outlives a cycle.
802    settled: std::sync::Arc<super::store::SettledCycle>,
803}
804
805impl SyncComponents {
806    pub(crate) async fn fill_eager_cache(
807        &self,
808        cancel: tokio::sync::watch::Receiver<bool>,
809        status: &tokio::sync::watch::Sender<super::store::blob::eager_cache::EagerCacheFillStatus>,
810    ) -> Result<(), std::sync::Arc<super::store::blob::eager_cache::EagerCacheFillError>> {
811        super::store::blob::eager_cache::run(
812            &self.database,
813            self.blob_access.as_ref(),
814            cancel,
815            status,
816        )
817        .await
818    }
819
820    /// Raised when a cycle materializes rows. A pull records what its rows bind
821    /// and downloads none of it, so this is what tells the eager cache fill to
822    /// scan again — the path an arriving album's artwork takes to local bytes.
823    pub(crate) fn eager_fill_wanted(&self) -> &tokio::sync::Notify {
824        &self.eager_fill_wanted
825    }
826
827    pub(crate) async fn probe_storage(&self) -> Result<(), coven_protocol::objects::StorageError> {
828        self.storage.probe_provider().await
829    }
830
831    async fn pending_blocked_writes(
832        &self,
833    ) -> Result<Vec<coven_protocol::write::PendingWrite>, coven_database::DbError> {
834        Ok(self
835            .database
836            .pending_writes()
837            .await?
838            .into_iter()
839            .filter(|write| {
840                matches!(
841                    write.status,
842                    coven_protocol::write::WriteStatus::Blocked(_)
843                        | coven_protocol::write::WriteStatus::LocalOnlyBlocked(_)
844                )
845            })
846            .collect())
847    }
848
849    /// Every durable operation a successful cycle leaves waiting on a person: a
850    /// write stopped by a semantic fault, a Circle operation whose authority or
851    /// stream position was lost, and a reclaim operation that failed with an
852    /// error running it again cannot change. All local reads.
853    pub(crate) async fn blocked_operations(
854        &self,
855    ) -> Result<Vec<super::sync_loop::BlockedOperation>, coven_database::DbError> {
856        use super::sync_loop::BlockedOperation;
857
858        let mut blocked: Vec<BlockedOperation> = self
859            .pending_blocked_writes()
860            .await?
861            .into_iter()
862            .map(BlockedOperation::Write)
863            .collect();
864        blocked.extend(
865            self.database
866                .get_circle_operations()
867                .await?
868                .into_iter()
869                .filter(|operation| {
870                    matches!(
871                        operation.state,
872                        coven_protocol::circle::CircleOperationState::Blocked { .. }
873                    )
874                })
875                .map(BlockedOperation::CircleOperation),
876        );
877        blocked.extend(
878            self.database
879                .stuck_reclaim_operations()
880                .await?
881                .into_iter()
882                .map(BlockedOperation::Reclaim),
883        );
884        Ok(blocked)
885    }
886
887    /// Clear one reclaim operation's stuck mark. Refused when the operation is
888    /// not stuck, so a stale retry cannot pass as a fresh decision.
889    pub(crate) async fn retry_stuck_reclaim(
890        &self,
891        operation_id: coven_protocol::store_commit::ObjectHash,
892    ) -> Result<(), coven_database::DbError> {
893        self.database
894            .retry_stuck_reclaim_operation(operation_id)
895            .await
896    }
897
898    pub(crate) async fn discard_blocked_write(
899        &self,
900        write_id: coven_protocol::write::WriteId,
901    ) -> Result<Vec<coven_protocol::write::WriteId>, super::store::StoreError> {
902        self.store
903            .discard_blocked_write(write_id, self.routing_encryption.as_ref())
904            .await
905    }
906
907    pub(crate) async fn members(
908        &self,
909    ) -> Result<Vec<coven_protocol::membership::MemberInfo>, super::store::MembershipOpsError> {
910        self.store.members().await
911    }
912
913    pub(crate) async fn restore_membership(
914        &self,
915    ) -> Result<super::store::authorization::StoreRestoreMembership, super::store::MembershipOpsError>
916    {
917        self.store.restore_membership().await
918    }
919
920    pub(crate) fn host_write_blob_staging(
921        &self,
922        runtime: tokio::runtime::Handle,
923    ) -> super::store::HostWriteBlobStaging {
924        self.store.host_write_blob_staging(runtime)
925    }
926
927    pub(crate) async fn propose_device_exclusion(
928        &self,
929        device_id: coven_protocol::StoreDeviceId,
930    ) -> Result<
931        coven_protocol::store_commit::StoreDeviceExclusionProposalRef,
932        super::store::StoreDeviceExclusionError,
933    > {
934        self.store
935            .propose_device_exclusion_for_device(device_id)
936            .await
937    }
938
939    pub(crate) async fn cancel_device_exclusion(
940        &self,
941        proposal: &coven_protocol::store_commit::StoreDeviceExclusionProposalRef,
942    ) -> Result<(), super::store::StoreDeviceExclusionError> {
943        self.store.cancel_device_exclusion_proposal(proposal).await
944    }
945
946    pub(crate) async fn finalize_device_exclusion(
947        &self,
948        proposal: &coven_protocol::store_commit::StoreDeviceExclusionProposalRef,
949    ) -> Result<(), super::store::StoreDeviceExclusionError> {
950        self.store
951            .finalize_device_exclusion_proposal(proposal)
952            .await
953    }
954
955    pub(crate) async fn begin_owner_promotion(
956        &self,
957        device_id: coven_protocol::StoreDeviceId,
958    ) -> Result<
959        coven_protocol::store_commit::OwnerPromotionRequest,
960        super::store::OwnerPromotionError,
961    > {
962        self.store.begin_owner_promotion_for_device(device_id).await
963    }
964
965    pub(crate) async fn accept_owner_promotion(
966        &self,
967        request: coven_protocol::store_commit::OwnerPromotionRequest,
968    ) -> Result<
969        coven_protocol::store_commit::OwnerPromotionAcceptance,
970        super::store::OwnerPromotionError,
971    > {
972        self.store.accept_owner_promotion(request).await
973    }
974
975    pub(crate) async fn finalize_owner_promotion(
976        &self,
977        acceptance: coven_protocol::store_commit::OwnerPromotionAcceptance,
978    ) -> Result<(), super::store::OwnerPromotionError> {
979        let encryption = self
980            .routing_encryption
981            .as_ref()
982            .ok_or(super::store::OwnerPromotionError::EncryptionRequired)?;
983        self.store
984            .finalize_owner_promotion(encryption, acceptance)
985            .await
986            .map(|_| ())
987    }
988
989    pub(crate) async fn begin_device_join_bundle(
990        &self,
991        member_pubkey: &str,
992    ) -> Result<crate::sync::DeviceJoinOfferBundle, super::store::DeviceJoinTransportError> {
993        self.store.begin_device_join_bundle(member_pubkey).await
994    }
995
996    pub(crate) async fn drive_device_join(
997        &self,
998        bundle: &crate::sync::DeviceJoinOfferBundle,
999        policy: crate::sync::DeviceJoinApprovalPolicy<'_>,
1000        access_administrator: Option<&dyn crate::sync::DeviceProviderAccessAdministrator>,
1001        on_progress: &(dyn Fn(crate::sync::AdmittingDeviceJoinProgress) + Send + Sync),
1002        timing: crate::sync::DeviceJoinTransportTiming,
1003    ) -> Result<crate::sync::DeviceJoinDriveOutcome, super::store::DeviceJoinTransportError> {
1004        self.store
1005            .device_join_transport()
1006            .drive(bundle, policy, access_administrator, on_progress, timing)
1007            .await
1008    }
1009
1010    pub(crate) async fn abandon_device_join_transport(
1011        &self,
1012        bundle: &crate::sync::DeviceJoinOfferBundle,
1013    ) -> Result<crate::sync::DeviceJoinAbandonment, super::store::DeviceJoinTransportError> {
1014        self.store.device_join_transport().abandon(bundle).await
1015    }
1016
1017    pub(crate) async fn abort_device_join_transport(
1018        &self,
1019        bundle: &crate::sync::DeviceJoinOfferBundle,
1020    ) -> Result<(), super::store::DeviceJoinTransportError> {
1021        self.store.device_join_transport().abort(bundle).await
1022    }
1023
1024    pub(crate) async fn begin_device_join(
1025        &self,
1026        member_pubkey: &str,
1027    ) -> Result<crate::sync::DeviceJoinOffer, crate::sync::DeviceJoinError> {
1028        self.store.begin_device_join(member_pubkey).await
1029    }
1030
1031    pub(crate) async fn abandon_device_join(
1032        &self,
1033        offer: crate::sync::DeviceJoinOffer,
1034    ) -> Result<crate::sync::DeviceJoinAbandonment, crate::sync::DeviceJoinError> {
1035        self.store.abandon_device_join(offer).await
1036    }
1037
1038    pub(crate) async fn authorize_device_provider_access(
1039        &self,
1040        request: crate::sync::DeviceProviderAccessRequest,
1041        access_administrator: Option<&dyn crate::sync::DeviceProviderAccessAdministrator>,
1042    ) -> Result<crate::sync::DeviceProviderAdmissionApproval, crate::sync::DeviceJoinError> {
1043        self.store
1044            .authorize_device_provider_access(request, access_administrator)
1045            .await
1046    }
1047
1048    pub(crate) async fn accept_device_registration(
1049        &self,
1050        request: crate::sync::DeviceRegistrationRequest,
1051    ) -> Result<crate::sync::ProvisionalDeviceBootstrap, crate::sync::DeviceJoinError> {
1052        self.store.accept_device_registration_request(request).await
1053    }
1054
1055    pub(crate) async fn publish_device_provider_challenge(
1056        &self,
1057        bootstrap: crate::sync::ProvisionalDeviceBootstrap,
1058    ) -> Result<crate::sync::ProviderReadyDeviceBootstrap, crate::sync::DeviceJoinError> {
1059        self.store
1060            .publish_device_provider_challenge(bootstrap)
1061            .await
1062    }
1063
1064    pub(crate) async fn complete_device_provider_admission(
1065        &self,
1066        readiness: crate::sync::DeviceJoinReadiness,
1067    ) -> Result<crate::sync::DeviceProviderAdmissionCompletion, crate::sync::DeviceJoinError> {
1068        self.store
1069            .complete_device_provider_admission(readiness)
1070            .await
1071    }
1072
1073    pub(crate) async fn finalize_device_join(
1074        &self,
1075        completion: crate::sync::DeviceProviderAdmissionCompletion,
1076    ) -> Result<crate::sync::DeviceJoinActivation, crate::sync::DeviceJoinError> {
1077        self.store.finalize_device_join(completion).await
1078    }
1079
1080    pub(crate) fn blob_path_scheme(&self) -> BlobPathScheme {
1081        self.store.blob_path_scheme()
1082    }
1083
1084    pub(crate) fn is_encrypted(&self) -> bool {
1085        !self.storage.is_plaintext()
1086    }
1087
1088    pub(crate) async fn drain_uploads(
1089        &self,
1090        clock: &dyn coven_foundation::clock::Clock,
1091        observer: Option<&dyn BlobTransitionObserver>,
1092    ) -> Result<crate::blob::DrainOutcome, crate::sync::store::StoreError> {
1093        self.store
1094            .authorize_writer()
1095            .await
1096            .map_err(crate::sync::store::StoreError::from)?
1097            .drain_uploads(clock, self.routing_encryption.as_ref(), observer)
1098            .await
1099            .map_err(crate::sync::store::StoreError::from)
1100    }
1101
1102    pub(crate) async fn make_remote(
1103        &self,
1104        root_table: &str,
1105        root_id: &str,
1106        root_label: &str,
1107        pin: bool,
1108        refs: Vec<coven_protocol::blob::RowBlobRef>,
1109    ) -> Result<(), crate::blob::transition::MakeRemoteError> {
1110        self.blob_transitions
1111            .make_remote(root_table, root_id, root_label, pin, refs)
1112            .await
1113    }
1114
1115    pub(crate) async fn make_remote_batch(
1116        &self,
1117        root_table: &str,
1118        roots: Vec<crate::blob::MakeRemoteRoot>,
1119        pin: bool,
1120    ) -> Result<(), crate::blob::transition::MakeRemoteError> {
1121        self.blob_transitions
1122            .make_remote_batch(root_table, roots, pin)
1123            .await
1124    }
1125
1126    pub(crate) async fn cancel_make_remote(
1127        &self,
1128        root_table: &str,
1129        root_id: &str,
1130    ) -> Result<(), crate::blob::transition::MakeRemoteError> {
1131        self.blob_transitions
1132            .cancel_make_remote(root_table, root_id)
1133            .await
1134    }
1135
1136    pub(crate) async fn make_local(
1137        &self,
1138        root_table: &str,
1139        root_id: &str,
1140        dest: &std::collections::HashMap<String, std::path::PathBuf>,
1141        cancel: &tokio::sync::watch::Receiver<bool>,
1142    ) -> Result<(), crate::blob::transition::MakeLocalError> {
1143        self.blob_transitions
1144            .make_local(root_table, root_id, dest, cancel)
1145            .await
1146    }
1147
1148    pub(crate) async fn admit_member(
1149        &self,
1150        public_key_hex: &str,
1151        member_email: Option<&str>,
1152        role: coven_protocol::membership::MemberRole,
1153        store_name: &str,
1154    ) -> Result<crate::sync::store::MemberAdmission, super::store::MembershipOpsError> {
1155        let encryption = self
1156            .routing_encryption
1157            .as_ref()
1158            .ok_or(super::store::MembershipOpsError::NotEncryptedHome)?;
1159        self.store
1160            .admit_member(
1161                public_key_hex,
1162                member_email,
1163                role,
1164                encryption,
1165                &self.store_id,
1166                store_name,
1167            )
1168            .await
1169    }
1170
1171    pub(crate) async fn remove_member(
1172        &self,
1173        public_key_hex: &str,
1174    ) -> Result<String, super::store::MembershipOpsError> {
1175        let encryption = self
1176            .routing_encryption
1177            .as_ref()
1178            .ok_or(super::store::MembershipOpsError::NotEncryptedHome)?;
1179        self.store
1180            .remove_member(
1181                public_key_hex,
1182                encryption,
1183                self.master_keys.as_ref(),
1184                self.storage.as_ref(),
1185                self.storage.as_ref(),
1186            )
1187            .await
1188    }
1189
1190    pub(crate) async fn create_circle(
1191        &self,
1192        name: &str,
1193    ) -> Result<coven_protocol::circle::CircleId, super::store::CircleOperationError> {
1194        self.store
1195            .circles()
1196            .create_circle(&self.database.stamp(), name)
1197            .await
1198    }
1199
1200    pub(crate) async fn rename_circle(
1201        &self,
1202        circle_id: coven_protocol::circle::CircleId,
1203        name: &str,
1204    ) -> Result<(), super::store::CircleOperationError> {
1205        self.store
1206            .circles()
1207            .rename_circle(&self.database.stamp(), circle_id, name)
1208            .await
1209    }
1210
1211    pub(crate) async fn resolve_circle_control(
1212        &self,
1213        circle_id: coven_protocol::circle::CircleId,
1214        chosen: coven_protocol::circle::CircleControlCoord,
1215    ) -> Result<(), super::store::CircleOperationError> {
1216        self.store
1217            .circles()
1218            .resolve_circle_control(circle_id, chosen)
1219            .await
1220    }
1221
1222    pub(crate) async fn delete_circle(
1223        &self,
1224        circle_id: coven_protocol::circle::CircleId,
1225    ) -> Result<(), super::store::CircleOperationError> {
1226        self.store.circles().delete_circle(circle_id).await
1227    }
1228
1229    pub(crate) async fn add_circle_member(
1230        &self,
1231        circle_id: coven_protocol::circle::CircleId,
1232        member_pubkey: String,
1233        role: coven_protocol::circle::CircleRole,
1234    ) -> Result<(), super::store::CircleOperationError> {
1235        use super::store::CircleOperationError;
1236        // A member addition captures a bootstrap over the scoped routing graph, so
1237        // an unscoped (browsable) Store cannot author one — the same refusal
1238        // `Store::add_circle_member` raises, surfaced here before the setup work.
1239        let routing_encryption = self
1240            .routing_encryption
1241            .as_ref()
1242            .ok_or(CircleOperationError::BrowsableStorage)?;
1243        let mut authorization = self
1244            .store
1245            .authorize_writer()
1246            .await
1247            .map_err(CircleOperationError::from)?;
1248        authorization
1249            .publish_pending_store_writes(Some(routing_encryption))
1250            .await
1251            .map_err(CircleOperationError::from)?;
1252        let bootstrap = authorization
1253            .circles()
1254            .snapshots()
1255            .capture_circle_snapshot_cut(routing_encryption, circle_id)
1256            .await?;
1257        let routing_key = coven_protocol::circle::derive_row_routing_key(
1258            routing_encryption,
1259            self.store.store_root().store_root_hash,
1260        )
1261        .map_err(CircleOperationError::from)?;
1262        authorization
1263            .circles()
1264            .add_circle_member(circle_id, member_pubkey, role, bootstrap, &routing_key)
1265            .await
1266    }
1267
1268    pub(crate) async fn remove_circle_member(
1269        &self,
1270        circle_id: coven_protocol::circle::CircleId,
1271        member_pubkey: String,
1272    ) -> Result<coven_protocol::circle::CircleOperationId, super::store::CircleOperationError> {
1273        self.store
1274            .circles()
1275            .remove_circle_member(circle_id, member_pubkey)
1276            .await
1277    }
1278
1279    pub(crate) async fn cancel_circle_epoch_close(
1280        &self,
1281        circle_id: coven_protocol::circle::CircleId,
1282    ) -> Result<coven_protocol::circle::CircleOperationId, super::store::CircleOperationError> {
1283        self.store
1284            .circles()
1285            .cancel_circle_epoch_close(circle_id)
1286            .await
1287    }
1288
1289    pub(crate) async fn exclude_circle_close_device(
1290        &self,
1291        circle_id: coven_protocol::circle::CircleId,
1292        excluded_device_id: coven_protocol::store_commit::StoreDeviceId,
1293    ) -> Result<(), super::store::CircleOperationError> {
1294        self.store
1295            .circles()
1296            .exclude_circle_close_device(circle_id, excluded_device_id)
1297            .await
1298    }
1299
1300    pub(crate) async fn retry_circle_operation(
1301        &self,
1302        operation_id: &coven_protocol::circle::CircleOperationId,
1303    ) -> Result<(), super::store::CircleOperationError> {
1304        self.store
1305            .circles()
1306            .retry_circle_operation(operation_id, self.routing_encryption.as_ref())
1307            .await
1308    }
1309
1310    pub(crate) async fn discard_circle_operation(
1311        &self,
1312        operation_id: &coven_protocol::circle::CircleOperationId,
1313    ) -> Result<(), super::store::CircleOperationError> {
1314        self.store
1315            .circles()
1316            .discard_circle_operation(operation_id)
1317            .await
1318    }
1319
1320    pub(crate) async fn circle_close_status(
1321        &self,
1322        circle_id: coven_protocol::circle::CircleId,
1323    ) -> Result<coven_protocol::circle::CircleCloseStatus, super::store::CircleOperationError> {
1324        self.store.circles().circle_close_status(circle_id).await
1325    }
1326
1327    /// The provider-operation counter of the home this loop works through, so
1328    /// a run over it can report each stage's count beside its wall time.
1329    pub(crate) fn provider_requests(
1330        &self,
1331    ) -> Option<std::sync::Arc<dyn coven_foundation::stage_timing::ProviderRequests>> {
1332        self.storage.provider_requests()
1333    }
1334
1335    pub async fn run_cycle(
1336        &self,
1337        clock: &dyn coven_foundation::clock::Clock,
1338        observer: Option<&dyn BlobTransitionObserver>,
1339        snapshot_commit_threshold: std::num::NonZeroU64,
1340    ) -> Result<SyncCycleResult, SyncCycleFailure> {
1341        let authorization =
1342            self.store.authorize_writer().await.map_err(|error| {
1343                SyncCycleFailure::operation("authorize local Store writer", error)
1344            })?;
1345        AuthorizedSyncCycle {
1346            device_id: &self.device_id,
1347            snapshot_commit_threshold,
1348            clock,
1349            cipher: self.storage.as_ref(),
1350            pending_rotation: self.storage.as_ref(),
1351            master_keys: Some(self.master_keys.as_ref()),
1352            routing_encryption: self.routing_encryption.as_ref(),
1353            local_blob_access: &self.local_blob_access,
1354            observer,
1355            settled: self.settled.as_ref(),
1356            authorization,
1357        }
1358        .run()
1359        .await
1360        .inspect(|result| {
1361            if result.changesets_applied > 0 {
1362                self.eager_fill_wanted.notify_one();
1363            }
1364        })
1365    }
1366
1367    #[cfg(any(test, feature = "test-utils"))]
1368    #[allow(clippy::too_many_arguments)]
1369    pub(crate) fn from_retained_test_device<S>(
1370        store: std::sync::Arc<Store>,
1371        database: coven_database::StoreDatabase,
1372        store_dir: StoreDir,
1373        storage: std::sync::Arc<S>,
1374        store_id: String,
1375        device_id: String,
1376        master_keys: std::sync::Arc<dyn coven_keys::keys::MasterKeyCustody>,
1377        // Carried in rather than defaulted: a sync loop keeps one of these for
1378        // its whole life, so a fixture that built a fresh one per cycle would
1379        // measure a device that forgets everything between cycles — which is
1380        // the opposite of what the memo is for.
1381        settled: std::sync::Arc<super::store::SettledCycle>,
1382    ) -> Self
1383    where
1384        S: CloudSyncCycleConnection + 'static,
1385    {
1386        database.assert_owns_payload_directory_for_test(&store_dir);
1387        let storage: std::sync::Arc<dyn CloudSyncCycleConnection> = storage;
1388        let store_storage: std::sync::Arc<dyn coven_storage::CloudSyncObjectStorage> =
1389            storage.clone();
1390        let local_blob_access = super::store::blob::LocalStoreBlobAccess::new(
1391            database.clone(),
1392            store_dir.clone(),
1393            super::store::blob::StoreBlobCache::new(database.clone(), store_dir.clone()),
1394        );
1395        let blob_access = std::sync::Arc::new(super::store::blob::RemoteStoreBlobAccess::new(
1396            local_blob_access.clone(),
1397            super::store::blob::CurrentRemoteBlobSource::current(database.clone(), store_storage),
1398        ));
1399        let blob_transitions = crate::blob::transition::ConnectedBlobTransitions::new(
1400            crate::blob::transition::LocalBlobTransitions::new(database.clone(), store_dir.clone()),
1401            blob_access.clone(),
1402            None,
1403            None,
1404        );
1405        Self {
1406            store,
1407            database,
1408            local_blob_access,
1409            store_id,
1410            storage,
1411            device_id,
1412            routing_encryption: None,
1413            master_keys,
1414            blob_transitions,
1415            blob_access,
1416            eager_fill_wanted: std::sync::Arc::default(),
1417            settled,
1418        }
1419    }
1420
1421    #[cfg(any(test, feature = "test-utils"))]
1422    pub async fn list_storage_objects_for_test(
1423        &self,
1424        prefix: &str,
1425    ) -> Result<Vec<String>, coven_protocol::objects::StorageError> {
1426        self.storage.list_provider_keys_for_test(prefix).await
1427    }
1428
1429    #[cfg(any(test, feature = "test-utils"))]
1430    pub fn uses_storage_for_test(
1431        &self,
1432        expected: &std::sync::Arc<dyn coven_storage::CloudSyncObjectStorage>,
1433    ) -> bool {
1434        let actual: std::sync::Arc<dyn coven_storage::CloudSyncObjectStorage> =
1435            self.storage.clone();
1436        std::sync::Arc::ptr_eq(&actual, expected)
1437    }
1438
1439    #[cfg(any(test, feature = "test-utils"))]
1440    pub fn uses_store_dir_for_test(&self, expected: &StoreDir) -> bool {
1441        self.local_blob_access.uses_store_dir_for_test(expected)
1442    }
1443
1444    #[cfg(any(test, feature = "test-utils"))]
1445    pub fn encryption_generation_for_test(&self) -> Option<u64> {
1446        self.storage.current_generation()
1447    }
1448
1449    #[cfg(any(test, feature = "test-utils"))]
1450    pub fn open_sealed_blob_for_test(
1451        &self,
1452        stored: &[u8],
1453        aad_context: &[u8],
1454    ) -> Result<
1455        (coven_keys::encryption::KeyFingerprint, Vec<u8>),
1456        coven_keys::encryption::EncryptionError,
1457    > {
1458        self.storage.open_sealed_blob_for_test(stored, aad_context)
1459    }
1460
1461    #[cfg(any(test, feature = "test-utils"))]
1462    pub fn adopt_key_rotation(
1463        &self,
1464        encryption: coven_keys::encryption::EncryptionService,
1465    ) -> Result<String, coven_keys::keys::KeyError> {
1466        CloudSyncCipherStateAccess::adopt_key_rotation(
1467            self.storage.as_ref(),
1468            &encryption,
1469            self.master_keys.as_ref(),
1470        )
1471        .map(|adopted| adopted.fingerprint().to_string())
1472    }
1473}