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}