1use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use sha2::{Digest, Sha256};
25
26#[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 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65pub struct ReplayToken {
66 pub effect_name: String,
69 pub inputs: Value,
73 pub inputs_hash_hex: String,
74 pub outputs: Value,
77 pub outputs_hash_hex: String,
78 pub model_version: String,
84 pub sampling: SamplingParams,
85 #[serde(with = "chrono::serde::ts_milliseconds")]
86 pub timestamp: DateTime<Utc>,
87 pub nonce_hex: String,
90 pub token_hash_hex: String,
93}
94
95impl ReplayToken {
96 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#[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
203pub 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
218fn 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 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}