Skip to main content

coven_replication/blob/
transition.rs

1//! coven-owned locality transitions: `make_remote` (Local → Remote) and
2//! `make_local` (Remote → Local), plus `cancel_make_remote`.
3//!
4//! A blob-bearing root is **Local** (its blobs live on-device — a user-provided
5//! blob at the user's path, a host-provided blob in coven's local store — and the
6//! root's gate is off) or **Remote** (its blobs live in the cloud fronted by
7//! coven's cache, and the gate is on). coven owns moving between the two, with the
8//! durable-copy-before-delete ordering and a single atomic commit point each way:
9//!
10//! - `LocalBlobTransitions::make_remote` journals every blob-bearing row beneath
11//!   the root, then returns. The upload drain verifies each source while sealing
12//!   it into its upload spool and creates each exact cloud object. Once every row
13//!   has a created object, one Store write
14//!   flips the gate true and drops external-file ownership. The intent and exact
15//!   upload journals remain authoritative until that Store write activates, when
16//!   activation consumes them atomically. Before publication starts,
17//!   `LocalBlobTransitions::cancel_make_remote` marks the intent Cancelling;
18//!   the upload drain
19//!   exact-deletes every object and spool before atomically removing the last
20//!   journal and intent, so the root remains Local.
21//! - `ConnectedBlobTransitions::make_local` brings each blob back to a local
22//!   file durability-first
23//!   (read from cache/cloud → write the local copy → verify): a **user-provided**
24//!   blob to its `dest` path (path required) registered as an external ref, a
25//!   **host-provided** blob to coven's local store (no path). Then it takes the
26//!   single commit `{flip the gate false + register the external refs + enqueue the
27//!   cloud deletes}`. The gate retract removes the subtree from peers; the tombstone
28//!   drain reclaims the cloud blobs after the grace.
29//!
30//! Both transitions operate on every exact blob-bearing row under the root and
31//! branch on provenance only to choose its Local filesystem home.
32//!
33//! A [`SyncedTable::remote_root`](coven_protocol::synced_schema::SyncedTable::remote_root)
34//! has no Local state in this model: its rows sync normally and its blobs are
35//! Remote by construction, so these transition APIs reject it.
36//!
37//! Every destructive step is enqueued durably *inside* the one commit, and nothing
38//! destructive happens before it, so there is no representable half-state and retry
39//! after a crash is idempotent.
40//!
41//! See the [blob concept tree](crate::blob) for how Local/Remote, provenance, and
42//! the cache fit together.
43
44use std::collections::HashMap;
45
46use coven_database::DbError;
47use coven_database::StoreDatabase;
48use coven_foundation::store_dir::StoreDir;
49use coven_protocol::blob::{Provenance, RowBlobRef};
50
51use coven_protocol::blob::BlobTransitionObserver;
52use std::path::PathBuf;
53use std::sync::Arc;
54use tokio::sync::watch;
55
56/// Why a make_remote (or its cancel) could not be started.
57#[derive(Debug, thiserror::Error)]
58pub enum MakeRemoteError {
59    #[error("a make_remote batch must contain at least one root")]
60    EmptyBatch,
61    #[error("root {0:?} appears more than once in the make_remote batch")]
62    DuplicateRoot(String),
63    #[error("sync is not running, so a transition cannot start")]
64    SyncNotReady,
65    #[error("table {0:?} is not a gated root, so it has no Local/Remote state")]
66    NotGated(String),
67    #[error("table {0:?} is a remote root, so its blobs are already Remote")]
68    RemoteRoot(String),
69    #[error("root {0:?}/{1:?} is already Remote, so make_remote has nothing to do")]
70    AlreadyRemote(String, String),
71    #[error("root {0:?}/{1:?} has no resolvable Local/Remote state (row absent or gate NULL)")]
72    UnresolvedLocality(String, String),
73    #[error("nothing to make Remote: root {0:?}/{1:?} has no blobs")]
74    NothingToMakeRemote(String, String),
75    #[error("blob {0:?} is not a user-provided (external) file, so it cannot be made Remote")]
76    NotExternal(String),
77    #[error("external source path for blob {blob_id:?} at {path}: {source}")]
78    SourcePath {
79        blob_id: String,
80        path: String,
81        #[source]
82        source: coven_foundation::store_dir::PathTokenError,
83    },
84    #[error("database error: {0}")]
85    Db(#[from] DbError),
86}
87
88#[derive(Clone)]
89pub struct MakeRemoteRoot {
90    pub id: String,
91    pub label: String,
92    pub refs: Vec<RowBlobRef>,
93}
94
95/// Why a make_local could not complete.
96#[derive(Debug, thiserror::Error)]
97pub enum MakeLocalError {
98    #[error("sync is not running, so a transition cannot start")]
99    SyncNotReady,
100    #[error("table {0:?} is not a gated root, so it has no Local/Remote state")]
101    NotGated(String),
102    #[error("table {0:?} is a remote root, so its blobs have no Local state")]
103    RemoteRoot(String),
104    #[error("root {0:?}/{1:?} is already Local, so make_local has nothing to do")]
105    AlreadyLocal(String, String),
106    #[error("root {0:?}/{1:?} has no resolvable Local/Remote state (row absent or gate NULL)")]
107    UnresolvedLocality(String, String),
108    #[error("no destination path supplied for user-provided blob {0:?}")]
109    MissingDest(String),
110    #[error("destination path for user-provided blob {blob_id:?} is not valid UTF-8: {path}")]
111    NonUtf8Dest { blob_id: String, path: String },
112    #[error("remote row for blob {0:?} has no exact stored reference")]
113    MissingStoredReference(String),
114    #[error("read blob {blob_id:?} to materialize: {source}")]
115    Read {
116        blob_id: String,
117        #[source]
118        source: crate::sync::BlobCacheError,
119    },
120    #[error("materialized blob path for {blob_id:?} at {path}: {source}")]
121    WritePath {
122        blob_id: String,
123        path: String,
124        #[source]
125        source: coven_foundation::store_dir::PathTokenError,
126    },
127    #[error("write materialized blob {blob_id:?} to {path}: {source}")]
128    WriteFile {
129        blob_id: String,
130        path: String,
131        #[source]
132        source: ExactPlaintextFileError,
133    },
134    #[error("publish materialized blob {blob_id:?} to {path}: {source}")]
135    CommitFile {
136        blob_id: String,
137        path: String,
138        #[source]
139        source: coven_foundation::local_file::CommitNewFileError,
140    },
141    #[error("make_local cancelled before the commit; the release stays Remote")]
142    Cancelled,
143    #[error("{operation}; materialized-file rollback failed: {failures}")]
144    Cleanup {
145        operation: Box<MakeLocalError>,
146        failures: MaterializedFileCleanupFailures,
147    },
148    #[error("database error: {0}")]
149    Db(#[from] DbError),
150}
151
152struct ExactPlaintextFile {
153    path: PathBuf,
154    expected_size: u64,
155    expected_hash: coven_protocol::store_commit::ObjectHash,
156}
157
158#[derive(Debug, thiserror::Error)]
159pub enum ExactPlaintextFileError {
160    #[error("{operation} {}: {source}", path.display())]
161    Io {
162        operation: &'static str,
163        path: PathBuf,
164        #[source]
165        source: std::io::Error,
166    },
167    #[error("blob path has no parent: {}", path.display())]
168    NoParent { path: PathBuf },
169    #[error("file size overflow: {}", path.display())]
170    SizeOverflow { path: PathBuf },
171    #[error("plaintext facts {actual_size}/{actual_hash} differ from expected facts {expected_size}/{expected_hash}")]
172    FactsMismatch {
173        actual_size: u64,
174        actual_hash: coven_protocol::store_commit::ObjectHash,
175        expected_size: u64,
176        expected_hash: coven_protocol::store_commit::ObjectHash,
177    },
178    #[error("destination {} has {actual} bytes, expected {expected}", path.display())]
179    SizeMismatch {
180        path: PathBuf,
181        actual: u64,
182        expected: u64,
183    },
184}
185
186#[derive(Debug)]
187pub struct MaterializedFileCleanupFailure {
188    path: PathBuf,
189    source: ExactPlaintextFileError,
190}
191
192#[derive(Debug)]
193pub struct MaterializedFileCleanupFailures(Vec<MaterializedFileCleanupFailure>);
194
195impl std::fmt::Display for MaterializedFileCleanupFailures {
196    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        for (index, failure) in self.0.iter().enumerate() {
198            if index > 0 {
199                formatter.write_str("; ")?;
200            }
201            write!(formatter, "{}: {}", failure.path.display(), failure.source)?;
202        }
203        Ok(())
204    }
205}
206
207impl std::error::Error for MaterializedFileCleanupFailures {
208    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
209        self.0.first().map(|failure| &failure.source as _)
210    }
211}
212
213impl ExactPlaintextFile {
214    fn new(
215        path: PathBuf,
216        expected_size: u64,
217        expected_hash: coven_protocol::store_commit::ObjectHash,
218    ) -> Self {
219        Self {
220            path,
221            expected_size,
222            expected_hash,
223        }
224    }
225
226    fn path(&self) -> &std::path::Path {
227        &self.path
228    }
229
230    async fn verify(&self) -> Result<(), ExactPlaintextFileError> {
231        use sha2::{Digest, Sha256};
232        use tokio::io::AsyncReadExt;
233
234        let mut file = tokio::fs::File::open(&self.path).await.map_err(|source| {
235            ExactPlaintextFileError::Io {
236                operation: "open",
237                path: self.path.clone(),
238                source,
239            }
240        })?;
241        let mut size = 0_u64;
242        let mut hasher = Sha256::new();
243        let mut buffer = vec![0_u8; 1 << 20];
244        loop {
245            let read =
246                file.read(&mut buffer)
247                    .await
248                    .map_err(|source| ExactPlaintextFileError::Io {
249                        operation: "read",
250                        path: self.path.clone(),
251                        source,
252                    })?;
253            if read == 0 {
254                break;
255            }
256            size = size.checked_add(read as u64).ok_or_else(|| {
257                ExactPlaintextFileError::SizeOverflow {
258                    path: self.path.clone(),
259                }
260            })?;
261            hasher.update(&buffer[..read]);
262        }
263        let hash = coven_protocol::store_commit::ObjectHash::from_digest(hasher.finalize().into());
264        if size != self.expected_size || hash != self.expected_hash {
265            return Err(ExactPlaintextFileError::FactsMismatch {
266                actual_size: size,
267                actual_hash: hash,
268                expected_size: self.expected_size,
269                expected_hash: self.expected_hash,
270            });
271        }
272        Ok(())
273    }
274
275    async fn ensure_parent(&self) -> Result<(), ExactPlaintextFileError> {
276        let parent = self
277            .path
278            .parent()
279            .ok_or_else(|| ExactPlaintextFileError::NoParent {
280                path: self.path.clone(),
281            })?;
282        tokio::fs::create_dir_all(parent)
283            .await
284            .map_err(|source| ExactPlaintextFileError::Io {
285                operation: "create parent",
286                path: parent.to_path_buf(),
287                source,
288            })
289    }
290
291    async fn verify_installed_size(&self) -> Result<(), ExactPlaintextFileError> {
292        let length = tokio::fs::metadata(&self.path)
293            .await
294            .map_err(|source| ExactPlaintextFileError::Io {
295                operation: "stat",
296                path: self.path.clone(),
297                source,
298            })?
299            .len();
300        if length != self.expected_size {
301            return Err(ExactPlaintextFileError::SizeMismatch {
302                path: self.path.clone(),
303                actual: length,
304                expected: self.expected_size,
305            });
306        }
307        Ok(())
308    }
309
310    async fn remove(&self) -> Result<(), ExactPlaintextFileError> {
311        match tokio::fs::remove_file(&self.path).await {
312            Ok(()) => Ok(()),
313            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
314            Err(source) => Err(ExactPlaintextFileError::Io {
315                operation: "remove",
316                path: self.path.clone(),
317                source,
318            }),
319        }
320    }
321}
322
323#[derive(Clone)]
324pub struct LocalBlobTransitions {
325    database: StoreDatabase,
326    store_dir: StoreDir,
327}
328
329impl LocalBlobTransitions {
330    pub fn new(database: StoreDatabase, store_dir: StoreDir) -> Self {
331        Self {
332            database,
333            store_dir,
334        }
335    }
336
337    /// Start making `(root_table, root_id)` Remote: refuse a root already Remote,
338    /// enqueue an upload per supplied blob in order, and record the make_remote
339    /// intent in one transaction. Returns once enqueued — the sync cycle verifies
340    /// each source's exact size/hash while sealing its spool, uploads every valid
341    /// blob, prepares the gate change only after they all land, and publishes that
342    /// change before the durable intent completes. The caller triggers a sync cycle
343    /// to start that work.
344    ///
345    /// The supplied rows must be exactly the root's current blob set. A missing or
346    /// changed source is a durable preparation failure on that root: its Local gate
347    /// stays closed, its successful siblings remain reusable for retry, and other
348    /// roots continue independently. `pin` becomes each upload's `retain_pinned`,
349    /// so the blob is kept in coven's cache as a pinned (offline) copy.
350    pub(crate) async fn make_remote(
351        &self,
352        root_table: &str,
353        root_id: &str,
354        root_label: &str,
355        pin: bool,
356        refs: Vec<coven_protocol::blob::RowBlobRef>,
357    ) -> Result<(), MakeRemoteError> {
358        require_make_remote_root(&self.database, root_table)?;
359        let prepared = self
360            .prepare_make_remote(root_table, root_id, root_label, refs)
361            .await?;
362        let locality = self
363            .database
364            .begin_make_remote(
365                root_table,
366                &prepared.root_id,
367                &prepared.root_label,
368                pin,
369                self.database.stamp(),
370                prepared.uploads,
371            )
372            .await?;
373        match locality {
374            Some(false) => Ok(()),
375            Some(true) => Err(MakeRemoteError::AlreadyRemote(
376                root_table.to_string(),
377                root_id.to_string(),
378            )),
379            None => Err(MakeRemoteError::UnresolvedLocality(
380                root_table.to_string(),
381                root_id.to_string(),
382            )),
383        }
384    }
385
386    async fn prepare_make_remote(
387        &self,
388        root_table: &str,
389        root_id: &str,
390        root_label: &str,
391        refs: Vec<RowBlobRef>,
392    ) -> Result<coven_database::MakeRemoteAdmission, MakeRemoteError> {
393        let db = &self.database;
394        let locality = db.gated_root_locality(root_table, root_id).await?;
395        match locality {
396            Some(false) => {}
397            Some(true) => {
398                return Err(MakeRemoteError::AlreadyRemote(
399                    root_table.to_string(),
400                    root_id.to_string(),
401                ));
402            }
403            None => {
404                return Err(MakeRemoteError::UnresolvedLocality(
405                    root_table.to_string(),
406                    root_id.to_string(),
407                ));
408            }
409        }
410        if refs.is_empty() {
411            return Err(MakeRemoteError::NothingToMakeRemote(
412                root_table.to_string(),
413                root_id.to_string(),
414            ));
415        }
416
417        let mut uploads = Vec::with_capacity(refs.len());
418        for reference in refs {
419            if reference.authority() != &coven_protocol::blob::RowBlobAuthority::Local
420                || reference.stored().is_some()
421            {
422                return Err(MakeRemoteError::AlreadyRemote(
423                    root_table.to_string(),
424                    root_id.to_string(),
425                ));
426            }
427            let blob = reference.blob();
428            let source_path = match blob.provenance {
429                Provenance::UserProvided => {
430                    db.external_blob_for_row(&reference)
431                        .await?
432                        .ok_or_else(|| MakeRemoteError::NotExternal(blob.id.clone()))?
433                        .path
434                }
435                Provenance::HostProvided => self
436                    .store_dir
437                    .local_blob_path(&blob.namespace, &blob.id)
438                    .map_err(|source| MakeRemoteError::SourcePath {
439                        blob_id: blob.id.clone(),
440                        path: format!("local/{}/{}", blob.namespace, blob.id),
441                        source,
442                    })?,
443            };
444            uploads.push((reference, source_path));
445        }
446        Ok(coven_database::MakeRemoteAdmission {
447            root_id: root_id.to_string(),
448            root_label: root_label.to_string(),
449            uploads,
450        })
451    }
452
453    pub(crate) async fn make_remote_batch(
454        &self,
455        root_table: &str,
456        roots: Vec<MakeRemoteRoot>,
457        pin: bool,
458    ) -> Result<(), MakeRemoteError> {
459        require_make_remote_root(&self.database, root_table)?;
460        if roots.is_empty() {
461            return Err(MakeRemoteError::EmptyBatch);
462        }
463        let mut root_ids = std::collections::HashSet::with_capacity(roots.len());
464        for root in &roots {
465            if !root_ids.insert(root.id.clone()) {
466                return Err(MakeRemoteError::DuplicateRoot(root.id.clone()));
467            }
468        }
469        let mut prepared = Vec::with_capacity(roots.len());
470        for root in roots {
471            prepared.push(
472                self.prepare_make_remote(root_table, &root.id, &root.label, root.refs)
473                    .await?,
474            );
475        }
476        self.database
477            .begin_make_remote_batch(root_table, pin, self.database.stamp(), prepared)
478            .await
479            .map_err(MakeRemoteError::from)
480    }
481
482    pub(crate) async fn cancel_make_remote(
483        &self,
484        root_table: &str,
485        root_id: &str,
486    ) -> Result<(), MakeRemoteError> {
487        require_make_remote_root(&self.database, root_table)?;
488        self.database
489            .cancel_make_remote(root_table, root_id)
490            .await
491            .map_err(MakeRemoteError::from)
492    }
493
494    async fn prepare_make_local(
495        &self,
496        root_table: &str,
497        root_id: &str,
498        routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
499    ) -> Result<PreparedMakeLocal, MakeLocalError> {
500        self.database
501            .validate_store_write_routing(routing_encryption)?;
502        require_make_local_root(&self.database, root_table)?;
503        match self
504            .database
505            .gated_root_locality(root_table, root_id)
506            .await?
507        {
508            Some(true) => {}
509            Some(false) => {
510                return Err(MakeLocalError::AlreadyLocal(
511                    root_table.to_string(),
512                    root_id.to_string(),
513                ));
514            }
515            None => {
516                return Err(MakeLocalError::UnresolvedLocality(
517                    root_table.to_string(),
518                    root_id.to_string(),
519                ));
520            }
521        }
522
523        let references = self
524            .database
525            .row_blob_refs_for_root(root_table, root_id)
526            .await?;
527        for reference in &references {
528            if !matches!(
529                reference.authority(),
530                coven_protocol::blob::RowBlobAuthority::Remote(_)
531            ) || reference.stored().is_none()
532            {
533                return Err(MakeLocalError::UnresolvedLocality(
534                    root_table.to_string(),
535                    root_id.to_string(),
536                ));
537            }
538        }
539        Ok(PreparedMakeLocal { references })
540    }
541
542    async fn commit_make_local(
543        &self,
544        root_table: &str,
545        root_id: &str,
546        routing_encryption: Option<coven_keys::encryption::EncryptionService>,
547        records: Vec<coven_database::MaterializedLocalBlob>,
548    ) -> Result<(), DbError> {
549        self.database
550            .commit_make_local(
551                root_table,
552                root_id,
553                self.database.stamp(),
554                routing_encryption,
555                records,
556            )
557            .await
558    }
559}
560
561struct PreparedMakeLocal {
562    references: Vec<RowBlobRef>,
563}
564
565/// Materializing a blob back to a local file needs one capability from the
566/// connected blob access the host composes: staging a verified local copy of
567/// an exact reference. Transitions name only this port.
568#[async_trait::async_trait]
569pub trait VerifiedLocalCopyStaging: Send + Sync {
570    async fn stage_verified_local_copy(
571        &self,
572        reference: &RowBlobRef,
573        destination: &std::path::Path,
574    ) -> Result<coven_foundation::local_file::AtomicStagedFile, crate::sync::BlobCacheError>;
575}
576
577pub struct ConnectedBlobTransitions {
578    local: LocalBlobTransitions,
579    blob_access: Arc<dyn VerifiedLocalCopyStaging>,
580    routing_encryption: Option<coven_keys::encryption::EncryptionService>,
581    observer: Option<Arc<dyn BlobTransitionObserver>>,
582}
583
584impl ConnectedBlobTransitions {
585    pub fn new(
586        local: LocalBlobTransitions,
587        blob_access: Arc<dyn VerifiedLocalCopyStaging>,
588        routing_encryption: Option<coven_keys::encryption::EncryptionService>,
589        observer: Option<Arc<dyn BlobTransitionObserver>>,
590    ) -> Self {
591        Self {
592            local,
593            blob_access,
594            routing_encryption,
595            observer,
596        }
597    }
598
599    pub(crate) async fn make_remote(
600        &self,
601        root_table: &str,
602        root_id: &str,
603        root_label: &str,
604        pin: bool,
605        refs: Vec<coven_protocol::blob::RowBlobRef>,
606    ) -> Result<(), MakeRemoteError> {
607        self.local
608            .make_remote(root_table, root_id, root_label, pin, refs)
609            .await
610    }
611
612    pub(crate) async fn make_remote_batch(
613        &self,
614        root_table: &str,
615        roots: Vec<MakeRemoteRoot>,
616        pin: bool,
617    ) -> Result<(), MakeRemoteError> {
618        self.local.make_remote_batch(root_table, roots, pin).await
619    }
620
621    pub(crate) async fn cancel_make_remote(
622        &self,
623        root_table: &str,
624        root_id: &str,
625    ) -> Result<(), MakeRemoteError> {
626        self.local.cancel_make_remote(root_table, root_id).await
627    }
628
629    pub(crate) async fn make_local(
630        &self,
631        root_table: &str,
632        root_id: &str,
633        dest: &HashMap<String, PathBuf>,
634        cancel: &watch::Receiver<bool>,
635    ) -> Result<(), MakeLocalError> {
636        let prepared = self
637            .local
638            .prepare_make_local(root_table, root_id, self.routing_encryption.as_ref())
639            .await?;
640
641        // Validate every provided dest is UTF-8 up front — before any materialization — so
642        // a non-UTF-8 path aborts with nothing written and the cloud intact, rather than
643        // being caught only at commit-building after the destructive materialize (and on a
644        // filesystem that rejects non-UTF-8 names, the write would fail first and that
645        // check would never be reached). An external ref persists the path as a string, so
646        // a non-UTF-8 path cannot be registered; fail loud rather than lossily rewrite it
647        // and tombstone the cloud copy.
648        for (blob_id, path) in dest {
649            if path.to_str().is_none() {
650                return Err(MakeLocalError::NonUtf8Dest {
651                    blob_id: blob_id.clone(),
652                    path: path.display().to_string(),
653                });
654            }
655        }
656
657        // Any error after the first local copy is written must roll those files back, so
658        // an aborted make_local leaves no partial materialization behind. The retained
659        // materialization owns that cleanup obligation until the database commit succeeds.
660        let mut materialization = MakeLocalMaterialization::new(self, root_table, root_id);
661        if let Err(error) = self
662            .materialize_blobs(
663                root_table,
664                root_id,
665                &prepared.references,
666                dest,
667                cancel,
668                &mut materialization,
669            )
670            .await
671        {
672            return Err(materialization.abort(error).await);
673        }
674
675        // The single atomic commit: flip false + register external refs (user-provided
676        // only) + enqueue the cloud deletes, together. The destructive cloud delete is
677        // durable inside this commit, so a crash right after can never leave the root
678        // Local with the cloud blobs un-tombstoned.
679        materialization.commit().await?;
680
681        if let Some(obs) = self.observer.as_deref() {
682            obs.on_root_made_local(root_table, root_id).await;
683        }
684        Ok(())
685    }
686
687    /// Materialize every remote blob through this operation's retained access,
688    /// local store, and observer, recording published paths for rollback.
689    async fn materialize_blobs(
690        &self,
691        root_table: &str,
692        root_id: &str,
693        refs: &[RowBlobRef],
694        dest: &HashMap<String, PathBuf>,
695        cancel: &watch::Receiver<bool>,
696        materialization: &mut MakeLocalMaterialization<'_>,
697    ) -> Result<(), MakeLocalError> {
698        let total = refs.len() as u64;
699
700        for (i, reference) in refs.iter().enumerate() {
701            if *cancel.borrow() {
702                return Err(MakeLocalError::Cancelled);
703            }
704            let blob = reference.blob();
705            let stored = reference
706                .stored()
707                .cloned()
708                .ok_or_else(|| MakeLocalError::MissingStoredReference(blob.id.clone()))?;
709
710            // Where the blob's bytes go is its provenance's Local home: a user-provided
711            // blob to the user's chosen `dest` path (registered as an external ref); a
712            // host-provided blob to coven's local store (no path, no ref). The kind is
713            // recorded in `written` so an abort's rollback treats a local-store leftover
714            // loud.
715            let record = match blob.provenance {
716                Provenance::UserProvided => {
717                    let dest_path = dest
718                        .get(&blob.id)
719                        .ok_or_else(|| MakeLocalError::MissingDest(blob.id.clone()))?
720                        .clone();
721                    let destination = ExactPlaintextFile::new(
722                        dest_path.clone(),
723                        reference.plaintext_size(),
724                        reference.plaintext_hash(),
725                    );
726                    destination.ensure_parent().await.map_err(|source| {
727                        MakeLocalError::WriteFile {
728                            blob_id: blob.id.clone(),
729                            path: dest_path.display().to_string(),
730                            source,
731                        }
732                    })?;
733                    let staged = self
734                        .blob_access
735                        .stage_verified_local_copy(reference, &dest_path)
736                        .await
737                        .map_err(|source| MakeLocalError::Read {
738                            blob_id: blob.id.clone(),
739                            source,
740                        })?;
741                    staged
742                        .commit_new()
743                        .await
744                        .map_err(|source| MakeLocalError::CommitFile {
745                            blob_id: blob.id.clone(),
746                            path: dest_path.display().to_string(),
747                            source,
748                        })?;
749                    destination
750                        .verify_installed_size()
751                        .await
752                        .map_err(|source| MakeLocalError::WriteFile {
753                            blob_id: blob.id.clone(),
754                            path: dest_path.display().to_string(),
755                            source,
756                        })?;
757                    materialization.record_created_file(destination);
758                    coven_database::MaterializedLocalBlob {
759                        remote: reference.clone(),
760                        stored,
761                        destination: Some(dest_path),
762                    }
763                }
764                Provenance::HostProvided => {
765                    let store_path = self
766                        .local
767                        .store_dir
768                        .local_blob_path(&blob.namespace, &blob.id)
769                        .map_err(|source| MakeLocalError::WritePath {
770                            blob_id: blob.id.clone(),
771                            path: format!("local/{}/{}", blob.namespace, blob.id),
772                            source,
773                        })?;
774                    let destination = ExactPlaintextFile::new(
775                        store_path.clone(),
776                        reference.plaintext_size(),
777                        reference.plaintext_hash(),
778                    );
779                    let staged = self
780                        .blob_access
781                        .stage_verified_local_copy(reference, &store_path)
782                        .await
783                        .map_err(|source| MakeLocalError::Read {
784                            blob_id: blob.id.clone(),
785                            source,
786                        })?;
787                    match staged.commit_new().await {
788                        Ok(()) => materialization.record_created_file(destination),
789                        Err(
790                            coven_foundation::local_file::CommitNewFileError::DestinationExists(_),
791                        ) => {
792                            destination.verify().await.map_err(|source| {
793                                MakeLocalError::WriteFile {
794                                    blob_id: blob.id.clone(),
795                                    path: store_path.display().to_string(),
796                                    source,
797                                }
798                            })?;
799                        }
800                        Err(error) => {
801                            return Err(MakeLocalError::CommitFile {
802                                blob_id: blob.id.clone(),
803                                path: store_path.display().to_string(),
804                                source: error,
805                            });
806                        }
807                    }
808                    ExactPlaintextFile::new(
809                        store_path.clone(),
810                        reference.plaintext_size(),
811                        reference.plaintext_hash(),
812                    )
813                    .verify_installed_size()
814                    .await
815                    .map_err(|source| MakeLocalError::WriteFile {
816                        blob_id: blob.id.clone(),
817                        path: store_path.display().to_string(),
818                        source,
819                    })?;
820                    coven_database::MaterializedLocalBlob {
821                        remote: reference.clone(),
822                        stored,
823                        destination: None,
824                    }
825                }
826            };
827            materialization.record_blob(record);
828
829            if let Some(obs) = self.observer.as_deref() {
830                obs.on_blob_materialize_progress(
831                    root_table,
832                    root_id,
833                    &blob.id,
834                    (i + 1) as u64,
835                    total,
836                )
837                .await;
838            }
839        }
840
841        if *cancel.borrow() {
842            return Err(MakeLocalError::Cancelled);
843        }
844        Ok(())
845    }
846}
847
848fn require_make_remote_root(
849    database: &StoreDatabase,
850    root_table: &str,
851) -> Result<(), MakeRemoteError> {
852    match database.blob_transition_root(root_table) {
853        coven_database::BlobTransitionRoot::Gated => Ok(()),
854        coven_database::BlobTransitionRoot::RemoteRoot => {
855            Err(MakeRemoteError::RemoteRoot(root_table.to_string()))
856        }
857        coven_database::BlobTransitionRoot::NotGated => {
858            Err(MakeRemoteError::NotGated(root_table.to_string()))
859        }
860    }
861}
862
863fn require_make_local_root(
864    database: &StoreDatabase,
865    root_table: &str,
866) -> Result<(), MakeLocalError> {
867    match database.blob_transition_root(root_table) {
868        coven_database::BlobTransitionRoot::Gated => Ok(()),
869        coven_database::BlobTransitionRoot::RemoteRoot => {
870            Err(MakeLocalError::RemoteRoot(root_table.to_string()))
871        }
872        coven_database::BlobTransitionRoot::NotGated => {
873            Err(MakeLocalError::NotGated(root_table.to_string()))
874        }
875    }
876}
877
878// ===========================================================================
879// make_local — foreground operation with a cancel signal
880// ===========================================================================
881
882/// Local files and database records produced by one make-local attempt before
883/// its atomic database commit. Until that commit succeeds, every file created by
884/// the attempt remains a rollback obligation owned by this value.
885struct MakeLocalMaterialization<'operation> {
886    transitions: &'operation ConnectedBlobTransitions,
887    root_table: &'operation str,
888    root_id: &'operation str,
889    records: Vec<coven_database::MaterializedLocalBlob>,
890    created_files: Vec<ExactPlaintextFile>,
891}
892
893impl<'operation> MakeLocalMaterialization<'operation> {
894    fn new(
895        transitions: &'operation ConnectedBlobTransitions,
896        root_table: &'operation str,
897        root_id: &'operation str,
898    ) -> Self {
899        Self {
900            transitions,
901            root_table,
902            root_id,
903            records: Vec::new(),
904            created_files: Vec::new(),
905        }
906    }
907
908    fn record_created_file(&mut self, file: ExactPlaintextFile) {
909        self.created_files.push(file);
910    }
911
912    fn record_blob(&mut self, record: coven_database::MaterializedLocalBlob) {
913        self.records.push(record);
914    }
915
916    async fn abort(self, cause: MakeLocalError) -> MakeLocalError {
917        let mut failures = Vec::new();
918        for file in self.created_files {
919            if let Err(source) = file.remove().await {
920                failures.push(MaterializedFileCleanupFailure {
921                    path: file.path().to_path_buf(),
922                    source,
923                });
924            }
925        }
926        if failures.is_empty() {
927            cause
928        } else {
929            MakeLocalError::Cleanup {
930                operation: Box::new(cause),
931                failures: MaterializedFileCleanupFailures(failures),
932            }
933        }
934    }
935
936    async fn commit(self) -> Result<(), MakeLocalError> {
937        let records = self.records.clone();
938        match self
939            .transitions
940            .local
941            .commit_make_local(
942                self.root_table,
943                self.root_id,
944                self.transitions.routing_encryption.clone(),
945                records,
946            )
947            .await
948        {
949            Ok(()) => Ok(()),
950            Err(error) => Err(self.abort(MakeLocalError::Db(error)).await),
951        }
952    }
953}