Skip to main content

axon/replay_token/
token.rs

1//! [`ReplayToken`] canonical shape + hash derivation.
2//!
3//! Hash input is built as:
4//!
5//! ```text
6//!   effect_name  ∥ RS ∥
7//!   canonical_json(inputs)  ∥ RS ∥
8//!   canonical_json(outputs)  ∥ RS ∥
9//!   model_version  ∥ RS ∥
10//!   canonical_json(sampling)  ∥ RS ∥
11//!   timestamp_rfc3339  ∥ RS ∥
12//!   nonce_hex
13//! ```
14//!
15//! where `RS = 0x1E` (ASCII Record Separator). The canonicaliser
16//! matches the one already used by the §Fase 10.g audit chain
17//! (recursive key sort, UTF-8 encoding, ASCII-safe escapes) so a
18//! token can be re-hashed by the enterprise audit writer with zero
19//! translation.
20
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use sha2::{Digest, Sha256};
25
26/// Sampling parameters captured at the point of a non-deterministic
27/// effect (LLM inference, random sampling). Replayability depends on
28/// the provider honouring `seed`; providers that ignore `seed` get
29/// their effects marked `@non_replayable` in the tool descriptor and
30/// the checker rejects their use in a `@sensitive` context.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32pub struct SamplingParams {
33    #[serde(default)]
34    pub temperature: Option<f64>,
35    #[serde(default)]
36    pub top_p: Option<f64>,
37    #[serde(default)]
38    pub top_k: Option<i64>,
39    #[serde(default)]
40    pub seed: Option<i64>,
41    #[serde(default)]
42    pub max_tokens: Option<i64>,
43    /// Extra provider-specific knobs (`frequency_penalty`, `stop`,
44    /// tool choice strategy, …). The recorder copies whatever it
45    /// was given so replay catches provider-specific defaults.
46    #[serde(default, skip_serializing_if = "Value::is_null")]
47    pub extras: Value,
48}
49
50impl Default for SamplingParams {
51    fn default() -> Self {
52        Self {
53            temperature: None,
54            top_p: None,
55            top_k: None,
56            seed: None,
57            max_tokens: None,
58            extras: Value::Null,
59        }
60    }
61}
62
63/// One replay receipt. Immutable once minted.
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65pub struct ReplayToken {
66    /// Canonical identifier of the effect invoked
67    /// (`call_tool:send_slack`, `llm_infer:gpt_4o`, `db_read:customers`).
68    pub effect_name: String,
69    /// Inputs as given to the effect. Canonicalised then hashed into
70    /// `inputs_hash_hex`; retained here in structured form for
71    /// replay executors.
72    pub inputs: Value,
73    pub inputs_hash_hex: String,
74    /// Outputs the effect produced. Same canonical treatment as
75    /// inputs.
76    pub outputs: Value,
77    pub outputs_hash_hex: String,
78    /// Model identifier (free string). For deterministic, non-LLM
79    /// effects adopters use a stable version slug like
80    /// `axon.builtin.db_read.v1`. For LLMs this is the provider's
81    /// model id (`gpt-4o-2024-11-20`, `claude-opus-4-7`) at call
82    /// time.
83    pub model_version: String,
84    pub sampling: SamplingParams,
85    #[serde(with = "chrono::serde::ts_milliseconds")]
86    pub timestamp: DateTime<Utc>,
87    /// 128-bit random nonce; prevents collision between two
88    /// semantically identical invocations.
89    pub nonce_hex: String,
90    /// Derived hex SHA-256 of the canonical hash input. Stable
91    /// across Rust + Python implementations.
92    pub token_hash_hex: String,
93}
94
95impl ReplayToken {
96    /// Build a fresh token, computing every derived hash. Callers
97    /// usually go through [`ReplayTokenBuilder`] for ergonomics but
98    /// the direct constructor is public for explicit constructions
99    /// in tests / adapters.
100    pub fn mint(
101        effect_name: impl Into<String>,
102        inputs: Value,
103        outputs: Value,
104        model_version: impl Into<String>,
105        sampling: SamplingParams,
106        timestamp: DateTime<Utc>,
107        nonce: [u8; 16],
108    ) -> Self {
109        let effect_name = effect_name.into();
110        let model_version = model_version.into();
111        let inputs_hash_hex = hex(&canonical_hash(&inputs));
112        let outputs_hash_hex = hex(&canonical_hash(&outputs));
113        let nonce_hex = hex(&nonce);
114        let token_hash_hex = hex(&derive_token_hash(
115            &effect_name,
116            &inputs,
117            &outputs,
118            &model_version,
119            &sampling,
120            timestamp,
121            &nonce,
122        ));
123        ReplayToken {
124            effect_name,
125            inputs,
126            inputs_hash_hex,
127            outputs,
128            outputs_hash_hex,
129            model_version,
130            sampling,
131            timestamp,
132            nonce_hex,
133            token_hash_hex,
134        }
135    }
136}
137
138/// Ergonomic builder; adopters populate fields and call `.mint()`.
139#[derive(Debug, Default)]
140pub struct ReplayTokenBuilder {
141    effect_name: Option<String>,
142    inputs: Option<Value>,
143    outputs: Option<Value>,
144    model_version: Option<String>,
145    sampling: SamplingParams,
146    timestamp: Option<DateTime<Utc>>,
147    nonce: Option<[u8; 16]>,
148}
149
150impl ReplayTokenBuilder {
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    pub fn effect_name(mut self, name: impl Into<String>) -> Self {
156        self.effect_name = Some(name.into());
157        self
158    }
159    pub fn inputs(mut self, v: Value) -> Self {
160        self.inputs = Some(v);
161        self
162    }
163    pub fn outputs(mut self, v: Value) -> Self {
164        self.outputs = Some(v);
165        self
166    }
167    pub fn model_version(mut self, s: impl Into<String>) -> Self {
168        self.model_version = Some(s.into());
169        self
170    }
171    pub fn sampling(mut self, s: SamplingParams) -> Self {
172        self.sampling = s;
173        self
174    }
175    pub fn timestamp(mut self, ts: DateTime<Utc>) -> Self {
176        self.timestamp = Some(ts);
177        self
178    }
179    pub fn nonce(mut self, bytes: [u8; 16]) -> Self {
180        self.nonce = Some(bytes);
181        self
182    }
183
184    pub fn mint(self) -> ReplayToken {
185        let effect_name = self.effect_name.expect("effect_name required");
186        let inputs = self.inputs.unwrap_or(Value::Null);
187        let outputs = self.outputs.unwrap_or(Value::Null);
188        let model_version = self.model_version.unwrap_or_else(|| "unset".into());
189        let timestamp = self.timestamp.unwrap_or_else(Utc::now);
190        let nonce = self.nonce.unwrap_or_else(generate_nonce);
191        ReplayToken::mint(
192            effect_name,
193            inputs,
194            outputs,
195            model_version,
196            self.sampling,
197            timestamp,
198            nonce,
199        )
200    }
201}
202
203// ── Canonical JSON hashing ──────────────────────────────────────────
204
205/// Compute SHA-256 of `v` after canonical JSON encoding. The
206/// canonical form sorts object keys recursively and omits optional
207/// whitespace — identical to the §Fase 10.g audit-chain canonicaliser.
208pub fn canonical_hash(v: &Value) -> [u8; 32] {
209    let canonical = canonicalize(v);
210    let mut h = Sha256::new();
211    h.update(canonical.as_bytes());
212    let out = h.finalize();
213    let mut array = [0u8; 32];
214    array.copy_from_slice(&out);
215    array
216}
217
218/// RFC 8785-style canonical JSON — keys sorted, no whitespace,
219/// ASCII-safe escapes. serde_json with `to_writer_pretty(false)`
220/// already omits whitespace; key sorting we do here.
221fn canonicalize(v: &Value) -> String {
222    let sorted = sort_object_keys(v.clone());
223    serde_json::to_string(&sorted).expect("canonical JSON encoding")
224}
225
226fn sort_object_keys(v: Value) -> Value {
227    match v {
228        Value::Object(map) => {
229            // serde_json::Map preserves insertion order. Re-insert
230            // in sorted order so the encoder emits sorted output.
231            let mut entries: Vec<(String, Value)> = map.into_iter().collect();
232            entries.sort_by(|a, b| a.0.cmp(&b.0));
233            let mut sorted_map = serde_json::Map::new();
234            for (k, inner) in entries {
235                sorted_map.insert(k, sort_object_keys(inner));
236            }
237            Value::Object(sorted_map)
238        }
239        Value::Array(items) => {
240            Value::Array(items.into_iter().map(sort_object_keys).collect())
241        }
242        other => other,
243    }
244}
245
246fn derive_token_hash(
247    effect_name: &str,
248    inputs: &Value,
249    outputs: &Value,
250    model_version: &str,
251    sampling: &SamplingParams,
252    timestamp: DateTime<Utc>,
253    nonce: &[u8; 16],
254) -> [u8; 32] {
255    const RS: u8 = 0x1E;
256    let mut h = Sha256::new();
257    h.update(effect_name.as_bytes());
258    h.update([RS]);
259    h.update(canonicalize(inputs).as_bytes());
260    h.update([RS]);
261    h.update(canonicalize(outputs).as_bytes());
262    h.update([RS]);
263    h.update(model_version.as_bytes());
264    h.update([RS]);
265    h.update(
266        canonicalize(&serde_json::to_value(sampling).expect("sampling serialisable"))
267            .as_bytes(),
268    );
269    h.update([RS]);
270    h.update(timestamp.to_rfc3339().as_bytes());
271    h.update([RS]);
272    h.update(nonce);
273    let out = h.finalize();
274    let mut array = [0u8; 32];
275    array.copy_from_slice(&out);
276    array
277}
278
279fn generate_nonce() -> [u8; 16] {
280    use rand::RngCore;
281    let mut bytes = [0u8; 16];
282    rand::rng().fill_bytes(&mut bytes);
283    bytes
284}
285
286fn hex(bytes: &[u8]) -> String {
287    let mut out = String::with_capacity(bytes.len() * 2);
288    for b in bytes {
289        out.push_str(&format!("{b:02x}"));
290    }
291    out
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use serde_json::json;
298
299    fn fixed_nonce() -> [u8; 16] {
300        [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
301    }
302
303    fn fixed_timestamp() -> DateTime<Utc> {
304        use chrono::TimeZone;
305        Utc.with_ymd_and_hms(2026, 4, 22, 12, 0, 0).unwrap()
306    }
307
308    #[test]
309    fn mint_sets_every_derived_hash() {
310        let token = ReplayToken::mint(
311            "call_tool:send_slack",
312            json!({"channel": "#ops", "text": "hi"}),
313            json!({"ok": true, "ts": "1700000000.000"}),
314            "axon.builtin.slack.v1",
315            SamplingParams::default(),
316            fixed_timestamp(),
317            fixed_nonce(),
318        );
319        assert_eq!(token.effect_name, "call_tool:send_slack");
320        assert_eq!(token.inputs_hash_hex.len(), 64);
321        assert_eq!(token.outputs_hash_hex.len(), 64);
322        assert_eq!(token.token_hash_hex.len(), 64);
323        assert_eq!(token.nonce_hex, "0102030405060708090a0b0c0d0e0f10");
324    }
325
326    #[test]
327    fn canonical_hash_is_key_order_independent() {
328        let a = json!({"a": 1, "b": 2, "c": 3});
329        let b = json!({"c": 3, "a": 1, "b": 2});
330        assert_eq!(canonical_hash(&a), canonical_hash(&b));
331    }
332
333    #[test]
334    fn canonical_hash_propagates_to_nested_objects() {
335        let a = json!({"outer": {"a": 1, "b": 2}});
336        let b = json!({"outer": {"b": 2, "a": 1}});
337        assert_eq!(canonical_hash(&a), canonical_hash(&b));
338    }
339
340    #[test]
341    fn token_hash_is_deterministic_for_identical_inputs() {
342        let nonce = fixed_nonce();
343        let ts = fixed_timestamp();
344        let inputs = json!({"prompt": "hi", "user_id": "u-1"});
345        let outputs = json!({"text": "hello"});
346
347        let t1 = ReplayToken::mint(
348            "llm_infer",
349            inputs.clone(),
350            outputs.clone(),
351            "claude-opus-4-7",
352            SamplingParams {
353                temperature: Some(0.7),
354                top_p: Some(0.95),
355                seed: Some(42),
356                ..Default::default()
357            },
358            ts,
359            nonce,
360        );
361        let t2 = ReplayToken::mint(
362            "llm_infer",
363            inputs,
364            outputs,
365            "claude-opus-4-7",
366            SamplingParams {
367                temperature: Some(0.7),
368                top_p: Some(0.95),
369                seed: Some(42),
370                ..Default::default()
371            },
372            ts,
373            nonce,
374        );
375        assert_eq!(t1.token_hash_hex, t2.token_hash_hex);
376    }
377
378    #[test]
379    fn token_hash_differs_when_model_version_differs() {
380        let t_old = ReplayToken::mint(
381            "llm_infer",
382            json!({"x": 1}),
383            json!({"y": 2}),
384            "claude-opus-4-7",
385            SamplingParams::default(),
386            fixed_timestamp(),
387            fixed_nonce(),
388        );
389        let t_new = ReplayToken::mint(
390            "llm_infer",
391            json!({"x": 1}),
392            json!({"y": 2}),
393            "claude-opus-4-8",
394            SamplingParams::default(),
395            fixed_timestamp(),
396            fixed_nonce(),
397        );
398        assert_ne!(t_old.token_hash_hex, t_new.token_hash_hex);
399    }
400
401    #[test]
402    fn builder_works() {
403        let t = ReplayTokenBuilder::new()
404            .effect_name("db_read:customers")
405            .inputs(json!({"where": {"id": 42}}))
406            .outputs(json!({"name": "Acme"}))
407            .model_version("axon.builtin.db_read.v1")
408            .timestamp(fixed_timestamp())
409            .nonce(fixed_nonce())
410            .mint();
411        assert_eq!(t.effect_name, "db_read:customers");
412        assert_eq!(t.token_hash_hex.len(), 64);
413    }
414
415    #[test]
416    fn random_nonce_differs_across_mints() {
417        let t1 = ReplayTokenBuilder::new()
418            .effect_name("x")
419            .timestamp(fixed_timestamp())
420            .mint();
421        let t2 = ReplayTokenBuilder::new()
422            .effect_name("x")
423            .timestamp(fixed_timestamp())
424            .mint();
425        assert_ne!(t1.nonce_hex, t2.nonce_hex);
426        assert_ne!(t1.token_hash_hex, t2.token_hash_hex);
427    }
428}