Skip to main content

spec_driven_docs/plan/
evidence.rs

1//! What the plan observed, and how.
2//!
3//! A plan's fields come from several places at once: the instance record,
4//! the disk, a release bundle, and sometimes a registry read. A reader who
5//! cannot tell which is reading a claim without its source. The ledger is
6//! that source: one item per observation, and a reference from every field
7//! that rests on it.
8
9use serde::{Deserialize, Serialize};
10
11use crate::domain::ownership::Sha256;
12
13/// Where one observation came from.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum Producer {
17    /// The target's working tree.
18    Disk,
19    /// The instance record.
20    Record,
21    /// The project's own declaration.
22    Declaration,
23    /// A release bundle.
24    Bundle,
25    /// The registry index.
26    Registry,
27    /// The host this command runs on.
28    Host,
29}
30
31/// One thing the plan observed.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Evidence {
34    /// A stable reference other sections cite.
35    pub id: String,
36    /// What was observed.
37    pub about: String,
38    /// Who produced it.
39    pub producer: Producer,
40    /// When, as an RFC 3339 timestamp the caller supplied.
41    pub observed_at: String,
42    /// The digest of what was read, where the observation has bytes.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub sha256: Option<Sha256>,
45    /// How it was read, in one phrase.
46    pub method: String,
47}
48
49/// The ledger a plan carries, in the order the observations were made.
50#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Ledger {
52    /// Every observation.
53    pub items: Vec<Evidence>,
54}
55
56impl Ledger {
57    /// An empty ledger.
58    #[must_use]
59    pub const fn new() -> Self {
60        Self { items: Vec::new() }
61    }
62
63    /// Record one observation and return the reference to cite.
64    pub fn record(
65        &mut self,
66        id: &str,
67        about: &str,
68        producer: Producer,
69        observed_at: &str,
70        sha256: Option<Sha256>,
71        method: &str,
72    ) -> String {
73        self.items.push(Evidence {
74            id: id.to_string(),
75            about: about.to_string(),
76            producer,
77            observed_at: observed_at.to_string(),
78            sha256,
79            method: method.to_string(),
80        });
81        id.to_string()
82    }
83
84    /// Whether the ledger carries one reference.
85    #[must_use]
86    pub fn holds(&self, id: &str) -> bool {
87        self.items.iter().any(|item| item.id == id)
88    }
89
90    /// Every reference, in order.
91    #[must_use]
92    pub fn ids(&self) -> Vec<&str> {
93        self.items.iter().map(|item| item.id.as_str()).collect()
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn a_recorded_observation_is_citable_by_the_reference_it_returns() {
103        let mut ledger = Ledger::new();
104        let reference = ledger.record(
105            "record",
106            "the instance manifest",
107            Producer::Record,
108            "2026-09-12T00:00:00Z",
109            Some(Sha256::of(b"x")),
110            "read from disk",
111        );
112        assert_eq!(reference, "record");
113        assert!(ledger.holds("record"));
114        assert!(!ledger.holds("absent"));
115        assert_eq!(ledger.ids(), ["record"]);
116    }
117
118    #[test]
119    fn an_observation_without_bytes_carries_no_digest() {
120        let mut ledger = Ledger::new();
121        ledger.record(
122            "host",
123            "the resolved paths",
124            Producer::Host,
125            "2026-09-12T00:00:00Z",
126            None,
127            "read from the environment",
128        );
129        assert_eq!(ledger.items[0].sha256, None);
130        assert_eq!(ledger.items[0].producer, Producer::Host);
131    }
132}