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