kv-sync: authenticated tombstones (sealed {id, seq, deleted: true})

Tombstones carried no ciphertext, so the server could forge a delete (hiding
an item), and a device that pulled one accepted the next live version without
a seq comparison (delete-then-replay rollback).

* A delete is sealed like an item (same AAD and padding) with plaintext
  {id, seq, deleted: true}; seq = working/base seq + 1. DELETE carries it as
  the ItemPut body; the test server stores and serves it.
* replay: Base is None | LegacyTombstone | Sealed(plaintext). check_pulled
  refuses a record without ciphertext, a deleted flag that disagrees with the
  plaintext, or a tombstone naming another item, then requires seq to grow
  for tombstones too. The `restores` exception is gone: edit beats delete
  re-seals with max(edit, tombstone) + 1. next_version returns just seq.
* Legacy: a pulled tombstone without ciphertext is an integrity alarm (cannot
  be told apart from a forgery). A pending legacy delete is sealed on push; a
  legacy tombstone already held as base is accepted as unknown seq.
* engine: open_sealed, seal_tombstone; next_local_version -> u64. Merkle leaf
  hashes a sealed tombstone's envelope like a live record's.
* kv-model Item: `restores` removed. kv-ffi delete_item seals the tombstone;
  FFI surface unchanged.
* Tests: forged (unsealed and relabelled) tombstones raise an integrity alarm
  (kv-sync and kv-ffi), replay after a tombstone, replays of the tombstone and
  of the restore, legacy pending delete, shared item_replay.json table; the
  live test now deletes an item through the real server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-16 11:34:08 +09:00
