Skip to main content

coven_replication/blob/
delete.rs

1//! The delete half of the blob engine: turn a queued blob deletion into a signed
2//! cloud **tombstone**, hold the actual deletion for a wall-clock grace, then
3//! reclaim the blob once that grace has passed.
4//!
5//! ## Why a tombstone and a grace, not an immediate delete
6//!
7//! A blob is shared cloud state referenced by DB rows on every device. Deleting
8//! it the instant the deletion drains strands any device that still holds the
9//! referencing row: an offline or lagging peer pulls the row's removal on its own
10//! later cycle, but by then the blob is already gone, so it sees a row pointing at
11//! nothing. A strict cross-device refcount would fix that, but it is
12//! unrepresentable in an eventually-consistent bucket with no lock and no global
13//! view of who still references what.
14//!
15//! So the deletion is recorded as a signed tombstone and the blob is kept for
16//! [`BLOB_TOMBSTONE_GRACE`] — the convergence window. A device offline for less
17//! than the grace is never stranded: it comes back, pulls the row removal, and
18//! the blob is still there to be read in the meantime. Once the grace has passed,
19//! a GC pass on any device deletes the blob and the tombstone. This is not a
20//! self-heal — an unreferenced-but-not-yet-deleted blob is *correct* state during
21//! the window; the immediate delete is what *created* the wrong state. The
22//! tombstone is the durable record of the deletion; the grace prevents the strand;
23//! the GC reclaims converged garbage.
24//!
25//! ## Why it is signed
26//!
27//! The bucket is untrusted: the at-rest cipher proves only confidentiality (the
28//! store key is shared by every member), not authorship, so anyone who can write
29//! the bucket could otherwise drop a tombstone that deletes a blob they were never
30//! authorized to remove. The tombstone is therefore signed by its author (like
31//! every other control object — heads, the snapshot meta/pointer), and the GC
32//! verifies the signature *and* that the author is a current write-capable member
33//! before acting on it. A tombstone that fails either check is skipped, never
34//! acted on — the blob survives.
35//!
36//! Tombstones name the exact immutable provider object, not its reusable logical
37//! key. Re-uploading the same logical blob allocates a different exact object, so
38//! an older tombstone can reclaim only the object it signed and never needs a
39//! cross-device cancel queue.
40
41use serde::{Deserialize, Serialize};
42use tracing::{debug, warn};
43
44use coven_database::{OutboxEntry, OutboxOperation};
45use coven_keys::keys::{self, UserKeypair};
46use coven_protocol::blob::locator::StoredBlobRef;
47use coven_storage::CloudSyncObjectStorage;
48
49/// The cloud key-prefix under which tombstones live. The suffix after this prefix
50/// is the hash of the exact immutable provider object reference.
51pub(crate) fn tombstone_object_id(
52    stored: &StoredBlobRef,
53) -> coven_protocol::store_commit::ObjectHash {
54    coven_storage::blob_tombstone_object_id(stored)
55}
56
57pub(crate) fn stored_cloud_key(stored: &StoredBlobRef) -> &str {
58    stored.object().slot().logical_key()
59}
60
61#[derive(Debug)]
62enum ExistingTombstone {
63    Valid,
64    Absent,
65    Invalid(InvalidTombstone),
66}
67
68#[derive(Debug, thiserror::Error)]
69enum InvalidTombstone {
70    #[error("open failed: {0}")]
71    Open(#[source] coven_protocol::objects::StorageError),
72    #[error("parse failed: {0}")]
73    Parse(#[source] serde_json::Error),
74    #[error("signed stored blob {actual:?} does not match {expected:?}")]
75    StoredReference {
76        actual: StoredBlobRef,
77        expected: StoredBlobRef,
78    },
79    #[error("signature verification failed")]
80    Signature,
81}
82
83#[derive(Debug, thiserror::Error)]
84pub(crate) enum TombstoneDrainError {
85    #[error("tombstone read failed: {0}")]
86    Read(#[source] coven_protocol::objects::StorageError),
87    #[error("tombstone serialization failed: {0}")]
88    Serialize(#[source] serde_json::Error),
89    #[error("tombstone write failed: {0}")]
90    Write(#[source] coven_protocol::objects::StorageError),
91    #[error("blob deletion database state: {0}")]
92    Database(#[from] coven_database::DbError),
93    #[error("pending delete query returned non-delete outbox entry {entry_id}")]
94    NonDeleteOutboxEntry { entry_id: i64 },
95    #[error("failed to record delete failure for {cloud_key} (entry {entry_id}): {source}")]
96    RecordFailure {
97        cloud_key: String,
98        entry_id: i64,
99        #[source]
100        source: coven_database::DbError,
101    },
102    #[error("tombstone write failed for {cloud_key}: {source}")]
103    Publish {
104        cloud_key: String,
105        #[source]
106        source: Box<TombstoneDrainError>,
107    },
108    #[error("failed to validate an existing tombstone for {cloud_key}: {source}")]
109    Validate {
110        cloud_key: String,
111        #[source]
112        source: Box<TombstoneDrainError>,
113    },
114    #[error("failed to remove delete outbox entry {entry_id}: {source}")]
115    RemoveOutboxEntry {
116        entry_id: i64,
117        #[source]
118        source: coven_database::DbError,
119    },
120}
121
122/// One coherent pass that drains queued blob deletions into signed tombstones.
123/// The blob itself is **not** deleted here — the writer's tombstone collection
124/// reclaims it once the tombstone has aged past [`BLOB_TOMBSTONE_GRACE`].
125///
126/// Coven records a delete intent atomically with the row or blob transition;
127/// the drain records that intent durably in the cloud as a tombstone so every
128/// device converges on the deletion, rather than deleting the blob out from
129/// under a peer that has not pulled the row removal yet.
130///
131/// A failed tombstone attempt leaves the outbox row queued, records the attempt,
132/// and fails the drain so the caller retries the whole operation. Rotation state
133/// refuses every write while this device has not adopted a Store-key rotation
134/// the cloud has already committed, leaving those rows queued for retry.
135///
136/// A valid tombstone already at the key is preserved. Its original `deleted_at`
137/// fixes the reclaim deadline; rewriting it during a repeated drain would keep
138/// moving that deadline and could prevent reclamation indefinitely.
139///
140/// The operation retains the exact database, cloud home, cipher, rotation state,
141/// Store identity, writer identity, and clock used by validation, publication,
142/// and retry recording.
143pub(crate) struct TombstoneDrain<'a> {
144    db: &'a coven_database::StoreDatabase,
145    storage: &'a dyn CloudSyncObjectStorage,
146    store_id: &'a str,
147    keypair: &'a UserKeypair,
148    clock: &'a dyn coven_foundation::clock::Clock,
149}
150
151/// Serialized form of a `blob_tombstones/{exact_object_hash}{suffix}` object: the durable,
152/// signed record that a blob was deleted, plus when, so a GC pass can reclaim the
153/// blob once the convergence grace has passed.
154///
155/// `author_pubkey`/`signature` cover the `BlobTombstoneFields` canonical payload
156/// — including the exact stored reference (the slot the tombstone lives under
157/// and the provider object it authorizes deleting) and `deleted_at` (so the age
158/// can't be forged to dodge or shorten the grace). The GC verifies this
159/// signature and authorizes the author against the membership chain before
160/// deleting anything.
161///
162/// `store_id` is part of the signed payload but not stored: the reader supplies
163/// its own store id to `Self::verify`, mirroring the snapshot meta/pointer.
164/// A member of two stores cannot take one store's tombstone and replay it as
165/// the other's — re-verifying under the second store's id fails, because the
166/// signature was taken over the first's.
167///
168/// `author_pubkey` *is* stored: a tombstone's
169/// author varies device to device, so the verifier learns who signed it and then
170/// checks that author against the chain (the authorization step).
171#[derive(Serialize, Deserialize)]
172pub struct BlobTombstoneJson {
173    /// The exact stored blob authorized for deletion. Its logical key determines
174    /// the tombstone slot; its object reference determines which provider object
175    /// GC may delete once the grace has passed.
176    pub stored: StoredBlobRef,
177    /// RFC 3339 wall-clock time the deletion was recorded. The grace is measured
178    /// from here; it is signature-covered so the age can't be forged.
179    pub deleted_at: String,
180    /// Hex-encoded Ed25519 public key of the device that wrote this tombstone.
181    pub author_pubkey: String,
182    /// Hex-encoded detached signature over `BlobTombstoneFields`.
183    pub signature: String,
184}
185
186/// The tombstone fields the signature covers, in declaration order. Excludes
187/// `author_pubkey`/`signature` (the signature's own outputs). Includes
188/// `store_id` (so a tombstone can't be replayed into a different store,
189/// mirroring the snapshot payloads) and the exact stored reference (so a valid
190/// tombstone can't be relocated or used to delete another provider object).
191#[derive(Serialize)]
192struct BlobTombstoneFields<'a> {
193    store_id: &'a str,
194    stored: &'a StoredBlobRef,
195    deleted_at: &'a str,
196}
197
198impl BlobTombstoneJson {
199    /// Build a tombstone for `stored` in `store_id` signed by `keypair`:
200    /// fills `author_pubkey` with the device's public key and `signature` with the
201    /// detached signature over the canonical payload (which binds `store_id`,
202    /// the exact stored object and the deletion time). `store_id` is bound but
203    /// not stored — the reader passes its own to `Self::verify`.
204    pub(crate) fn signed(
205        store_id: &str,
206        stored: StoredBlobRef,
207        deleted_at: String,
208        keypair: &UserKeypair,
209    ) -> Self {
210        let payload = tombstone_signing_payload(store_id, &stored, &deleted_at);
211        let sig = keypair.sign(&payload);
212        BlobTombstoneJson {
213            stored,
214            deleted_at,
215            author_pubkey: hex::encode(keypair.public_key()),
216            signature: hex::encode(sig),
217        }
218    }
219
220    /// Verify the embedded signature against the embedded `author_pubkey`, bound to
221    /// `store_id`. A tombstone that fails this is forged, corrupt, tampered (its
222    /// stored reference or `deleted_at` changed after signing), or a different store's
223    /// tombstone replayed here, and must not be acted on. Whether the author is
224    /// *authorized* (a current write-capable member) is a separate check the GC
225    /// runs after this.
226    pub(crate) fn verify(&self, store_id: &str) -> bool {
227        let payload = tombstone_signing_payload(store_id, &self.stored, &self.deleted_at);
228        keys::verify_signature_hex(&self.author_pubkey, &self.signature, &payload)
229    }
230}
231
232fn tombstone_signing_payload(store_id: &str, stored: &StoredBlobRef, deleted_at: &str) -> Vec<u8> {
233    let fields = BlobTombstoneFields {
234        store_id,
235        stored,
236        deleted_at,
237    };
238    serde_json::to_vec(&fields).expect("tombstone fields serialization cannot fail")
239}
240
241impl<'a> TombstoneDrain<'a> {
242    async fn existing_tombstone_state(
243        &self,
244        expected_stored: &StoredBlobRef,
245    ) -> Result<ExistingTombstone, TombstoneDrainError> {
246        let decoded = match self.storage.read_blob_tombstone(expected_stored).await {
247            Ok(Some(decoded)) => decoded,
248            Ok(None) => return Ok(ExistingTombstone::Absent),
249            Err(coven_protocol::objects::StorageError::InvalidContent(error)) => {
250                return Ok(ExistingTombstone::Invalid(InvalidTombstone::Open(
251                    coven_protocol::objects::StorageError::InvalidContent(error),
252                )));
253            }
254            Err(error) => return Err(TombstoneDrainError::Read(error)),
255        };
256        let tombstone: BlobTombstoneJson = match serde_json::from_slice(&decoded) {
257            Ok(tombstone) => tombstone,
258            Err(error) => {
259                return Ok(ExistingTombstone::Invalid(InvalidTombstone::Parse(error)));
260            }
261        };
262        if &tombstone.stored != expected_stored {
263            return Ok(ExistingTombstone::Invalid(
264                InvalidTombstone::StoredReference {
265                    actual: tombstone.stored,
266                    expected: expected_stored.clone(),
267                },
268            ));
269        }
270        if !tombstone.verify(self.store_id) {
271            return Ok(ExistingTombstone::Invalid(InvalidTombstone::Signature));
272        }
273        Ok(ExistingTombstone::Valid)
274    }
275
276    async fn write_signed_tombstone(
277        &self,
278        stored: &StoredBlobRef,
279        deleted_at: &str,
280    ) -> Result<(), TombstoneDrainError> {
281        let tombstone = BlobTombstoneJson::signed(
282            self.store_id,
283            stored.clone(),
284            deleted_at.to_string(),
285            self.keypair,
286        );
287        let bytes = serde_json::to_vec(&tombstone).map_err(TombstoneDrainError::Serialize)?;
288        self.storage
289            .write_blob_tombstone(stored, bytes)
290            .await
291            .map_err(TombstoneDrainError::Write)
292    }
293
294    async fn record_outbox_failure(
295        &self,
296        entry: &OutboxEntry,
297        cloud_key: &str,
298        error: &str,
299        attempted_at: &str,
300    ) -> Result<(), TombstoneDrainError> {
301        if let Err(record_error) = self
302            .db
303            .record_outbox_failure(
304                entry,
305                coven_database::OutboxFailure::other(error),
306                attempted_at,
307            )
308            .await
309        {
310            return Err(TombstoneDrainError::RecordFailure {
311                cloud_key: cloud_key.to_owned(),
312                entry_id: entry.id,
313                source: record_error,
314            });
315        }
316        Ok(())
317    }
318
319    /// Bind every dependency used by one deletion-drain pass.
320    pub(crate) fn new(
321        db: &'a coven_database::StoreDatabase,
322        storage: &'a dyn CloudSyncObjectStorage,
323        store_id: &'a str,
324        keypair: &'a UserKeypair,
325        clock: &'a dyn coven_foundation::clock::Clock,
326    ) -> Self {
327        TombstoneDrain {
328            db,
329            storage,
330            store_id,
331            keypair,
332            clock,
333        }
334    }
335
336    /// Write each due deletion as a signed tombstone, then remove its outbox row.
337    /// Existing valid tombstones keep their original deletion time; any failed
338    /// validation or publication records retry state and fails the pass.
339    pub(crate) async fn drain(&self) -> Result<usize, TombstoneDrainError> {
340        let db = self.db;
341        let clock = self.clock;
342        let deletes = db.pending_blob_deletes().await?;
343
344        let now = clock.now();
345        let now_rfc = now.to_rfc3339();
346        let mut count = 0;
347        let scheduled_deletes = deletes
348            .into_iter()
349            .map(|entry| {
350                crate::blob::retry::entry_in_backoff(&entry, now)
351                    .map(|in_backoff| (entry, in_backoff))
352            })
353            .collect::<Result<Vec<_>, _>>()?;
354        for (entry, in_backoff) in scheduled_deletes {
355            let OutboxOperation::Delete { stored } = &entry.operation else {
356                return Err(TombstoneDrainError::NonDeleteOutboxEntry { entry_id: entry.id });
357            };
358            let cloud_key = stored_cloud_key(stored);
359            if in_backoff {
360                continue;
361            }
362
363            // Write only when the slot is absent or invalid. A valid tombstone already
364            // at the key carries the original `deleted_at` that the grace is measured
365            // from; overwriting it with a fresh `now` would reset the grace, so a row
366            // that re-drains (its prior row-removal failed) must not move the deadline.
367            match self.existing_tombstone_state(stored).await {
368                Ok(ExistingTombstone::Valid) => {
369                    debug!(
370                        %cloud_key,
371                        "tombstone already exists; preserving its deleted_at (not resetting the grace)"
372                    );
373                }
374                Ok(ExistingTombstone::Absent) => {
375                    if let Err(e) = self.write_signed_tombstone(stored, &now_rfc).await {
376                        let detail = e.to_string();
377                        self.record_outbox_failure(&entry, cloud_key, &detail, &now_rfc)
378                            .await?;
379                        return Err(TombstoneDrainError::Publish {
380                            cloud_key: cloud_key.to_owned(),
381                            source: Box::new(e),
382                        });
383                    }
384                    count += 1;
385                }
386                Ok(ExistingTombstone::Invalid(reason)) => {
387                    warn!(
388                        %cloud_key,
389                        reason = %reason,
390                        "replacing invalid tombstone object"
391                    );
392                    if let Err(e) = self.write_signed_tombstone(stored, &now_rfc).await {
393                        let detail = e.to_string();
394                        self.record_outbox_failure(&entry, cloud_key, &detail, &now_rfc)
395                            .await?;
396                        return Err(TombstoneDrainError::Publish {
397                            cloud_key: cloud_key.to_owned(),
398                            source: Box::new(e),
399                        });
400                    }
401                    count += 1;
402                }
403                Err(e) => {
404                    let detail = format!("tombstone validation failed: {e}");
405                    self.record_outbox_failure(&entry, cloud_key, &detail, &now_rfc)
406                        .await?;
407                    return Err(TombstoneDrainError::Validate {
408                        cloud_key: cloud_key.to_owned(),
409                        source: Box::new(e),
410                    });
411                }
412            }
413
414            // The tombstone is present (written now or already there); drop the local
415            // intent row. If this remove fails the row stays and the next drain finds
416            // the tombstone already present, so it removes the row without touching the
417            // tombstone — the deletion is never lost and the grace never moves.
418            db.remove_blob_delete(&entry).await.map_err(|source| {
419                TombstoneDrainError::RemoveOutboxEntry {
420                    entry_id: entry.id,
421                    source,
422                }
423            })?;
424        }
425
426        Ok(count)
427    }
428}