Skip to main content

release_kit/plan/
evidence.rs

1//! The evidence ledger: every observed value, with who observed it, when,
2//! how, and what it digested to.
3//!
4//! Provenance attaches to claims and not to the document: a field in the
5//! plan that depends on the record, the disk, the bundle, and a fetch at
6//! once cites each one through `evidence_refs`, rather than the plan
7//! carrying one stamp that describes none of them.
8
9use serde::{Deserialize, Serialize};
10
11use crate::digest::Digest;
12
13/// What class of thing an evidence item is.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum EvidenceKind {
17    /// The landing record at the target.
18    Record,
19    /// The committed configuration at the target.
20    Configuration,
21    /// One destination's bytes at the target.
22    Destination,
23    /// The repository's own state: git, markers, the version file.
24    Repository,
25    /// A release bundle, read through the seam.
26    Bundle,
27    /// The engine and the host it runs on.
28    Host,
29    /// The pin a tool manager records for `rk`.
30    Pin,
31    /// A fact read from the forge.
32    Forge,
33}
34
35/// One observed value.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct EvidenceItem {
38    /// A stable id other fields cite.
39    pub id: String,
40    /// What class of thing was observed.
41    pub kind: EvidenceKind,
42    /// What produced the observation: the engine's own reader, a git
43    /// call, a source name.
44    pub producer: String,
45    /// When it was observed, RFC 3339.
46    pub observed_at: String,
47    /// The digest of what was observed, where the observation is bytes.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub sha256: Option<Digest>,
50    /// How it was collected, one line.
51    pub method: String,
52}
53
54/// The ledger under construction: items appended in observation order,
55/// each id unique.
56#[derive(Debug, Default)]
57pub struct Ledger {
58    items: Vec<EvidenceItem>,
59}
60
61impl Ledger {
62    /// An empty ledger.
63    #[must_use]
64    pub const fn new() -> Self {
65        Self { items: Vec::new() }
66    }
67
68    /// Record one observation and answer its id, for the field that
69    /// cites it.
70    pub fn observe(
71        &mut self,
72        id: impl Into<String>,
73        kind: EvidenceKind,
74        producer: impl Into<String>,
75        observed_at: &str,
76        sha256: Option<Digest>,
77        method: impl Into<String>,
78    ) -> String {
79        let id = id.into();
80        debug_assert!(
81            !self.items.iter().any(|item| item.id == id),
82            "evidence id {id} is already in the ledger"
83        );
84        self.items.push(EvidenceItem {
85            id: id.clone(),
86            kind,
87            producer: producer.into(),
88            observed_at: observed_at.to_owned(),
89            sha256,
90            method: method.into(),
91        });
92        id
93    }
94
95    /// The items, in observation order.
96    #[must_use]
97    pub fn into_items(self) -> Vec<EvidenceItem> {
98        self.items
99    }
100
101    /// Whether the ledger carries `id`.
102    #[must_use]
103    pub fn has(&self, id: &str) -> bool {
104        self.items.iter().any(|item| item.id == id)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::{EvidenceKind, Ledger};
111    use crate::digest::Digest;
112
113    #[test]
114    fn an_observation_answers_the_id_a_field_cites() {
115        let mut ledger = Ledger::new();
116        let id = ledger.observe(
117            "record",
118            EvidenceKind::Record,
119            "rk",
120            "2026-01-01T00:00:00Z",
121            Some(Digest::of(b"{}")),
122            "read .release-kit/manifest.json",
123        );
124        assert_eq!(id, "record");
125        assert!(ledger.has("record"));
126        let items = ledger.into_items();
127        assert_eq!(items.len(), 1);
128        assert_eq!(
129            serde_json::to_string(&items[0]).expect("serializes"),
130            format!(
131                r#"{{"id":"record","kind":"record","producer":"rk","observed_at":"2026-01-01T00:00:00Z","sha256":"{}","method":"read .release-kit/manifest.json"}}"#,
132                Digest::of(b"{}")
133            )
134        );
135    }
136}