co-authored by Claude Opus 5.5
parent abcb758b67
commit cfab3ab543
15 changed files with 397 additions and 166 deletions
+9 -2
View File
@@ -252,8 +252,15 @@ impl ApiClient {
self.json(Method::PUT, &format!("{PATH_VAULTS}/{vault}/items/{item}"), Some(body), Some(base), true).await
}
pub async fn delete_item(&self, vault: Uuid, item: Uuid, base: u64) -> Result<ItemRecord, SyncError> {
self.json(Method::DELETE, &format!("{PATH_VAULTS}/{vault}/items/{item}"), None::<&()>, Some(base), true).await
/// Tombstones an item. `body` is the sealed tombstone `{id, seq, deleted: true}` (docs/03).
pub async fn delete_item(
&self,
vault: Uuid,
item: Uuid,
base: u64,
body: &ItemPut,
) -> Result<ItemRecord, SyncError> {
self.json(Method::DELETE, &format!("{PATH_VAULTS}/{vault}/items/{item}"), Some(body), Some(base), true).await
}
/// `None` when the vault has no manifest yet.
+76 -40
View File
@@ -59,17 +59,28 @@ fn crypto(e: kv_crypto::CryptoError) -> SyncError {
SyncError::Crypto(e.to_string())
}
/// Decrypts an item record's payload into JSON.
/// Decrypts a live item record's payload into JSON. `None` for a tombstone.
pub fn open_record(
account_id: Uuid,
vault_id: Uuid,
vk: &SymKey,
record: &ItemRecord,
) -> Result<Option<Value>, SyncError> {
let (Some(k), Some(p)) = (&record.wrapped_item_key, &record.payload) else { return Ok(None) };
if record.deleted {
return Ok(None);
}
open_sealed(account_id, vault_id, vk, record)
}
/// Decrypts the plaintext of a live record or a sealed tombstone. `None` when the record carries no
/// ciphertext (a legacy tombstone).
pub fn open_sealed(
account_id: Uuid,
vault_id: Uuid,
vk: &SymKey,
record: &ItemRecord,
) -> Result<Option<Value>, SyncError> {
let (Some(k), Some(p)) = (&record.wrapped_item_key, &record.payload) else { return Ok(None) };
let sealed = SealedItem { wrapped_item_key: k.clone(), payload: p.clone() };
let pt = open_item(account_id, vault_id, record.item_id, vk, &sealed).map_err(crypto)?;
serde_json::from_slice(&pt).map(Some).map_err(|e| SyncError::Crypto(format!("item JSON: {e}")))
@@ -97,6 +108,20 @@ pub fn seal_record(
})
}
/// Seals a tombstone `{id, seq, deleted: true}` into a new (local) deleted record. `seq` comes from
/// [`next_local_version`] (docs/03 "Per-item replay protection").
pub fn seal_tombstone(
account_id: Uuid,
vault_id: Uuid,
vk: &SymKey,
item_id: Uuid,
seq: u64,
device_id: Uuid,
) -> Result<ItemRecord, SyncError> {
let record = seal_record(account_id, vault_id, vk, item_id, &replay::tombstone(item_id, seq), device_id)?;
Ok(ItemRecord { deleted: true, ..record })
}
fn history_entry(r: &ItemRecord) -> Option<HistoryEntry> {
Some(HistoryEntry {
revision: r.revision,
@@ -106,20 +131,20 @@ fn history_entry(r: &ItemRecord) -> Option<HistoryEntry> {
})
}
/// The decrypted form of a local base, for [`replay`]. Owns the plaintext a [`Base::Live`] borrows.
/// The decrypted form of a local base, for [`replay`]. Owns the plaintext a [`Base::Sealed`] borrows.
enum OpenedBase {
None,
Tombstone(u64),
Live(u64, Value),
LegacyTombstone,
Sealed(Value),
}
impl OpenedBase {
fn open(account_id: Uuid, vault_id: Uuid, vk: &SymKey, base: Option<&ItemRecord>) -> Result<Self, SyncError> {
Ok(match base {
None => Self::None,
Some(b) if b.deleted => Self::Tombstone(b.revision),
Some(b) => match open_record(account_id, vault_id, vk, b)? {
Some(v) => Self::Live(b.revision, v),
Some(b) => match open_sealed(account_id, vault_id, vk, b)? {
Some(v) => Self::Sealed(v),
None if b.deleted => Self::LegacyTombstone,
None => Self::None,
},
})
@@ -128,23 +153,23 @@ impl OpenedBase {
fn as_base(&self) -> Base<'_> {
match self {
Self::None => Base::None,
Self::Tombstone(revision) => Base::Tombstone { revision: *revision },
Self::Live(revision, item) => Base::Live { revision: *revision, item },
Self::LegacyTombstone => Base::LegacyTombstone,
Self::Sealed(v) => Base::Sealed(v),
}
}
}
/// The `seq` and `restores` a new local version of `item_id` must carry (docs/03 "Per-item replay
/// protection"). Call it before sealing a local edit and stamp the result into the plaintext.
/// The `seq` a new local version of `item_id`, live or tombstone, must carry (docs/03 "Per-item replay
/// protection"). Call it before sealing a local edit or delete and stamp the result into the plaintext.
pub fn next_local_version(
store: &dyn Store,
account_id: Uuid,
vault_id: Uuid,
vk: &SymKey,
item_id: Uuid,
) -> Result<(u64, Option<u64>), SyncError> {
let Some(local) = store.get(vault_id, item_id)? else { return Ok((1, None)) };
let working = open_record(account_id, vault_id, vk, &local.record)?;
) -> Result<u64, SyncError> {
let Some(local) = store.get(vault_id, item_id)? else { return Ok(1) };
let working = open_sealed(account_id, vault_id, vk, &local.record)?;
let base = OpenedBase::open(account_id, vault_id, vk, local.base.as_ref())?;
Ok(replay::next_version(working.as_ref(), base.as_base()))
}
@@ -306,8 +331,8 @@ impl SyncContext<'_> {
self.resolve_conflict(vault, vk, local, remote, report)
}
/// Integrity alarm unless `remote` authenticates and may follow `base` (docs/03 "Per-item replay
/// protection"). Tombstones carry no ciphertext and pass.
/// Integrity alarm unless `remote`, live or tombstone, authenticates, is what its `deleted` flag says
/// it is, and may follow `base` (docs/03 "Per-item replay protection").
fn check_replay(
&self,
vault: Uuid,
@@ -315,17 +340,12 @@ impl SyncContext<'_> {
base: Option<&ItemRecord>,
remote: &ItemRecord,
) -> Result<(), SyncError> {
if remote.deleted {
return Ok(());
}
let id = remote.item_id;
let incoming = match open_record(self.account_id(), vault, vk, remote) {
Ok(Some(v)) => v,
Ok(None) => return Err(self.alarm(vault, format!("item {id}: live record without ciphertext"))),
Err(_) => return Err(self.alarm(vault, format!("item {id}: envelope does not authenticate"))),
let Ok(incoming) = open_sealed(self.account_id(), vault, vk, remote) else {
return Err(self.alarm(vault, format!("item {id}: envelope does not authenticate")));
};
let base = OpenedBase::open(self.account_id(), vault, vk, base)?;
replay::check_successor(base.as_base(), remote.revision, &incoming)
replay::check_pulled(base.as_base(), id, remote.deleted, incoming.as_ref())
.map_err(|e| self.alarm(vault, format!("item {id}: {e}")))
}
@@ -352,13 +372,15 @@ impl SyncContext<'_> {
report.restored.push(id);
}
(false, true) => {
// Our edit beats their delete: keep ours and push it on top of the tombstone, marked with
// the tombstone's revision so devices that never saw the delete accept a lower `seq`.
// Our edit beats their delete: keep ours and push it on top of the tombstone with a `seq`
// above both, so every device sees `seq` grow (docs/03 "Per-item replay protection").
let acct = self.account_id();
let mut mine =
open_record(acct, vault, vk, &local.record)?.ok_or(SyncError::Protocol("empty item".into()))?;
let seq = replay::seq_of(&mine);
replay::stamp(&mut mine, seq, Some(remote.revision));
let tomb =
open_sealed(acct, vault, vk, remote)?.ok_or(SyncError::Protocol("empty tombstone".into()))?;
let seq = replay::seq_of(&mine).max(replay::seq_of(&tomb)) + 1;
replay::stamp(&mut mine, seq);
let sealed = seal_record(acct, vault, vk, id, &mine, self.device_id)?;
self.store.put(vault, &rebased(sealed))?;
report.restored.push(id);
@@ -387,7 +409,7 @@ impl SyncContext<'_> {
self.store.put_remote(vault, remote)?;
} else {
let seq = replay::seq_of(&mine).max(replay::seq_of(&theirs)) + 1;
replay::stamp(&mut out.merged, seq, None);
replay::stamp(&mut out.merged, seq);
let sealed = seal_record(acct, vault, vk, id, &out.merged, self.device_id)?;
self.store.put(vault, &rebased(sealed))?;
}
@@ -475,17 +497,22 @@ impl SyncContext<'_> {
for item in pending {
let base = item.base_revision();
let id = item.record.item_id;
let result = if item.record.deleted {
if base == 0 {
self.store.remove(vault, id)?;
continue;
}
self.api.delete_item(vault, id, base).await
if item.record.deleted && base == 0 {
self.store.remove(vault, id)?;
continue;
}
let item = if item.record.deleted && item.record.payload.is_none() {
self.seal_legacy_delete(vault, vk, item)?
} else {
item
};
let (Some(k), Some(p)) = (&item.record.wrapped_item_key, &item.record.payload) else {
return Err(SyncError::Protocol(format!("pending item {id} has no ciphertext")));
};
let body = ItemPut { wrapped_item_key: k.clone(), payload: p.clone() };
let result = if item.record.deleted {
self.api.delete_item(vault, id, base, &body).await
} else {
let (Some(k), Some(p)) = (&item.record.wrapped_item_key, &item.record.payload) else {
return Err(SyncError::Protocol(format!("pending item {id} has no ciphertext")));
};
let body = ItemPut { wrapped_item_key: k.clone(), payload: p.clone() };
self.api.put_item(vault, id, base, &body).await
};
match result {
@@ -507,6 +534,15 @@ impl SyncContext<'_> {
Ok(pushed)
}
/// Seals a pending delete stored before tombstones were sealed.
fn seal_legacy_delete(&self, vault: Uuid, vk: &SymKey, item: LocalItem) -> Result<LocalItem, SyncError> {
let id = item.record.item_id;
let seq = next_local_version(self.store, self.account_id(), vault, vk, id)?;
let tomb = seal_tombstone(self.account_id(), vault, vk, id, seq, self.device_id)?;
self.store.put_local(vault, &tomb)?;
Ok(self.store.get(vault, id)?.ok_or(kv_store::StoreError::NotFound)?)
}
/// Records the server's acceptance, unless the item was edited again while the request was in flight.
fn confirm(&self, vault: Uuid, pushed: &LocalItem, confirmed: ItemRecord) -> Result<(), SyncError> {
let current = self.store.get(vault, confirmed.item_id)?;
+1 -1
View File
@@ -8,7 +8,7 @@
//! a callback into platform code ([`DeviceSigner`]).
//! * [`engine`]: pull, then push with `If-Match`, then three-way field merge on 412 ([`merge`]), then manifest verify
//! and publish ([`manifest`]).
//! * [`replay`]: per-item replay check on pull (the `seq` / `restores` fields of the item plaintext).
//! * [`replay`]: per-item replay check on pull (the `seq` field sealed into every item version and tombstone).
pub mod client;
pub mod engine;
+4 -3
View File
@@ -123,11 +123,12 @@ pub fn envelope_cbor(e: &Envelope) -> Vec<u8> {
out
}
/// `blake3(envelope)` for one item record.
/// `blake3(envelope)` for one item record. A sealed tombstone hashes its envelope like a live record; only
/// a legacy tombstone without ciphertext hashes the empty array.
pub fn content_hash(record: &ItemRecord) -> [u8; 32] {
let mut out = Vec::new();
match (&record.wrapped_item_key, &record.payload, record.deleted) {
(Some(k), Some(p), false) => {
match (&record.wrapped_item_key, &record.payload) {
(Some(k), Some(p)) => {
cbor::array(&mut out, 2);
out.extend_from_slice(&envelope_cbor(k));
out.extend_from_slice(&envelope_cbor(p));
+4 -4
View File
@@ -2,12 +2,12 @@
//!
//! Items are merged as JSON objects so every top-level field of `kv_model::Item` (and any field a
//! newer client adds) is a merge unit. `id` and `created_at` come from the base, `modified_at` and
//! `seq` are the maximum of both sides, and `restores` is dropped. The caller stamps the next `seq` if it
//! writes the result as a new version ([`crate::replay`]).
//! `seq` are the maximum of both sides. The caller stamps the next `seq` if it writes the result as a new
//! version ([`crate::replay`]).
use serde_json::{Map, Value};
use crate::replay::{seq_of, RESTORES, SEQ};
use crate::replay::{seq_of, SEQ};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
@@ -25,7 +25,7 @@ pub struct MergeOutcome {
pub conflicting_fields: Vec<String>,
}
const FIXED: [&str; 5] = ["id", "created_at", "modified_at", SEQ, RESTORES];
const FIXED: [&str; 4] = ["id", "created_at", "modified_at", SEQ];
fn modified_at(v: &Value) -> i64 {
v.get("modified_at").and_then(Value::as_i64).unwrap_or(0)
+82 -53
View File
@@ -1,43 +1,46 @@
//! Per-item replay protection (docs/03 "Per-item replay protection").
//!
//! The AAD binds an envelope to its item but not to a server revision, so a server could re-serve an
//! old, authentic envelope under a new revision. Every item plaintext therefore carries `seq`, an edit
//! counter that grows along every honest chain of versions, and `restores`, the revision of the
//! tombstone a version was written over (tombstones carry no `seq`). Pulled records are checked
//! against the local base, the last version the server confirmed to this device.
//! old, authentic envelope under a new revision. Every version of an item, live or deleted, is sealed
//! and carries `seq`, an edit counter that grows along every honest chain of versions. A tombstone's
//! plaintext is `{id, seq, deleted: true}`. Pulled records are checked against the local base, the
//! last version the server confirmed to this device.
use serde_json::Value;
use serde_json::{json, Value};
use uuid::Uuid;
/// The plaintext field holding the per-item edit counter. Absent means 0.
pub const SEQ: &str = "seq";
/// The plaintext field holding the revision of the tombstone a version was written over.
pub const RESTORES: &str = "restores";
/// The plaintext field that marks a tombstone.
pub const DELETED: &str = "deleted";
pub fn seq_of(item: &Value) -> u64 {
item.get(SEQ).and_then(Value::as_u64).unwrap_or(0)
}
pub fn restores_of(item: &Value) -> Option<u64> {
item.get(RESTORES).and_then(Value::as_u64)
/// Sets `seq`.
pub fn stamp(item: &mut Value, seq: u64) {
if let Some(obj) = item.as_object_mut() {
obj.insert(SEQ.into(), Value::from(seq));
}
}
/// Sets `seq` and `restores` (removing `restores` when `None`).
pub fn stamp(item: &mut Value, seq: u64, restores: Option<u64>) {
let Some(obj) = item.as_object_mut() else { return };
obj.insert(SEQ.into(), Value::from(seq));
match restores {
Some(t) => obj.insert(RESTORES.into(), Value::from(t)),
None => obj.remove(RESTORES),
};
/// The plaintext of a tombstone for item `id`.
pub fn tombstone(id: Uuid, seq: u64) -> Value {
json!({ "id": id, SEQ: seq, DELETED: true })
}
/// Whether two item plaintexts have the same content, ignoring `seq` and `restores`.
/// Whether a plaintext is a tombstone (`deleted: true`).
pub fn is_tombstone(plaintext: &Value) -> bool {
plaintext.get(DELETED) == Some(&Value::Bool(true))
}
/// Whether two item plaintexts have the same content, ignoring `seq`.
pub fn same_content(a: &Value, b: &Value) -> bool {
let strip = |v: &Value| {
let mut v = v.clone();
if let Some(o) = v.as_object_mut() {
o.remove(SEQ);
o.remove(RESTORES);
}
v
};
@@ -49,13 +52,10 @@ pub fn same_content(a: &Value, b: &Value) -> bool {
pub enum Base<'a> {
/// This device holds no server version of the item.
None,
Tombstone {
revision: u64,
},
Live {
revision: u64,
item: &'a Value,
},
/// A tombstone without ciphertext, accepted before tombstones were sealed. Its `seq` is unknown.
LegacyTombstone,
/// The decrypted plaintext of a live version or a sealed tombstone.
Sealed(&'a Value),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
@@ -65,17 +65,41 @@ pub struct Replay {
pub incoming: u64,
}
/// Checks that `incoming` (a live record at server `revision`) may follow `base`.
pub fn check_successor(base: Base<'_>, revision: u64, incoming: &Value) -> Result<(), Replay> {
let Base::Live { revision: base_revision, item: held } = base else { return Ok(()) };
/// Why a pulled record is refused. Every variant is an integrity alarm.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Rejected {
#[error("tombstone without ciphertext (unauthenticated delete)")]
UnsealedTombstone,
#[error("live record without ciphertext")]
UnsealedItem,
#[error("deleted flag does not match the sealed plaintext")]
KindMismatch,
#[error("tombstone names another item")]
WrongItem,
#[error(transparent)]
Replay(#[from] Replay),
}
/// Checks a pulled record of item `id`: its `deleted` flag and its authenticated plaintext (`None` when it
/// carries no ciphertext) against the local base.
pub fn check_pulled(base: Base<'_>, id: Uuid, deleted: bool, plaintext: Option<&Value>) -> Result<(), Rejected> {
let incoming = plaintext.ok_or(if deleted { Rejected::UnsealedTombstone } else { Rejected::UnsealedItem })?;
if is_tombstone(incoming) != deleted {
return Err(Rejected::KindMismatch);
}
if deleted && incoming.get("id") != Some(&Value::from(id.to_string())) {
return Err(Rejected::WrongItem);
}
Ok(check_successor(base, incoming)?)
}
/// Checks that `incoming` (the plaintext of a pulled live record or tombstone) may follow `base`.
pub fn check_successor(base: Base<'_>, incoming: &Value) -> Result<(), Replay> {
let Base::Sealed(held) = base else { return Ok(()) };
let (held_seq, seq) = (seq_of(held), seq_of(incoming));
if seq > held_seq {
return Ok(());
}
// A delete this device never saw came between its base and `incoming`.
if restores_of(incoming).is_some_and(|t| base_revision < t && t < revision) {
return Ok(());
}
// The same content again is not a rollback.
if seq == held_seq && incoming == held {
return Ok(());
@@ -83,18 +107,13 @@ pub fn check_successor(base: Base<'_>, revision: u64, incoming: &Value) -> Resul
Err(Replay { held: held_seq, incoming: seq })
}
/// `seq` and `restores` for a new local version of an item whose working copy is `local` (if live).
pub fn next_version(local: Option<&Value>, base: Base<'_>) -> (u64, Option<u64>) {
/// `seq` for a new local version (live or tombstone) of an item whose working copy is `local`.
pub fn next_version(local: Option<&Value>, base: Base<'_>) -> u64 {
let held = match base {
Base::Live { item, .. } => seq_of(item),
Base::Sealed(v) => seq_of(v),
_ => 0,
};
let seq = local.map_or(0, seq_of).max(held) + 1;
let restores = match base {
Base::Tombstone { revision } => Some(revision),
_ => None,
};
(seq, restores)
local.map_or(0, seq_of).max(held) + 1
}
#[cfg(test)]
@@ -105,25 +124,35 @@ mod tests {
#[test]
fn stamp_and_same_content() {
let mut v = json!({"title": "a", "restores": 4});
stamp(&mut v, 7, None);
let mut v = json!({"title": "a"});
stamp(&mut v, 7);
assert_eq!(v, json!({"title": "a", "seq": 7}));
stamp(&mut v, 8, Some(3));
assert_eq!(v, json!({"title": "a", "seq": 8, "restores": 3}));
assert!(same_content(&v, &json!({"title": "a"})));
assert!(!same_content(&v, &json!({"title": "b", "seq": 8})));
assert!(!same_content(&v, &json!({"title": "b", "seq": 7})));
}
#[test]
fn tombstone_plaintext() {
let id = Uuid::nil();
let t = tombstone(id, 4);
assert_eq!(t, json!({"id": "00000000-0000-0000-0000-000000000000", "seq": 4, "deleted": true}));
assert!(is_tombstone(&t));
assert!(!is_tombstone(&json!({"title": "a", "deleted": false})));
assert_eq!(seq_of(&t), 4);
}
#[test]
fn next_version_rules() {
let base = json!({"seq": 5});
let local = json!({"seq": 6});
assert_eq!(next_version(None, Base::None), (1, None));
assert_eq!(next_version(Some(&base), Base::Live { revision: 3, item: &base }), (6, None));
assert_eq!(next_version(Some(&local), Base::Live { revision: 3, item: &base }), (7, None));
let tomb = tombstone(Uuid::nil(), 9);
assert_eq!(next_version(None, Base::None), 1);
assert_eq!(next_version(Some(&base), Base::Sealed(&base)), 6);
assert_eq!(next_version(Some(&local), Base::Sealed(&base)), 7);
// A pending delete being re-saved continues from the base.
assert_eq!(next_version(None, Base::Live { revision: 3, item: &base }), (6, None));
assert_eq!(next_version(None, Base::Tombstone { revision: 9 }), (1, Some(9)));
assert_eq!(next_version(Some(&local), Base::Tombstone { revision: 9 }), (7, Some(9)));
assert_eq!(next_version(None, Base::Sealed(&base)), 6);
assert_eq!(next_version(None, Base::Sealed(&tomb)), 10);
assert_eq!(next_version(Some(&local), Base::Sealed(&tomb)), 10);
assert_eq!(next_version(None, Base::LegacyTombstone), 1);
}
}
+7 -5
View File
@@ -387,7 +387,9 @@ fn authed(
let vid = Uuid::parse_str(vid).map_err(|_| err(404, "not_found"))?;
let iid = Uuid::parse_str(iid).map_err(|_| err(404, "not_found"))?;
let base = if_match(req)?;
let body: Option<ItemPut> = if method == "PUT" { Some(parse(req)?) } else { None };
// PUT carries the item, DELETE the sealed tombstone; both have the same shape.
let body: ItemPut = parse(req)?;
let deleted = method == "DELETE";
if st.force_412 > 0 {
st.force_412 -= 1;
return Err(err(412, "revision_conflict"));
@@ -397,16 +399,16 @@ fn authed(
if current != base {
return Err(err(412, "revision_conflict"));
}
if body.is_none() && current == 0 {
if deleted && current == 0 {
return Err(err(404, "not_found"));
}
v.head += 1;
let rec = ItemRecord {
item_id: iid,
revision: v.head,
deleted: body.is_none(),
wrapped_item_key: body.as_ref().map(|b| b.wrapped_item_key.clone()),
payload: body.map(|b| b.payload),
deleted,
wrapped_item_key: Some(body.wrapped_item_key),
payload: Some(body.payload),
updated_by: device,
updated_at: now(),
};