Skip to main content

mnem_core/objects/
tombstone.rs

1//! [`Tombstone`] - logical "forget this node" marker (SPEC §4, mnem/0.2+).
2//!
3//! Agents periodically need to revoke a memory ("User said forget X")
4//! without mutating the append-only, content-addressed node record. A
5//! Tombstone is a small side-record stored on the [`View`] that records
6//! the intent-to-forget: a [`NodeId`][crate::id::NodeId], the reason, and
7//! the microsecond timestamp the tombstone was written.
8//!
9//! Semantics:
10//!
11//! - The underlying [`crate::objects::Node`] remains in the node Prolly
12//!   tree. Its CID is unchanged. Prior commits that referenced it still
13//!   resolve.
14//! - Retrieval paths filter tombstoned nodes by default
15//!   ([`crate::retrieve::Retriever::include_tombstoned`] opts out).
16//! - Re-tombstoning the same `NodeId` is a no-op at the semantic level:
17//!   the second call overwrites the first's reason and timestamp, but
18//!   no additional state change is observable to a retrieve or to a
19//!   subsequent `is_tombstoned` query.
20//!
21//! Documented in SPEC §4.10.
22//!
23//! [`View`]: crate::objects::View
24
25use std::collections::BTreeMap;
26
27use ipld_core::ipld::Ipld;
28use serde::{Deserialize, Deserializer, Serialize, Serializer};
29
30/// A logical forget-marker attached to a [`NodeId`].
31///
32/// [`NodeId`]: crate::id::NodeId
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct Tombstone {
35    /// Free-form UTF-8 reason string. MAY be empty.
36    pub reason: String,
37    /// Microseconds since Unix epoch when the tombstone was recorded.
38    pub tombstoned_at: u64,
39    /// Forward-compat extension map (SPEC §3.2).
40    pub extra: BTreeMap<String, Ipld>,
41}
42
43impl Tombstone {
44    /// The `_kind` discriminator on the wire.
45    pub const KIND: &'static str = "tombstone";
46
47    /// Construct a tombstone with the given reason + timestamp.
48    #[must_use]
49    pub fn new(reason: impl Into<String>, tombstoned_at: u64) -> Self {
50        Self {
51            reason: reason.into(),
52            tombstoned_at,
53            extra: BTreeMap::new(),
54        }
55    }
56}
57
58// ---------------- Serde ----------------
59
60#[derive(Serialize, Deserialize)]
61struct TombstoneWire {
62    #[serde(rename = "_kind")]
63    kind: String,
64    reason: String,
65    tombstoned_at: u64,
66    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
67    extra: BTreeMap<String, Ipld>,
68}
69
70impl Serialize for Tombstone {
71    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
72        TombstoneWire {
73            kind: Self::KIND.into(),
74            reason: self.reason.clone(),
75            tombstoned_at: self.tombstoned_at,
76            extra: self.extra.clone(),
77        }
78        .serialize(serializer)
79    }
80}
81
82impl<'de> Deserialize<'de> for Tombstone {
83    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
84        let w = TombstoneWire::deserialize(deserializer)?;
85        if w.kind != Self::KIND {
86            return Err(serde::de::Error::custom(format!(
87                "expected _kind='{}', got '{}'",
88                Self::KIND,
89                w.kind
90            )));
91        }
92        Ok(Self {
93            reason: w.reason,
94            tombstoned_at: w.tombstoned_at,
95            extra: w.extra,
96        })
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::codec::{from_canonical_bytes, to_canonical_bytes};
104
105    #[test]
106    fn tombstone_round_trip_byte_identity() {
107        let t = Tombstone::new("user asked to forget", 1_700_000_000_000_000);
108        let bytes = to_canonical_bytes(&t).unwrap();
109        let decoded: Tombstone = from_canonical_bytes(&bytes).unwrap();
110        assert_eq!(t, decoded);
111        let bytes2 = to_canonical_bytes(&decoded).unwrap();
112        assert_eq!(bytes, bytes2);
113    }
114
115    #[test]
116    fn tombstone_kind_rejection() {
117        let w = TombstoneWire {
118            kind: "node".into(),
119            reason: "x".into(),
120            tombstoned_at: 0,
121            extra: BTreeMap::new(),
122        };
123        let bytes = serde_ipld_dagcbor::to_vec(&w).unwrap();
124        let err = serde_ipld_dagcbor::from_slice::<Tombstone>(&bytes).unwrap_err();
125        assert!(err.to_string().contains("_kind"));
126    }
127
128    #[test]
129    fn tombstone_empty_reason_round_trips() {
130        let t = Tombstone::new("", 42);
131        let bytes = to_canonical_bytes(&t).unwrap();
132        let decoded: Tombstone = from_canonical_bytes(&bytes).unwrap();
133        assert_eq!(t, decoded);
134        assert!(decoded.reason.is_empty());
135    }
136}