1use chrono::{DateTime, SecondsFormat, Timelike, Utc};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use uuid::Uuid;
7
8use crate::error::ProvenanceError;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
13pub struct ChainEntryId(
14 pub Uuid,
16);
17
18impl ChainEntryId {
19 pub fn new() -> Self {
21 Self(Uuid::new_v4())
22 }
23}
24
25impl Default for ChainEntryId {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl std::fmt::Display for ChainEntryId {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 write!(f, "{}", self.0)
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
40#[serde(rename_all = "snake_case")]
41pub enum ProvenanceEventKind {
42 AgentRunStarted,
44 LlmCall,
46 ToolCall,
48 MemoryWrite,
50 AgentRunCompleted,
52 Heartbeat,
54 Custom(String),
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
61pub struct ProvenanceEvent {
62 pub kind: ProvenanceEventKind,
64 pub actor: String,
66 pub resource_ref: String,
68 pub payload_hash_hex: String,
70 pub metadata: serde_json::Value,
72}
73
74impl ProvenanceEvent {
75 pub fn canonical_bytes(&self) -> Vec<u8> {
80 let mut buf = Vec::new();
81 let mut ser =
82 serde_json::Serializer::with_formatter(&mut buf, serde_json::ser::CompactFormatter);
83 let value: serde_json::Value = serde_json::to_value(self).expect("event serialisable");
84 let canonical = canonicalise(&value);
85 canonical.serialize(&mut ser).expect("canonical write");
86 buf
87 }
88}
89
90fn canonicalise(value: &serde_json::Value) -> serde_json::Value {
91 use serde_json::Value;
92 match value {
93 Value::Object(m) => {
94 let mut sorted = std::collections::BTreeMap::new();
95 for (k, v) in m {
96 sorted.insert(k.clone(), canonicalise(v));
97 }
98 let mut out = serde_json::Map::with_capacity(sorted.len());
99 for (k, v) in sorted {
100 out.insert(k, v);
101 }
102 Value::Object(out)
103 }
104 Value::Array(a) => Value::Array(a.iter().map(canonicalise).collect()),
105 other => other.clone(),
106 }
107}
108
109pub const CURRENT_CHAIN_VERSION: u8 = 2;
112
113fn default_chain_version_v1() -> u8 {
114 1
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
125#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
126#[non_exhaustive]
127pub struct ChainEntry {
128 pub id: ChainEntryId,
130 pub scope: String,
132 pub sequence: u64,
134 pub previous_hash_hex: String,
136 pub event: ProvenanceEvent,
138 pub recorded_at: DateTime<Utc>,
140 pub entry_hash_hex: String,
142 #[serde(default)]
148 pub parent_hashes_hex: Vec<String>,
149 #[serde(default = "default_chain_version_v1")]
156 pub chain_version: u8,
157}
158
159impl ChainEntry {
160 pub fn canonical_timestamp(recorded_at: DateTime<Utc>) -> String {
162 recorded_at.to_rfc3339_opts(SecondsFormat::Millis, true)
163 }
164
165 pub fn compute_hash_v1(
171 previous_hash_hex: &str,
172 sequence: u64,
173 event: &ProvenanceEvent,
174 recorded_at: DateTime<Utc>,
175 ) -> String {
176 let mut hasher = Sha256::new();
177 hasher.update(previous_hash_hex.as_bytes());
178 hasher.update(sequence.to_be_bytes());
179 hasher.update(Self::canonical_timestamp(recorded_at).as_bytes());
180 hasher.update(event.canonical_bytes());
181 hex::encode(hasher.finalize())
182 }
183
184 pub fn compute_hash_v2(
194 previous_hash_hex: &str,
195 sequence: u64,
196 event: &ProvenanceEvent,
197 recorded_at: DateTime<Utc>,
198 parent_hashes_hex: &[String],
199 ) -> String {
200 let mut sorted_parents: Vec<&str> = parent_hashes_hex.iter().map(String::as_str).collect();
201 sorted_parents.sort_unstable();
202
203 let mut hasher = Sha256::new();
204 hasher.update([CURRENT_CHAIN_VERSION]);
205 hasher.update(previous_hash_hex.as_bytes());
206 hasher.update(sequence.to_be_bytes());
207 hasher.update(Self::canonical_timestamp(recorded_at).as_bytes());
208 hasher.update(event.canonical_bytes());
209 for parent in sorted_parents {
210 hasher.update(parent.as_bytes());
211 hasher.update([0x00_u8]);
212 }
213 hex::encode(hasher.finalize())
214 }
215
216 pub fn compute_hash_for_version(
223 chain_version: u8,
224 previous_hash_hex: &str,
225 sequence: u64,
226 event: &ProvenanceEvent,
227 recorded_at: DateTime<Utc>,
228 parent_hashes_hex: &[String],
229 ) -> Result<String, ProvenanceError> {
230 match chain_version {
231 1 => Ok(Self::compute_hash_v1(
232 previous_hash_hex,
233 sequence,
234 event,
235 recorded_at,
236 )),
237 2 => Ok(Self::compute_hash_v2(
238 previous_hash_hex,
239 sequence,
240 event,
241 recorded_at,
242 parent_hashes_hex,
243 )),
244 other => Err(ProvenanceError::UnsupportedChainVersion(other)),
245 }
246 }
247}
248
249pub const GENESIS_HASH_HEX: &str =
251 "0000000000000000000000000000000000000000000000000000000000000000";
252
253fn round_to_millis(t: DateTime<Utc>) -> DateTime<Utc> {
254 let nanos = t.timestamp_subsec_nanos();
255 let trimmed = (nanos / 1_000_000) * 1_000_000;
256 t.with_nanosecond(trimmed).unwrap_or(t)
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
262pub struct ProvenanceChain {
263 pub scope: String,
265 pub entries: Vec<ChainEntry>,
267}
268
269impl ProvenanceChain {
270 pub fn new(scope: String) -> Self {
272 Self {
273 scope,
274 entries: Vec::new(),
275 }
276 }
277
278 pub fn head_hash_hex(&self) -> &str {
280 self.entries
281 .last()
282 .map(|e| e.entry_hash_hex.as_str())
283 .unwrap_or(GENESIS_HASH_HEX)
284 }
285
286 pub fn next_sequence(&self) -> u64 {
288 self.entries.last().map(|e| e.sequence + 1).unwrap_or(0)
289 }
290
291 pub fn append(&mut self, event: ProvenanceEvent) -> ChainEntry {
295 self.append_with_parents(event, Vec::new())
296 }
297
298 pub fn append_with_parents(
302 &mut self,
303 event: ProvenanceEvent,
304 parent_hashes_hex: Vec<String>,
305 ) -> ChainEntry {
306 let recorded_at = round_to_millis(Utc::now());
307 let sequence = self.next_sequence();
308 let previous_hash_hex = self.head_hash_hex().to_string();
309 let entry_hash_hex = ChainEntry::compute_hash_v2(
310 &previous_hash_hex,
311 sequence,
312 &event,
313 recorded_at,
314 &parent_hashes_hex,
315 );
316 let entry = ChainEntry {
317 id: ChainEntryId::new(),
318 scope: self.scope.clone(),
319 sequence,
320 previous_hash_hex,
321 event,
322 recorded_at,
323 entry_hash_hex,
324 parent_hashes_hex,
325 chain_version: CURRENT_CHAIN_VERSION,
326 };
327 self.entries.push(entry.clone());
328 entry
329 }
330
331 pub fn verify(&self) -> Result<(), ProvenanceError> {
336 let mut expected_prev = GENESIS_HASH_HEX.to_string();
337 for (i, entry) in self.entries.iter().enumerate() {
338 if entry.sequence != i as u64 {
339 return Err(ProvenanceError::ChainBroken {
340 sequence: entry.sequence,
341 reason: format!("sequence gap at index {i}"),
342 });
343 }
344 if entry.previous_hash_hex != expected_prev {
345 return Err(ProvenanceError::ChainBroken {
346 sequence: entry.sequence,
347 reason: "previous_hash mismatch".into(),
348 });
349 }
350 let recomputed = ChainEntry::compute_hash_for_version(
351 entry.chain_version,
352 &entry.previous_hash_hex,
353 entry.sequence,
354 &entry.event,
355 entry.recorded_at,
356 &entry.parent_hashes_hex,
357 )?;
358 if recomputed != entry.entry_hash_hex {
359 return Err(ProvenanceError::ChainBroken {
360 sequence: entry.sequence,
361 reason: format!(
362 "hash mismatch (recomputed {recomputed} vs stored {})",
363 entry.entry_hash_hex
364 ),
365 });
366 }
367 expected_prev = entry.entry_hash_hex.clone();
368 }
369 Ok(())
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 fn run_started(run_id: &str) -> ProvenanceEvent {
378 ProvenanceEvent {
379 kind: ProvenanceEventKind::AgentRunStarted,
380 actor: "agent:hello".into(),
381 resource_ref: run_id.into(),
382 payload_hash_hex: "ab".repeat(32),
383 metadata: serde_json::json!({"agent_name": "hello"}),
384 }
385 }
386
387 #[test]
388 fn empty_chain_has_genesis_head() {
389 let chain = ProvenanceChain::new("run-1".into());
390 assert_eq!(chain.head_hash_hex(), GENESIS_HASH_HEX);
391 assert_eq!(chain.next_sequence(), 0);
392 }
393
394 #[test]
395 fn append_first_entry_links_to_genesis() {
396 let mut chain = ProvenanceChain::new("run-1".into());
397 let entry = chain.append(run_started("run-1"));
398 assert_eq!(entry.sequence, 0);
399 assert_eq!(entry.previous_hash_hex, GENESIS_HASH_HEX);
400 assert_ne!(entry.entry_hash_hex, GENESIS_HASH_HEX);
401 }
402
403 #[test]
404 fn append_extends_chain_and_links_correctly() {
405 let mut chain = ProvenanceChain::new("run-1".into());
406 let a = chain.append(run_started("run-1"));
407 let b = chain.append(run_started("run-1"));
408 assert_eq!(b.sequence, 1);
409 assert_eq!(b.previous_hash_hex, a.entry_hash_hex);
410 }
411
412 #[test]
413 fn verify_passes_on_clean_chain() {
414 let mut chain = ProvenanceChain::new("run-1".into());
415 chain.append(run_started("run-1"));
416 chain.append(run_started("run-1"));
417 chain.append(run_started("run-1"));
418 chain.verify().unwrap();
419 }
420
421 #[test]
422 fn verify_detects_broken_link() {
423 let mut chain = ProvenanceChain::new("run-1".into());
424 chain.append(run_started("run-1"));
425 chain.append(run_started("run-1"));
426 chain.entries[1].previous_hash_hex = "ff".repeat(32);
427 let err = chain.verify().unwrap_err();
428 match err {
429 ProvenanceError::ChainBroken { sequence, reason } => {
430 assert_eq!(sequence, 1);
431 assert!(reason.contains("previous_hash mismatch"));
432 }
433 other => panic!("unexpected: {other:?}"),
434 }
435 }
436
437 fn fixture_event() -> ProvenanceEvent {
438 ProvenanceEvent {
439 kind: ProvenanceEventKind::AgentRunStarted,
440 actor: "agent:fixture".into(),
441 resource_ref: "run-fixture".into(),
442 payload_hash_hex: "ab".repeat(32),
443 metadata: serde_json::json!({"agent_name": "fixture"}),
444 }
445 }
446
447 fn fixture_recorded_at() -> DateTime<Utc> {
448 "2026-06-04T12:00:00.000Z".parse().unwrap()
449 }
450
451 #[test]
452 fn append_mints_v2_entries() {
453 let mut chain = ProvenanceChain::new("run-1".into());
454 let entry = chain.append(run_started("run-1"));
455 assert_eq!(entry.chain_version, CURRENT_CHAIN_VERSION);
456 assert_eq!(entry.chain_version, 2);
457 assert!(entry.parent_hashes_hex.is_empty());
458 }
459
460 #[test]
461 fn append_with_parents_records_supplied_parents() {
462 let mut chain = ProvenanceChain::new("run-1".into());
463 let parents = vec!["aa".repeat(32), "bb".repeat(32)];
464 let entry = chain.append_with_parents(run_started("run-1"), parents.clone());
465 assert_eq!(entry.chain_version, 2);
466 assert_eq!(entry.parent_hashes_hex, parents);
467 chain.verify().expect("v2 chain with parents verifies");
468 }
469
470 #[test]
471 fn compute_hash_v1_matches_pre_extension_fixture() {
472 let recorded_at = fixture_recorded_at();
475 let event = fixture_event();
476 let expected_hex = ChainEntry::compute_hash_v1(GENESIS_HASH_HEX, 0, &event, recorded_at);
477 let payload_hash = "ab".repeat(32);
478
479 let json = format!(
480 r#"{{
481 "id": "00000000-0000-4000-8000-000000000001",
482 "scope": "run-fixture",
483 "sequence": 0,
484 "previous_hash_hex": "{GENESIS_HASH_HEX}",
485 "event": {{
486 "kind": "agent_run_started",
487 "actor": "agent:fixture",
488 "resource_ref": "run-fixture",
489 "payload_hash_hex": "{payload_hash}",
490 "metadata": {{"agent_name": "fixture"}}
491 }},
492 "recorded_at": "2026-06-04T12:00:00.000Z",
493 "entry_hash_hex": "{expected_hex}"
494 }}"#
495 );
496
497 let entry: ChainEntry = serde_json::from_str(&json).expect("legacy JSON deserialises");
498 assert_eq!(
499 entry.chain_version, 1,
500 "missing chain_version defaults to v1"
501 );
502 assert!(
503 entry.parent_hashes_hex.is_empty(),
504 "missing parents default to empty"
505 );
506
507 let recomputed = ChainEntry::compute_hash_for_version(
508 entry.chain_version,
509 &entry.previous_hash_hex,
510 entry.sequence,
511 &entry.event,
512 entry.recorded_at,
513 &entry.parent_hashes_hex,
514 )
515 .expect("v1 dispatcher accepts version 1");
516
517 assert_eq!(
518 recomputed, entry.entry_hash_hex,
519 "v1 hash via dispatcher must match the embedded entry_hash_hex"
520 );
521 }
522
523 #[test]
524 fn compute_hash_v2_includes_parent_hashes() {
525 let recorded_at = fixture_recorded_at();
526 let event = fixture_event();
527 let h_a = ChainEntry::compute_hash_v2(
528 GENESIS_HASH_HEX,
529 0,
530 &event,
531 recorded_at,
532 &["aa".repeat(32)],
533 );
534 let h_b = ChainEntry::compute_hash_v2(
535 GENESIS_HASH_HEX,
536 0,
537 &event,
538 recorded_at,
539 &["bb".repeat(32)],
540 );
541 assert_ne!(h_a, h_b, "different parents must produce different hashes");
542 }
543
544 #[test]
545 fn compute_hash_v2_parent_hashes_order_independent() {
546 let recorded_at = fixture_recorded_at();
547 let event = fixture_event();
548 let asc = ChainEntry::compute_hash_v2(
549 GENESIS_HASH_HEX,
550 0,
551 &event,
552 recorded_at,
553 &["aa".repeat(32), "bb".repeat(32)],
554 );
555 let desc = ChainEntry::compute_hash_v2(
556 GENESIS_HASH_HEX,
557 0,
558 &event,
559 recorded_at,
560 &["bb".repeat(32), "aa".repeat(32)],
561 );
562 assert_eq!(
563 asc, desc,
564 "parent order at call site must not change the hash"
565 );
566 }
567
568 #[test]
569 fn compute_hash_v1_distinct_from_v2_with_empty_parents() {
570 let recorded_at = fixture_recorded_at();
571 let event = fixture_event();
572 let v1 = ChainEntry::compute_hash_v1(GENESIS_HASH_HEX, 0, &event, recorded_at);
573 let v2 = ChainEntry::compute_hash_v2(GENESIS_HASH_HEX, 0, &event, recorded_at, &[]);
574 assert_ne!(
575 v1, v2,
576 "the v2 version-byte prefix must distinguish v1 from v2 even with empty parents"
577 );
578 }
579
580 #[test]
581 fn compute_hash_for_version_rejects_unknown_version() {
582 let recorded_at = fixture_recorded_at();
583 let event = fixture_event();
584 let err =
585 ChainEntry::compute_hash_for_version(99, GENESIS_HASH_HEX, 0, &event, recorded_at, &[])
586 .expect_err("unknown version must be rejected");
587 match err {
588 ProvenanceError::UnsupportedChainVersion(v) => assert_eq!(v, 99),
589 other => panic!("expected UnsupportedChainVersion, got {other:?}"),
590 }
591 }
592
593 #[test]
594 fn chain_verifies_mixed_versions() {
595 let recorded_at = fixture_recorded_at();
598 let event = fixture_event();
599 let v1_hash = ChainEntry::compute_hash_v1(GENESIS_HASH_HEX, 0, &event, recorded_at);
600 let v1_entry = ChainEntry {
601 id: ChainEntryId::new(),
602 scope: "mixed".into(),
603 sequence: 0,
604 previous_hash_hex: GENESIS_HASH_HEX.to_string(),
605 event,
606 recorded_at,
607 entry_hash_hex: v1_hash,
608 parent_hashes_hex: Vec::new(),
609 chain_version: 1,
610 };
611 let mut chain = ProvenanceChain::new("mixed".into());
612 chain.entries.push(v1_entry);
613 chain.append(run_started("mixed"));
614 chain.append(run_started("mixed"));
615 chain
616 .verify()
617 .expect("mixed v1-prefix + v2-suffix chain verifies end-to-end");
618 assert_eq!(chain.entries.last().unwrap().chain_version, 2);
620 assert_eq!(chain.entries[0].chain_version, 1);
621 }
622
623 #[test]
624 fn verify_rejects_unknown_chain_version_on_an_entry() {
625 let mut chain = ProvenanceChain::new("run-1".into());
626 chain.append(run_started("run-1"));
627 chain.entries[0].chain_version = 99;
628 let err = chain
629 .verify()
630 .expect_err("verify must surface unsupported version");
631 match err {
632 ProvenanceError::UnsupportedChainVersion(v) => assert_eq!(v, 99),
633 other => panic!("expected UnsupportedChainVersion, got {other:?}"),
634 }
635 }
636}