Skip to main content

kernel/
idem.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! IdemKey: the dedup key for outward actions.
7//! The part `crates/kernel/spec/Idem.lean` specifies this module.
8//!
9//! Derivation is the whole point: `BLAKE3-XOF(run(16B) || seq(8B LE) ||
10//! action_canonical)` taken as a direct 16-byte output (not a truncation),
11//! plus one version byte carried alongside so a future derivation change
12//! cannot collide old keys with new ones. No `From<Uuid>`, no randomness,
13//! no timestamps: resume and replay must re-derive the identical key, or
14//! the double-payment defense dies exactly on the recovery path.
15
16use std::collections::BTreeSet;
17
18use serde::{Deserialize, Serialize};
19
20use crate::event::{RunId, Seq};
21
22/// Current derivation-scheme version byte.
23pub const IDEM_DERIVE_V: u8 = 1;
24
25/// Idempotency key: 16 digest bytes plus the derivation version.
26/// Private fields; [`IdemKey::derive`] is the only mint.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct IdemKey {
29    v: u8,
30    digest: [u8; 16],
31}
32
33impl IdemKey {
34    /// The sole construction point (`kernel::idem::derive`).
35    /// Fixed-width run and seq make the framing injective without length
36    /// prefixes; the action canonicalization rule is `ToolCall::action`'s.
37    pub fn derive(run: &RunId, seq: Seq, action_canonical: &[u8]) -> IdemKey {
38        let mut hasher = blake3::Hasher::new();
39        hasher.update(run.as_bytes());
40        hasher.update(&seq.value().to_le_bytes());
41        hasher.update(action_canonical);
42        let mut digest = [0u8; 16];
43        hasher.finalize_xof().fill(&mut digest);
44        IdemKey {
45            v: IDEM_DERIVE_V,
46            digest,
47        }
48    }
49
50    fn parse(raw: &str) -> Option<IdemKey> {
51        let rest = raw.strip_prefix("idem")?;
52        let (v_raw, hex) = rest.split_once('-')?;
53        if v_raw != "1" {
54            return None;
55        }
56        let digest = crate::locator::decode_hex_fixed::<16>(hex)?;
57        Some(IdemKey {
58            v: IDEM_DERIVE_V,
59            digest,
60        })
61    }
62}
63
64/// The right to perform one unreplayable side effect, once.
65///
66/// A guard exists only because [`claim`] put its key into a seen set,
67/// and the entrance to every effect that cannot be replayed —
68/// decrypting, billing, outward delivery (8.2) — asks for one by
69/// reference. That turns "judge duplicates before you act" from a
70/// sentence in a comment into something the compiler checks: an
71/// entrance that wants a guard cannot be called by a caller that never
72/// claimed.
73#[derive(Debug, PartialEq, Eq)]
74pub struct IdemGuard {
75    key: IdemKey,
76}
77
78impl IdemGuard {
79    /// The key this guard was claimed under, for the record the effect
80    /// writes.
81    #[must_use]
82    pub fn key(&self) -> &IdemKey {
83        &self.key
84    }
85}
86
87/// A key that was already claimed. The caller answers with what the
88/// first claim produced rather than doing the work twice.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct Duplicate {
91    key: IdemKey,
92}
93
94impl Duplicate {
95    #[must_use]
96    pub fn key(&self) -> &IdemKey {
97        &self.key
98    }
99}
100
101/// Claims `key` in the caller's seen set, handing back the right to
102/// act once.
103///
104/// The set stays the caller's state — kernel holds none — and the
105/// claim is the only way to add to it, so a key that is in the set is
106/// a key some caller was already given the right for.
107///
108/// # Errors
109/// [`Duplicate`] when the key is already in the set, carrying that key
110/// so the caller can look up the answer it gave the first time.
111pub fn claim(seen: &mut BTreeSet<IdemKey>, key: IdemKey) -> Result<IdemGuard, Duplicate> {
112    if seen.contains(&key) {
113        return Err(Duplicate { key });
114    }
115    seen.insert(key);
116    Ok(IdemGuard { key })
117}
118
119impl std::fmt::Display for IdemKey {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        write!(f, "idem{}-", self.v)?;
122        crate::locator::write_hex(f, &self.digest)
123    }
124}
125
126impl Serialize for IdemKey {
127    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
128        serializer.collect_str(self)
129    }
130}
131
132impl<'de> Deserialize<'de> for IdemKey {
133    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
134        let raw = String::deserialize(deserializer)?;
135        IdemKey::parse(&raw).ok_or_else(|| {
136            serde::de::Error::custom("expected `idem1-` plus 32 lowercase hex digits")
137        })
138    }
139}
140
141#[cfg(test)]
142#[allow(
143    clippy::unwrap_used,
144    clippy::expect_used,
145    clippy::panic,
146    clippy::indexing_slicing,
147    reason = "test code"
148)]
149mod tests {
150    use super::*;
151    use crate::event::{RunId, Seq};
152
153    fn run() -> RunId {
154        RunId::from_bytes([7; 16])
155    }
156
157    #[test]
158    fn same_inputs_same_key_always() {
159        let a = IdemKey::derive(&run(), Seq::new(42), b"tool:exec{cmd}");
160        let b = IdemKey::derive(&run(), Seq::new(42), b"tool:exec{cmd}");
161        assert_eq!(a, b);
162    }
163
164    #[test]
165    fn any_input_change_changes_the_key() {
166        let base = IdemKey::derive(&run(), Seq::new(42), b"action");
167        assert_ne!(base, IdemKey::derive(&run(), Seq::new(43), b"action"));
168        assert_ne!(
169            base,
170            IdemKey::derive(&RunId::from_bytes([8; 16]), Seq::new(42), b"action")
171        );
172        assert_ne!(base, IdemKey::derive(&run(), Seq::new(42), b"actioN"));
173    }
174
175    #[test]
176    fn display_carries_the_derivation_version() {
177        let key = IdemKey::derive(&run(), Seq::FIRST, b"x");
178        let text = key.to_string();
179        assert!(text.starts_with("idem1-"), "{text}");
180        assert_eq!(text.len(), "idem1-".len() + 32, "16 bytes as 32 hex digits");
181    }
182
183    #[test]
184    fn serde_roundtrips_the_string_form() {
185        let key = IdemKey::derive(&run(), Seq::new(5), b"payload");
186        let json = serde_json::to_string(&key).unwrap();
187        let back: IdemKey = serde_json::from_str(&json).unwrap();
188        assert_eq!(back, key);
189        assert!(serde_json::from_str::<IdemKey>("\"idem1-zz\"").is_err());
190        assert!(
191            serde_json::from_str::<IdemKey>("\"idem9-00000000000000000000000000000000\"").is_err()
192        );
193    }
194
195    #[test]
196    fn a_key_is_claimable_once_and_the_second_claim_names_it() {
197        let key = IdemKey::derive(&run(), Seq::FIRST, b"send mail");
198        let other = IdemKey::derive(&run(), Seq::FIRST, b"send other mail");
199        let mut seen = BTreeSet::new();
200        let guard = claim(&mut seen, key).expect("the first claim on a fresh key");
201        assert_eq!(guard.key(), &key);
202        let again = claim(&mut seen, key).expect_err("the second claim is a duplicate");
203        assert_eq!(again.key(), &key);
204        assert!(
205            claim(&mut seen, other).is_ok(),
206            "a different action is a different key"
207        );
208    }
209
210    proptest::proptest! {
211        #[test]
212        fn rederivation_is_identity(
213            run_bytes in proptest::array::uniform16(0u8..=255),
214            seq in 0u64..,
215            action in proptest::collection::vec(0u8..=255, 0..64),
216        ) {
217            let r = RunId::from_bytes(run_bytes);
218            let first = IdemKey::derive(&r, Seq::new(seq), &action);
219            let second = IdemKey::derive(&r, Seq::new(seq), &action);
220            proptest::prop_assert_eq!(first, second);
221        }
222    }
223}