Skip to main content

coven_protocol/
blob.rs

1//! The blob engine: coven's single owner of a blob's whole durability lifecycle.
2//!
3//! coven syncs blobs referenced by database rows. It owns the cloud layout and
4//! encryption; the host declares which rows carry blobs, their local plaintext
5//! source, and encryption scope. An opaque home uses
6//! `{namespace}/opaque/{locator_hash}`. A browsable home uses the consumer's
7//! [`BlobRef::cloud_path`] within
8//! `{namespace}/readable/{cloud_path}/.coven-versions/{locator_hash}`.
9//! Both layouts name immutable versions through [`locator::BlobLocator`].
10//!
11//! # The coven concept tree
12//!
13//! A blob has two **declared** properties — [`Provenance`] (its Local story) and
14//! [`CacheFill`] (its Remote story) — and one **state**, locality, flipped by the
15//! transitions. The cache is a *mechanism* that serves Remote blobs; it is not a
16//! kind of blob.
17//!
18//! ```text
19//! A blob the host declares with:
20//!
21//! provenance — its LOCAL story: where the bytes live when Local, and the
22//!              Remote→Local path requirement
23//!    ├─ user-provided   the user's file at a path; coven references it.
24//!    │                  Remote→Local writes the bytes back to a user file → NEEDS A PATH.
25//!    └─ host-provided   bae hands coven the data; coven keeps it in its local store.
26//!                       Remote→Local restores it to the local store → no path.
27//!
28//! cache fill — its REMOTE story: how a device gets the bytes when the release is
29//!              Remote. A cache-mechanism setting; applies to ANY blob, regardless of
30//!              provenance, once it is Remote.
31//!    ├─ CacheEager   fetched into the cache on pull, with the SQL row   (covers)
32//!    └─ CacheLazy    fetched into the cache on first read               (audio — big, fetch what you play)
33//!
34//! and a current state:
35//!
36//! locality
37//!    ├─ Local    bytes on-device — the user's path (user-provided) or coven's local store (host-provided)
38//!    └─ Remote   bytes in the cloud; each device's local copy is a CACHE copy, filled
39//!                per `cache fill`, kept-or-evicted per `pin`
40//!
41//! namespace (bucket)   the blob's category — release_files · covers · artist_images
42//!
43//! transitions
44//!    ├─ Local → Remote   upload the bytes; now cache-distributed to every device per cache fill
45//!    └─ Remote → Local   bring the bytes back to a local file — path required iff user-provided
46//!
47//! cache budget   per-NAMESPACE size limit; each namespace evicts independently, so
48//!                evicting release_files (big) never touches covers (small reserved slice)
49//! pin            keep one specific Remote blob's cache copy from eviction (e.g. a
50//!                release the user pinned for offline)
51//! ```
52//!
53//! ## The cache vs local files
54//!
55//! The cache holds local copies of **Remote** blobs (filled per `cache fill`,
56//! evicted per budget unless pinned). It is **segmented by namespace**: each
57//! namespace has its own configurable cache budget and evicts independently, so
58//! evicting `release_files` (big) never touches `covers` (a small reserved slice). A
59//! `CacheEager` cover that falls out of its namespace budget shows a placeholder
60//! until the next read re-fetches it — covers are not pinned. A **Local** blob is not
61//! in the cache: a user-provided Local blob is the user's file at its path (an
62//! external ref); a host-provided Local blob is in coven's local store, whose
63//! paths and file operations are owned by [`coven_foundation::store_dir::StoreDir`]. The cache is the mechanism for *remoteness* — so
64//! `CacheEager`/`CacheLazy`/pin/budget describe a blob only while it is Remote, never
65//! while it is Local.
66//!
67//! # The engine's halves
68//!
69//! This module is the engine; its halves move a blob through its lifecycle:
70//!
71//! - `blob::cache` — the device-local cache for **Remote** blobs: bytes on disk keyed
72//!   by exact locator hash, with the folder a file lives in as the only retention truth
73//!   (`storage/pinned/` protected, `storage/cache/` evictable). Reads — one-shot
74//!   whole, which checks the plaintext against the row's hash because it reads
75//!   every byte anyway, or an opened stream whose ranges each cost their own
76//!   bytes: a positioned read of a local file, or the sealed chunks covering the
77//!   range fetched from the cloud object and opened — plus pin/unpin, clear, and
78//!   budget eviction.
79//! - [`coven_foundation::store_dir::StoreDir`] — coven's own copy of a **host-provided Local**
80//!   blob, in `storage/local/<namespace>/<id>`. Never evicted; the budget sweep
81//!   never walks it.
82//! - `blob::upload` — the cloud-write half: drain the durable upload queue, sealing
83//!   each blob under its scope and writing it to the cloud with coalesced progress,
84//!   so a local-only blob becomes uploaded. The sync cycle calls the drain
85//!   each round before it pushes.
86//! - `blob::delete` — the cloud-delete half: turn a queued deletion into a signed
87//!   cloud tombstone, hold the blob for a convergence grace so a lagging peer
88//!   isn't stranded, then GC the blob once the grace has passed. The sync cycle
89//!   drains tombstones and runs the GC each round after it pulls.
90//!
91//! The types below ([`BlobRef`], [`BlobScope`], [`Provenance`],
92//! [`CacheFill`], [`BlobTransitionObserver`]) are the vocabulary both halves and
93//! the host speak. Which rows carry blobs is not a runtime callback but a per-table
94//! declaration ([`crate::synced_schema::BlobDecl`]) coven resolves into a
95//! the database's `BlobDecls` each cycle to derive the blob set itself.
96//!
97//! coven also owns the two locality transitions (`blob::transition`): `make_remote`
98//! (Local → Remote: upload the bytes, then flip the gate) and `make_local`
99//! (Remote → Local: bring each blob back to a local file, then retract). The
100//! The upload drain advances the durable make-Remote intent after every exact
101//! object lands. The Store publication activates the resulting gate change;
102//! hosts observe both handoffs through the durable cloud-outbox query.
103
104pub mod locator;
105
106#[cfg(test)]
107mod row_ref_tests;
108
109use sha2::{Digest, Sha256};
110
111/// The content hash a blob-bearing row carries: the lowercase-hex SHA-256 of the
112/// blob's plaintext bytes, computed at import and stored in the row's blob columns
113/// alongside the declared size. The row is carried in a signed changeset (and in a
114/// signed snapshot), so this hash is signed by the row's author — that is what
115/// makes it authoritative: on download coven hashes the decrypted plaintext and
116/// requires equality with the row's hash, so the bytes are pinned by the author,
117/// not by the cloud key they happened to arrive under. A host computes this over a
118/// blob's plaintext at import and writes it into the row's declared hash column,
119/// the same way it writes the plaintext length into the size column.
120pub fn content_hash(plaintext: &[u8]) -> String {
121    hex::encode(Sha256::digest(plaintext))
122}
123
124/// An incremental SHA-256 over a blob's plaintext, so the streaming download path
125/// verifies a blob's content hash without holding the whole plaintext in memory:
126/// feed each decrypted chunk to [`update`](Self::update), call
127/// [`finish`](Self::finish), and compare the returned digest with the row's hash
128/// before committing the bytes to the cache. The hex-encoded digest matches
129/// [`content_hash`] over the same bytes.
130pub struct ContentHasher(Sha256);
131
132impl ContentHasher {
133    pub fn new() -> Self {
134        ContentHasher(Sha256::new())
135    }
136
137    /// Fold the next plaintext chunk into the running digest.
138    pub fn update(&mut self, chunk: &[u8]) {
139        self.0.update(chunk);
140    }
141
142    /// The lowercase-hex digest of everything fed so far.
143    pub fn finish(self) -> String {
144        hex::encode(self.0.finalize())
145    }
146}
147
148impl Default for ContentHasher {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154/// How many blob transfers coven runs at once in each of its two transfer loops:
155/// the upload drain and the pin/download loop. An
156/// open-time blob-engine tunable the host sets on the builder,
157/// carried on the `Database` alongside the other open-time
158/// blob config and read back by each loop, which holds `&Database`.
159///
160/// Each bound is a [`NonZeroUsize`], so a zero — which would leave a loop admitting
161/// nothing and never completing — is unrepresentable rather than clamped or rejected
162/// at open. `one_at_a_time()` (both `1`) is the default: transfers run one at a
163/// time in queue order.
164///
165/// [`NonZeroUsize`]: std::num::NonZeroUsize
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub struct TransferLimits {
168    /// Maximum concurrent blob uploads in one upload-drain pass.
169    pub uploads: std::num::NonZeroUsize,
170    /// Maximum concurrent blob downloads (fetches) in one pin call.
171    pub downloads: std::num::NonZeroUsize,
172}
173
174impl TransferLimits {
175    /// One at a time in each loop.
176    pub fn one_at_a_time() -> Self {
177        Self {
178            uploads: std::num::NonZeroUsize::MIN,
179            downloads: std::num::NonZeroUsize::MIN,
180        }
181    }
182}
183
184// The cache's own tests: real `Database` + `TestStore` over a temp store
185// dir, asserting hits/misses, the pinned/cache folder split, and pin/unpin/clear.
186// These drive a real temp directory on the filesystem. See `blob::cache`.
187// The upload drain's tests: real `Database` (the `cloud_outbox` queue) driven
188// against `InMemoryCloudHome`/`FailingCloudHome`, asserting record-and-continue,
189// per-entry backoff, scope-resolved sealing, and the observer callbacks. See
190// `blob::upload`.
191// The coven-owned make-Remote / make-Local transition tests: multi-device
192// make_remote + make_local through the real cycle, cancel both directions, the
193// drain's completion flip, durable cancellation, crash-idempotency at each commit
194// boundary, and a round-trip. Uses a `watch` cancel signal and retained test devices,
195// See `blob::transition`.
196// The local-files store's tests: store/read round-trip, a host-provided Local blob
197// surviving a budget sweep (the sweep never walks `local/`), and drop. These
198// drive a real temp directory through `StoreDir`.
199// The delete half's tests: tombstone signing, the drain that writes tombstones,
200// the graced GC that reclaims exact immutable objects, and the delete-outbox row
201// shape. Driven against `InMemoryCloudHome` and
202// `TestStore`. See `blob::delete`.
203
204/// Which key encrypts a blob, as a host names it on a [`BlobRef`].
205///
206/// The host names *what* a blob is scoped to — the whole store or a derived
207/// per-scope key — never the raw key bytes. Storage and encryption consume this
208/// same type; there is no key material in it to leak.
209#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
210pub enum BlobScope {
211    /// The store master key — every member reads it.
212    Master,
213    /// A per-scope key derived from the master key (e.g. one key per item).
214    Derived(String),
215}
216
217/// A blob's **Local story**: where its bytes live while the blob is Local, and
218/// whether bringing it back from Remote needs a destination path. Orthogonal to
219/// [`CacheFill`] (the Remote story) — a blob declares both.
220///
221/// The cache never enters into this: a Local blob is not a cache copy. Provenance
222/// decides which of the two Local homes holds it, and what `make_local` does to
223/// restore it.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
225pub enum Provenance {
226    /// The user's own file at a path; coven references it but does not own it
227    /// (tracked as an external ref — see `local_blob_refs`). `make_local` writes
228    /// the bytes back to a user file, so it **needs a destination path**.
229    UserProvided,
230    /// The host hands coven the data; coven keeps its own copy in the local store
231    /// (`storage/local/<namespace>/<id>`, owned by [`coven_foundation::store_dir::StoreDir`]). `make_local`
232    /// restores it to the local store, so it needs **no path**.
233    HostProvided,
234}
235
236/// A blob's **Remote story**: how a device gets the bytes once the blob is Remote.
237/// A cache-mechanism setting — it describes a blob only while Remote — that applies
238/// to ANY blob regardless of [`Provenance`]. Orthogonal to provenance; a blob
239/// declares both.
240///
241/// Both classes are declared per blob and are global (every device reads the same
242/// class from the blob's [`BlobRef`]); the difference is what a device does with
243/// the blob on pull. The distinction has to be a declared property and not a
244/// per-device choice: device B, deciding during its own pull whether to fetch a
245/// blob, can only read the blob's declared class — it cannot see what device A
246/// chose locally.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
248pub enum CacheFill {
249    /// Fetched into the cache on pull, right away, on every device — part of
250    /// "having the store" (e.g. cover art, so the grid renders from local bytes
251    /// without a fetch). The cache copy is evictable + re-fetchable, not pinned.
252    CacheEager,
253    /// Not fetched on pull: a pulling device skips it and fetches it into the cache
254    /// on first read — e.g. audio, which is big and streams on demand.
255    CacheLazy,
256}
257
258/// Whether changeset updates may repoint a blob-bearing row at another blob id.
259/// Orthogonal to [`Provenance`] and [`CacheFill`]. Both variants use
260/// [`locator::BlobLocator`] to identify immutable cloud objects independently
261/// of the row's replacement policy.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum BlobReplacement {
264    /// The row may be repointed at another blob id. This is the default.
265    Replaceable,
266    /// Changeset updates may not change this row's blob-id column.
267    WriteOnce,
268}
269
270/// A blob a row references: its logical identity, encryption scope, and the two
271/// declared properties ([`provenance`](BlobRef::provenance) +
272/// [`fill`](BlobRef::fill)). coven derives it from the row's declared columns
273/// ([`crate::synced_schema::BlobDecl`]) via the database's `BlobDecls`. Where its bytes
274/// live depends on its locality and provenance: a user-provided Local blob is the
275/// user's file at its path; a host-provided Local blob is in coven's local store
276/// (`storage/local/<namespace>/<id>`); a Remote blob's device-local copy is a cache
277/// copy (`storage/pinned/<namespace>/<ab>/<cd>/<locator-hash>` or
278/// `storage/cache/<namespace>/<ab>/<cd>/<locator-hash>`). The shard's `ab` and
279/// `cd` are the first two byte-pairs of the locator hash.
280#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
281pub struct BlobRef {
282    /// Cloud namespace, e.g. `"images"`. Prefixes the opaque or readable
283    /// version path generated by [`locator::BlobLocator::semantic_key`].
284    pub namespace: String,
285    /// Blob id (typically the id of the blob-bearing row).
286    pub id: String,
287    /// Encryption scope for this blob.
288    pub scope: BlobScope,
289    /// The consumer's readable cloud-relative path for this blob, e.g.
290    /// `"Artist - Album/cover-blob-id.jpg"`. A browsable locator includes it
291    /// beneath `{namespace}/readable/` and appends `.coven-versions/{locator_hash}`.
292    /// An opaque locator does not use it. A browsable home requires a path.
293    pub cloud_path: Option<String>,
294    /// The blob's **Local story**: where its bytes live while Local, and whether
295    /// `make_local` needs a destination path. See [`Provenance`].
296    pub provenance: Provenance,
297    /// The blob's **Remote story**: whether a pulling device fetches it into the
298    /// cache right away ([`CacheFill::CacheEager`]) or on first read
299    /// ([`CacheFill::CacheLazy`]). See [`CacheFill`].
300    pub fill: CacheFill,
301}
302
303/// One exact blob-bearing row version. A reference becomes stale when the live
304/// row stamp or any declared blob value changes.
305#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
306#[serde(deny_unknown_fields)]
307pub struct RowBlobRef {
308    table: String,
309    row_id: String,
310    row_stamp: String,
311    column: String,
312    blob: BlobRef,
313    plaintext_size: u64,
314    plaintext_hash: crate::store_commit::ObjectHash,
315    authority: RowBlobAuthority,
316    stored: Option<locator::StoredBlobRef>,
317}
318
319/// The authority state that determines where one row version's blob lives.
320/// A remote-audience blob remains `PendingRemote` while its verified plaintext
321/// is local and no cloud object has been created; `Remote` carries the exact
322/// package authority needed to open its committed object.
323#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
324#[serde(rename_all = "snake_case", deny_unknown_fields)]
325pub enum RowBlobAuthority {
326    Local,
327    PendingRemote(locator::RemoteAudience),
328    Remote(crate::audience_package::PackageAudience),
329}
330
331pub enum BlobOpeningAuthority<'a> {
332    Store,
333    Circle {
334        circle_id: crate::circle::CircleId,
335        control: &'a crate::circle::CircleControlCoord,
336        key_fingerprint: coven_keys::encryption::KeyFingerprint,
337    },
338}
339
340#[derive(Debug, thiserror::Error)]
341pub enum BlobOpeningAuthorityError {
342    #[error("blob {id} has no exact remote authority")]
343    LocalityUnresolved { id: String },
344    #[error(
345        "Circle {circle_id} blob locator audience or key differs from its exact activated authority"
346    )]
347    CircleAuthorityMismatch { circle_id: crate::circle::CircleId },
348}
349
350impl<'de> serde::Deserialize<'de> for RowBlobRef {
351    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
352    where
353        D: serde::Deserializer<'de>,
354    {
355        #[derive(serde::Deserialize)]
356        #[serde(deny_unknown_fields)]
357        struct Fields {
358            table: String,
359            row_id: String,
360            row_stamp: String,
361            column: String,
362            blob: BlobRef,
363            plaintext_size: u64,
364            plaintext_hash: crate::store_commit::ObjectHash,
365            authority: RowBlobAuthority,
366            stored: Option<locator::StoredBlobRef>,
367        }
368
369        let fields = Fields::deserialize(deserializer)?;
370        Self::new(
371            fields.table,
372            fields.row_id,
373            fields.row_stamp,
374            fields.column,
375            fields.blob,
376            fields.plaintext_size,
377            fields.plaintext_hash,
378            fields.authority,
379            fields.stored,
380        )
381        .map_err(serde::de::Error::custom)
382    }
383}
384
385impl RowBlobAuthority {
386    pub fn audience(&self) -> crate::circle::Audience {
387        match self {
388            Self::Local => crate::circle::Audience::Local,
389            Self::PendingRemote(locator::RemoteAudience::Store) => crate::circle::Audience::Store,
390            Self::PendingRemote(locator::RemoteAudience::Circle(circle_id)) => {
391                crate::circle::Audience::Circle(*circle_id)
392            }
393            Self::Remote(crate::audience_package::PackageAudience::Store) => {
394                crate::circle::Audience::Store
395            }
396            Self::Remote(crate::audience_package::PackageAudience::Circle {
397                circle_id, ..
398            }) => crate::circle::Audience::Circle(*circle_id),
399        }
400    }
401
402    pub fn opening_authority<'a>(
403        &'a self,
404        stored: &locator::StoredBlobRef,
405    ) -> Result<BlobOpeningAuthority<'a>, BlobOpeningAuthorityError> {
406        match self {
407            Self::Local | Self::PendingRemote(_) => {
408                Err(BlobOpeningAuthorityError::LocalityUnresolved {
409                    id: stored.locator().blob_id().to_string(),
410                })
411            }
412            Self::Remote(crate::audience_package::PackageAudience::Store) => {
413                Ok(BlobOpeningAuthority::Store)
414            }
415            Self::Remote(crate::audience_package::PackageAudience::Circle {
416                circle_id,
417                control,
418                key_fingerprint,
419            }) => {
420                if stored.locator().audience() != locator::RemoteAudience::Circle(*circle_id)
421                    || stored.locator().key_fingerprint() != Some(*key_fingerprint)
422                {
423                    return Err(BlobOpeningAuthorityError::CircleAuthorityMismatch {
424                        circle_id: *circle_id,
425                    });
426                }
427                Ok(BlobOpeningAuthority::Circle {
428                    circle_id: *circle_id,
429                    control,
430                    key_fingerprint: *key_fingerprint,
431                })
432            }
433        }
434    }
435}
436
437/// Whether this row can retain its uploaded object for the destination audience.
438/// Content, encryption scope, audience, and a Browsable locator's readable path
439/// must agree. A Store key rotation alone does not require uploading the same
440/// bytes again: the accepted locator retains its original sealing key.
441pub fn locator_is_this_rows_upload(
442    locator: &locator::BlobLocator,
443    blob: &BlobRef,
444    plaintext_size: u64,
445    plaintext_hash: crate::store_commit::ObjectHash,
446    audience: &locator::RemoteAudience,
447) -> bool {
448    locator_describes_row(locator, blob, plaintext_size, plaintext_hash)
449        && &locator.audience() == audience
450        && locator
451            .cloud_path()
452            .is_none_or(|path| blob.cloud_path.as_deref() == Some(path))
453}
454
455/// Whether the object supplies this row's plaintext and encryption scope.
456/// The readable path and audience may differ when this object is the source
457/// for a rename or an audience move; retaining it as the destination upload
458/// additionally requires [`locator_is_this_rows_upload`].
459pub fn locator_describes_row(
460    locator: &locator::BlobLocator,
461    blob: &BlobRef,
462    plaintext_size: u64,
463    plaintext_hash: crate::store_commit::ObjectHash,
464) -> bool {
465    locator.namespace() == blob.namespace
466        && locator.blob_id() == blob.id
467        && locator.plaintext_size() == plaintext_size
468        && locator.plaintext_hash() == plaintext_hash
469        && locator.scope().is_none_or(|scope| scope == &blob.scope)
470}
471
472impl RowBlobRef {
473    #[allow(clippy::too_many_arguments)]
474    pub fn new(
475        table: String,
476        row_id: String,
477        row_stamp: String,
478        column: String,
479        blob: BlobRef,
480        plaintext_size: u64,
481        plaintext_hash: crate::store_commit::ObjectHash,
482        authority: RowBlobAuthority,
483        stored: Option<locator::StoredBlobRef>,
484    ) -> Result<Self, RowBlobRefError> {
485        let remote = match &authority {
486            RowBlobAuthority::Local => None,
487            RowBlobAuthority::PendingRemote(audience) => Some(audience.clone()),
488            RowBlobAuthority::Remote(package) => Some(package.remote_audience()),
489        };
490        match (&authority, remote.as_ref(), stored.as_ref()) {
491            (RowBlobAuthority::Local, None, None)
492            | (RowBlobAuthority::PendingRemote(_), Some(_), None) => {}
493            (RowBlobAuthority::Remote(_), Some(expected), Some(stored))
494                if &stored.locator().audience() == expected => {}
495            (RowBlobAuthority::Local, None, Some(_)) => {
496                return Err(RowBlobRefError::LocalHasLocator);
497            }
498            (RowBlobAuthority::PendingRemote(_), Some(_), Some(_)) => {
499                return Err(RowBlobRefError::PendingHasLocator);
500            }
501            (RowBlobAuthority::Remote(_), Some(_), None) => {
502                return Err(RowBlobRefError::RemoteMissingLocator);
503            }
504            (RowBlobAuthority::Remote(_), Some(expected), Some(stored)) => {
505                return Err(RowBlobRefError::AudienceMismatch {
506                    row: expected.clone(),
507                    locator: stored.locator().audience(),
508                });
509            }
510            _ => unreachable!("authority determines whether a remote audience exists"),
511        }
512        if let Some(stored) = &stored {
513            let locator = stored.locator();
514            if locator.namespace() != blob.namespace {
515                return Err(RowBlobRefError::NamespaceMismatch {
516                    row: blob.namespace.clone(),
517                    locator: locator.namespace().to_string(),
518                });
519            }
520            if locator.blob_id() != blob.id {
521                return Err(RowBlobRefError::IdMismatch {
522                    row: blob.id.clone(),
523                    locator: locator.blob_id().to_string(),
524                });
525            }
526            if locator.plaintext_size() != plaintext_size
527                || locator.plaintext_hash() != plaintext_hash
528            {
529                return Err(RowBlobRefError::PlaintextMismatch);
530            }
531            match locator {
532                locator::BlobLocator::Opaque {
533                    scope,
534                    key_fingerprint,
535                    ..
536                } => {
537                    if scope != &blob.scope {
538                        return Err(RowBlobRefError::ScopeMismatch);
539                    }
540                    if let RowBlobAuthority::Remote(
541                        crate::audience_package::PackageAudience::Circle {
542                            key_fingerprint: expected,
543                            ..
544                        },
545                    ) = &authority
546                    {
547                        if key_fingerprint != expected {
548                            return Err(RowBlobRefError::CircleKeyMismatch);
549                        }
550                    }
551                }
552                locator::BlobLocator::Browsable { cloud_path, .. } => {
553                    if blob.cloud_path.as_deref() != Some(cloud_path) {
554                        return Err(RowBlobRefError::CloudPathMismatch);
555                    }
556                }
557            }
558        }
559        Ok(Self {
560            table,
561            row_id,
562            row_stamp,
563            column,
564            blob,
565            plaintext_size,
566            plaintext_hash,
567            authority,
568            stored,
569        })
570    }
571
572    pub fn table(&self) -> &str {
573        &self.table
574    }
575
576    pub fn row_id(&self) -> &str {
577        &self.row_id
578    }
579
580    pub fn row_stamp(&self) -> &str {
581        &self.row_stamp
582    }
583
584    pub fn column(&self) -> &str {
585        &self.column
586    }
587
588    pub fn blob(&self) -> &BlobRef {
589        &self.blob
590    }
591
592    pub fn plaintext_size(&self) -> u64 {
593        self.plaintext_size
594    }
595
596    pub fn plaintext_hash(&self) -> crate::store_commit::ObjectHash {
597        self.plaintext_hash
598    }
599
600    pub fn authority(&self) -> &RowBlobAuthority {
601        &self.authority
602    }
603
604    pub fn audience(&self) -> crate::circle::Audience {
605        self.authority.audience()
606    }
607
608    pub fn stored(&self) -> Option<&locator::StoredBlobRef> {
609        self.stored.as_ref()
610    }
611}
612
613#[derive(Debug, thiserror::Error)]
614pub enum RowBlobRefError {
615    #[error("Local row blob carries a remote locator")]
616    LocalHasLocator,
617    #[error("pending remote row blob carries a cloud locator")]
618    PendingHasLocator,
619    #[error("remote row blob has no exact locator")]
620    RemoteMissingLocator,
621    #[error("row audience {row:?} differs from locator audience {locator:?}")]
622    AudienceMismatch {
623        row: locator::RemoteAudience,
624        locator: locator::RemoteAudience,
625    },
626    #[error("row blob namespace {row:?} differs from locator namespace {locator:?}")]
627    NamespaceMismatch { row: String, locator: String },
628    #[error("row blob id {row:?} differs from locator id {locator:?}")]
629    IdMismatch { row: String, locator: String },
630    #[error("row blob plaintext size or hash differs from its exact locator")]
631    PlaintextMismatch,
632    #[error("row blob encryption scope differs from its exact locator")]
633    ScopeMismatch,
634    #[error("row blob Circle key differs from its exact locator")]
635    CircleKeyMismatch,
636    #[error("row blob cloud path differs from its exact locator")]
637    CloudPathMismatch,
638}
639
640/// Notified about coven's blob transitions, for host-specific bookkeeping and UI:
641/// per-blob upload progress while a make_remote uploads, per-blob materialize
642/// progress while a make_local copies files back, and the synchronous
643/// make-local completion the host turns into its own UI event.
644///
645/// The host no longer drives the transition — coven owns flipping the gate and
646/// deciding when a cycle publishes — so this observer only *reports*. The upload
647/// callbacks fire as the drain works: preparation starts while the plaintext is
648/// verified and sealed into its durable spool, `on_blob_upload_started` fires
649/// only when that prepared spool is handed to the provider,
650/// `on_blob_upload_progress` fires zero or more times as encrypted bytes reach
651/// the cloud (backends that can't report sub-file progress call it once at the end
652/// with `bytes_done == bytes_total`), `on_blob_uploaded` on success (notification
653/// only — the durable queue records Created and the Store publication later
654/// activates the root),
655/// and `on_blob_upload_failed` when an attempt fails and its entry stays queued.
656///
657/// A make-remote's root state is durable and belongs in
658/// `CloudOutboxLiveQuery`, not an observer callback that can be lost across a
659/// restart. `on_root_made_local` reports the synchronous opposite direction;
660/// `on_blob_materialize_progress` moves its per-file progress bar.
661///
662/// The upload-pause methods let the host suspend the upload pipeline without
663/// touching the queue or discarding an open provider upload. The drain checks
664/// the absolute state before admitting work, stops polling active preparation,
665/// and stops active provider request bodies from yielding bytes while paused;
666/// resume continues those same operations.
667///
668#[async_trait::async_trait]
669pub trait BlobTransitionObserver: Send + Sync {
670    /// The plaintext source is being verified and sealed into its durable
671    /// upload spool. Fires only for a Pending journal; a restart-resumed
672    /// Prepared journal proceeds directly to upload.
673    async fn on_blob_preparation_started(&self, upload: &RowBlobRef) {
674        let _ = upload;
675    }
676
677    /// `bytes_done` of `bytes_total` plaintext source bytes have been consumed
678    /// by preparation. Values are cumulative and monotonic.
679    async fn on_blob_preparation_progress(
680        &self,
681        upload: &RowBlobRef,
682        bytes_done: u64,
683        bytes_total: u64,
684    ) {
685        let _ = (upload, bytes_done, bytes_total);
686    }
687
688    /// The durable spool is prepared and its provider upload is starting now.
689    async fn on_blob_upload_started(&self, upload: &RowBlobRef);
690
691    /// `bytes_done` of `bytes_total` encrypted bytes have reached the cloud for
692    /// this in-flight blob. `bytes_done` is cumulative and monotonic within one
693    /// upload attempt. The default is a no-op so observers that don't surface
694    /// sub-file progress don't need a stub.
695    async fn on_blob_upload_progress(
696        &self,
697        upload: &RowBlobRef,
698        bytes_done: u64,
699        bytes_total: u64,
700    ) {
701        let _ = (upload, bytes_done, bytes_total);
702    }
703
704    /// The blob was uploaded to the cloud successfully — notification only.
705    /// coven owns the durable Created handoff and the Store publication that
706    /// completes the make_remote.
707    async fn on_blob_uploaded(&self, upload: &RowBlobRef);
708
709    /// An upload attempt failed; the entry remains queued for retry.
710    async fn on_blob_upload_failed(&self, upload: &RowBlobRef, error: &str);
711
712    /// Whether upload work is currently paused. The drain checks this before
713    /// admission and while provider work is active. The default is `false` so
714    /// existing implementations don't need a stub.
715    fn should_skip_uploads(&self) -> bool {
716        false
717    }
718
719    /// Complete when the absolute upload-pause state becomes paused. The
720    /// default never completes because the default state never pauses.
721    async fn wait_until_uploads_paused(&self) {
722        std::future::pending::<()>().await;
723    }
724
725    /// Complete when the absolute upload-pause state becomes running. An
726    /// observer that can return `true` from [`Self::should_skip_uploads`] must
727    /// override this so a suspended transfer can resume.
728    async fn wait_until_uploads_resumed(&self) {
729        std::future::pending::<()>().await;
730    }
731
732    /// coven completed a make_local of `(root_table, root_id)`: every blob is back
733    /// to a local file (a user file for user-provided, the local store for
734    /// host-provided), the gate is flipped false (the subtree retracts from peers),
735    /// and the cloud blobs are queued for tombstoning. The default is a no-op.
736    async fn on_root_made_local(&self, root_table: &str, root_id: &str) {
737        let _ = (root_table, root_id);
738    }
739
740    /// `done` of `total` of a make_local's blobs have been materialized back to a
741    /// local file, so the host can move a per-file progress bar. The default is a
742    /// no-op.
743    async fn on_blob_materialize_progress(
744        &self,
745        root_table: &str,
746        root_id: &str,
747        blob_id: &str,
748        done: u64,
749        total: u64,
750    ) {
751        let _ = (root_table, root_id, blob_id, done, total);
752    }
753}
754
755/// The default convergence window a host gets if it configures none: how long a
756/// deleted blob is kept after its tombstone is written, before a GC pass
757/// reclaims it. The host overrides it on the coven builder; the writer's
758/// tombstone collection evaluates whatever grace it is handed against the
759/// tombstone's `deleted_at`.
760///
761/// A device offline for less than the grace is never stranded by a deletion —
762/// when it reconnects it pulls the removal of the row that referenced the blob,
763/// and the blob is still present until then. The window is human-scale (days,
764/// not the sub-second commit window the snapshot sweep's grace covers) because
765/// the device it protects is a person's offline laptop or phone, not a
766/// concurrent writer mid-publish.
767pub const BLOB_TOMBSTONE_GRACE: chrono::Duration = chrono::Duration::days(7);
768
769#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
770#[serde(rename_all = "snake_case")]
771pub enum DeferredLocalBlobDisposition {
772    Drop,
773    Cache,
774    Pin,
775}
776
777impl DeferredLocalBlobDisposition {
778    pub fn as_db(self) -> &'static str {
779        match self {
780            Self::Drop => "drop",
781            Self::Cache => "cache",
782            Self::Pin => "pin",
783        }
784    }
785
786    pub fn from_db(raw: &str) -> Result<Self, DeferredLocalBlobDispositionError> {
787        match raw {
788            "drop" => Ok(Self::Drop),
789            "cache" => Ok(Self::Cache),
790            "pin" => Ok(Self::Pin),
791            other => Err(DeferredLocalBlobDispositionError {
792                value: other.to_string(),
793            }),
794        }
795    }
796}
797
798#[derive(Debug, thiserror::Error)]
799#[error("unknown disposition in published blob drop intent: {value}")]
800pub struct DeferredLocalBlobDispositionError {
801    value: String,
802}
803
804#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
805#[serde(deny_unknown_fields)]
806pub struct DeferredLocalBlobDrop {
807    pub namespace: String,
808    pub id: String,
809    pub size: u64,
810    pub plaintext_hash: crate::store_commit::ObjectHash,
811    pub locator_hash: crate::store_commit::ObjectHash,
812    pub disposition: DeferredLocalBlobDisposition,
813}