Skip to main content

klieo_provenance/
chain.rs

1//! Append-only Merkle chain primitives.
2
3use chrono::{DateTime, SecondsFormat, Timelike, Utc};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use uuid::Uuid;
7
8use crate::error::ProvenanceError;
9
10/// Identifies an entry in the provenance chain.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
13pub struct ChainEntryId(
14    /// Underlying UUID-v4.
15    pub Uuid,
16);
17
18impl ChainEntryId {
19    /// Mint a fresh random id.
20    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/// What kind of operation this provenance entry records.
38#[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    /// An agent run started.
43    AgentRunStarted,
44    /// An LLM was invoked.
45    LlmCall,
46    /// A tool was invoked.
47    ToolCall,
48    /// Memory was written.
49    MemoryWrite,
50    /// An agent run reached a terminal state.
51    AgentRunCompleted,
52    /// Scheduled liveness marker.
53    Heartbeat,
54    /// Catch-all for callers that need to emit a kind not yet promoted.
55    Custom(String),
56}
57
58/// A single appendable record in the chain.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
61pub struct ProvenanceEvent {
62    /// What kind of operation this entry records.
63    pub kind: ProvenanceEventKind,
64    /// Who initiated the operation (e.g. `agent:hello`).
65    pub actor: String,
66    /// Strong reference to the resource (run id, spec id, etc.).
67    pub resource_ref: String,
68    /// Hash-only payload to avoid storing potentially sensitive content directly.
69    pub payload_hash_hex: String,
70    /// Free-form structured metadata.
71    pub metadata: serde_json::Value,
72}
73
74impl ProvenanceEvent {
75    /// Canonical bytes used as input to the entry hash.
76    ///
77    /// Map keys are sorted recursively so logically-equal events with
78    /// different insertion orders produce identical bytes.
79    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
109/// Current chain canonicalisation version emitted by
110/// [`ProvenanceChain::append`]. See ADR-037 (`docs/adr/adr-037-chain-canonicalisation-v2.md`).
111pub const CURRENT_CHAIN_VERSION: u8 = 2;
112
113fn default_chain_version_v1() -> u8 {
114    1
115}
116
117/// One entry in the append-only chain.
118///
119/// `#[non_exhaustive]` from klieo-provenance 2.0.0 onwards — new optional
120/// fields can land in a minor without forcing struct-literal updates. New
121/// callers construct via [`ProvenanceChain::append`] (or
122/// [`ProvenanceChain::append_with_parents`] for v2 multi-parent entries);
123/// reading the public fields stays unchanged.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
126#[non_exhaustive]
127pub struct ChainEntry {
128    /// Random id for this entry.
129    pub id: ChainEntryId,
130    /// Free-form scope this chain belongs to.
131    pub scope: String,
132    /// 0-based monotonic position in the chain.
133    pub sequence: u64,
134    /// Hex SHA-256 of the prior entry, or [`GENESIS_HASH_HEX`] for `sequence == 0`.
135    pub previous_hash_hex: String,
136    /// The audit event this entry records.
137    pub event: ProvenanceEvent,
138    /// Wall-clock at which the entry was minted, rounded to ms precision.
139    pub recorded_at: DateTime<Utc>,
140    /// Hex SHA-256 of this entry's canonical inputs.
141    pub entry_hash_hex: String,
142    /// Additional semantic-derivation parents (v2+). Empty for linear chains.
143    /// Non-empty when an entry derives from multiple prior facts (e.g. M5
144    /// PathRAG verbalisation across several path hops). Sorted lexicographically
145    /// before hashing for determinism (see [`Self::compute_hash_v2`]).
146    /// Legacy v1 JSON deserialises with this field empty via `#[serde(default)]`.
147    #[serde(default)]
148    pub parent_hashes_hex: Vec<String>,
149    /// Canonicalisation version that produced [`Self::entry_hash_hex`].
150    /// `1` for entries minted before ADR-037 (legacy JSON has no field;
151    /// the `#[serde(default)]` hook supplies 1); `2` for entries minted
152    /// by [`ProvenanceChain::append`] under klieo-provenance 2.0+.
153    /// Verification dispatches per-entry on this value, so chains may
154    /// contain a v1 prefix and a v2 suffix and still verify end-to-end.
155    #[serde(default = "default_chain_version_v1")]
156    pub chain_version: u8,
157}
158
159impl ChainEntry {
160    /// Canonical timestamp form — pinned to ms precision + Z suffix.
161    pub fn canonical_timestamp(recorded_at: DateTime<Utc>) -> String {
162        recorded_at.to_rfc3339_opts(SecondsFormat::Millis, true)
163    }
164
165    /// Canonical SHA-256 hash for the legacy v1 entry shape.
166    ///
167    /// Canonical input: `prev_hex_bytes || seq_be_bytes || ts_bytes || event_canonical_bytes`.
168    /// Bit-identical to the pre-ADR-037 `compute_hash`; preserved verbatim
169    /// so v1 entries serialised before the migration still verify.
170    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    /// Canonical SHA-256 hash for the v2 entry shape (ADR-037).
185    ///
186    /// Canonical input: `0x02 || prev_hex_bytes || seq_be_bytes || ts_bytes ||
187    /// event_canonical_bytes || sorted_parents_separated`.
188    /// `parent_hashes_hex` is sorted lexicographically before hashing so
189    /// the call-site order is irrelevant. A null byte separates adjacent
190    /// parent strings to make parent boundaries unambiguous. The leading
191    /// `0x02` distinguishes v2 entries from v1 entries with identical
192    /// payloads and empty parent sets.
193    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    /// Dispatch to the version-specific hash algorithm.
217    ///
218    /// Returns `Err(ProvenanceError::UnsupportedChainVersion)` for any
219    /// `chain_version` outside `{1, 2}`. Verifiers MUST refuse unknown
220    /// versions rather than fall back to a default — a chain with an
221    /// unknown version cannot be cryptographically validated.
222    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
249/// Genesis hash used as the predecessor of the first entry in any chain.
250pub 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/// Provenance chain aggregate. Append-only.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
262pub struct ProvenanceChain {
263    /// The scope this chain belongs to.
264    pub scope: String,
265    /// Entries in monotonically-increasing sequence.
266    pub entries: Vec<ChainEntry>,
267}
268
269impl ProvenanceChain {
270    /// Construct an empty chain for the given scope.
271    pub fn new(scope: String) -> Self {
272        Self {
273            scope,
274            entries: Vec::new(),
275        }
276    }
277
278    /// Hex SHA-256 of the most recent entry, or [`GENESIS_HASH_HEX`] if empty.
279    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    /// Sequence the next appended entry will use.
287    pub fn next_sequence(&self) -> u64 {
288        self.entries.last().map(|e| e.sequence + 1).unwrap_or(0)
289    }
290
291    /// Append a linear-chain event (empty semantic parents). Mints a v2
292    /// entry. Use [`Self::append_with_parents`] when the entry derives
293    /// from multiple prior facts.
294    pub fn append(&mut self, event: ProvenanceEvent) -> ChainEntry {
295        self.append_with_parents(event, Vec::new())
296    }
297
298    /// Append an event whose canonical hash includes the supplied
299    /// semantic-derivation parents. Parents are sorted lexicographically
300    /// in the canonical input (see [`ChainEntry::compute_hash_v2`]).
301    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    /// Verify chain integrity. Returns `ChainBroken` on first
332    /// inconsistency. Per-entry hash computation dispatches on
333    /// `entry.chain_version` so chains containing a v1 prefix + v2
334    /// suffix verify end-to-end.
335    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        // Frozen pre-ADR-037 wire format: no `chain_version`, no
473        // `parent_hashes_hex` in the JSON. `#[serde(default)]` fills both.
474        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        // Build a chain whose first entry is a hand-minted v1 entry
596        // (simulating pre-ADR-037 wire) followed by v2 entries via append().
597        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        // Sanity: head is v2.
619        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}