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}