Skip to main content

warden/store/
id.rs

1//! Content-derived event ids, so re-ingest dedupes.
2//!
3//! The id is a truncated SHA-256 over the identifying parts of an event, joined
4//! with a separator that cannot appear in a length-prefixed encoding. It is
5//! stable across runs, processes, and machines.
6
7use sha2::{Digest, Sha256};
8
9/// Length of a rendered id in hex characters.
10const ID_HEX_LEN: usize = 32;
11
12/// The identifying content of an event. Two source records with identical
13/// identity produce the same event id.
14#[derive(Debug, Clone, Copy, Default)]
15pub struct EventIdentity<'a> {
16    pub agent: &'a str,
17    pub session_id: Option<&'a str>,
18    /// The source record's own id (e.g. Claude Code's `uuid`), when it has one.
19    pub source_id: Option<&'a str>,
20    pub ts: i64,
21    pub role: &'a str,
22    /// Any extra discriminator an adapter needs to keep sibling records apart.
23    pub extra: Option<&'a str>,
24}
25
26/// Deterministic id for an event.
27pub fn event_id(identity: EventIdentity<'_>) -> String {
28    let mut hasher = Sha256::new();
29    write_field(&mut hasher, identity.agent.as_bytes());
30    write_field(&mut hasher, identity.session_id.unwrap_or("").as_bytes());
31    write_field(&mut hasher, identity.source_id.unwrap_or("").as_bytes());
32    write_field(&mut hasher, &identity.ts.to_be_bytes());
33    write_field(&mut hasher, identity.role.as_bytes());
34    write_field(&mut hasher, identity.extra.unwrap_or("").as_bytes());
35    hex(&hasher.finalize())[..ID_HEX_LEN].to_string()
36}
37
38/// Deterministic hash of prompt text, used for exact-duplicate detection.
39pub fn text_hash(text: &str) -> String {
40    let mut hasher = Sha256::new();
41    hasher.update(text.as_bytes());
42    hex(&hasher.finalize())[..ID_HEX_LEN].to_string()
43}
44
45/// Length-prefixed so concatenation is unambiguous: `("ab","c")` and
46/// `("a","bc")` must not hash alike.
47fn write_field(hasher: &mut Sha256, bytes: &[u8]) {
48    hasher.update((bytes.len() as u64).to_be_bytes());
49    hasher.update(bytes);
50}
51
52fn hex(bytes: &[u8]) -> String {
53    let mut out = String::with_capacity(bytes.len() * 2);
54    for byte in bytes {
55        use std::fmt::Write as _;
56        let _ = write!(out, "{byte:02x}");
57    }
58    out
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    fn identity() -> EventIdentity<'static> {
66        EventIdentity {
67            agent: "claude-code",
68            session_id: Some("sess-1"),
69            source_id: Some("uuid-1"),
70            ts: 1_754_300_000_000,
71            role: "assistant",
72            extra: None,
73        }
74    }
75
76    #[test]
77    fn same_content_same_id() {
78        assert_eq!(event_id(identity()), event_id(identity()));
79    }
80
81    #[test]
82    fn id_is_stable_across_runs() {
83        // Pinned: a change here means previously ingested events would re-add.
84        assert_eq!(event_id(identity()), "6cbc1e2d028a0bc4fee7ae15c0405df6");
85    }
86
87    #[test]
88    fn different_content_different_id() {
89        let base = event_id(identity());
90        let mut other = identity();
91        other.ts += 1;
92        assert_ne!(event_id(other), base);
93
94        let mut other = identity();
95        other.role = "user";
96        assert_ne!(event_id(other), base);
97
98        let mut other = identity();
99        other.extra = Some("1");
100        assert_ne!(event_id(other), base);
101    }
102
103    #[test]
104    fn field_boundaries_are_unambiguous() {
105        let a = EventIdentity {
106            agent: "ab",
107            session_id: Some("c"),
108            ..EventIdentity::default()
109        };
110        let b = EventIdentity {
111            agent: "a",
112            session_id: Some("bc"),
113            ..EventIdentity::default()
114        };
115        assert_ne!(event_id(a), event_id(b));
116    }
117
118    #[test]
119    fn text_hash_is_deterministic() {
120        assert_eq!(text_hash("run the tests"), text_hash("run the tests"));
121        assert_ne!(text_hash("run the tests"), text_hash("run the test"));
122        assert_eq!(text_hash("").len(), ID_HEX_LEN);
123    }
124}