Skip to main content

boundgraph_core/
ledger.rs

1//! The Ledger Entry (`λ`) — one tamper-evident record.
2//!
3//! The ledger is event-sourced rather than telemetry, and that single choice
4//! buys three features from one mechanism: audit, deterministic replay, and
5//! time travel.
6//!
7//! Tamper-evidence comes from the chain. Each entry commits to the digest of
8//! the one before it, so altering entry *N* invalidates every entry after it.
9//! Rewriting history therefore means rewriting the whole tail, and the root
10//! digest gives that away.
11//!
12//! This module defines the entry and how the chain links. Writing entries
13//! anywhere is US-1001, and the sinks are US-1002 onward.
14
15use rust_decimal::Decimal;
16use serde::{Deserialize, Serialize};
17
18use crate::canonical::{Canonical, CanonicalError};
19use crate::digest::Digest;
20use crate::evidence::Evidence;
21use crate::grant::GrantSet;
22use crate::id::{RunId, StepId};
23use crate::isolation::Isolation;
24
25/// What a step asked for, what it got, and what was taken away.
26///
27/// Recording all three makes Law I auditable after the fact: "this step asked
28/// to write anywhere and was clamped to `/build`" is a fact in the ledger, not
29/// something to be reconstructed from logs.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct GrantDelta {
32    /// What the work order requested.
33    pub requested: GrantSet,
34    /// What the gate actually granted.
35    pub granted: GrantSet,
36    /// What was removed between the two.
37    pub attenuated: GrantSet,
38}
39
40impl GrantDelta {
41    /// Record a grant decision.
42    pub fn new(requested: GrantSet, granted: GrantSet, attenuated: GrantSet) -> Self {
43        Self {
44            requested,
45            granted,
46            attenuated,
47        }
48    }
49
50    /// Whether anything was taken away.
51    pub fn was_attenuated(&self) -> bool {
52        !self.attenuated.is_empty()
53    }
54}
55
56/// What a step cost.
57#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
58pub struct CostRecord {
59    /// Prompt tokens consumed.
60    pub tokens_in: u64,
61    /// Completion tokens produced.
62    pub tokens_out: u64,
63    /// Monetary cost.
64    ///
65    /// A decimal, never a float: money that does not add up exactly is worse
66    /// than no figure at all.
67    pub usd: Decimal,
68    /// Wall-clock time in milliseconds, including waiting.
69    pub wall_ms: u64,
70    /// Time spent inside tools, in milliseconds.
71    pub tool_ms: u64,
72}
73
74impl CostRecord {
75    /// Total tokens, in and out.
76    pub fn total_tokens(&self) -> u64 {
77        self.tokens_in.saturating_add(self.tokens_out)
78    }
79}
80
81/// Points at the journalled nondeterministic inputs for a step.
82///
83/// Replay substitutes these for the outside world. Without one, a step is not
84/// reproducible, which the determinism audit (US-0407) flags before the run.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct JournalRef {
87    /// Content address of the journal record.
88    pub digest: Digest,
89    /// How many nondeterministic inputs it captures.
90    pub entries: u32,
91}
92
93/// `λ` — one append-only, hash-chained ledger record.
94///
95/// # Example
96///
97/// ```
98/// use boundgraph_core::{Canonical, CostRecord, Digest, GrantDelta, GrantSet,
99///                  LedgerEntry, RunId, StepId};
100///
101/// let run = RunId::new();
102/// let step = StepId::new(Digest::of(b"step-1"));
103/// let delta = GrantDelta::new(GrantSet::EMPTY, GrantSet::EMPTY, GrantSet::EMPTY);
104///
105/// let first = LedgerEntry::genesis(run, step, delta.clone(), CostRecord::default(), None, None);
106/// let second = LedgerEntry::following(&first, step, delta, CostRecord::default(), None, None)?;
107///
108/// // Each entry commits to the one before it.
109/// assert_eq!(second.prev, first.digest()?);
110/// assert!(second.follows(&first)?);
111/// # Ok::<(), boundgraph_core::CanonicalError>(())
112/// ```
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct LedgerEntry {
115    /// Position in the chain, starting at zero and gapless.
116    pub seq: u64,
117    /// Digest of entry `seq - 1`. The genesis entry links to all-zero.
118    pub prev: Digest,
119    /// Which run this belongs to.
120    pub run: RunId,
121    /// Which step this records.
122    pub step: StepId,
123    /// What was requested, granted, and attenuated.
124    pub grant: GrantDelta,
125    /// What it cost.
126    pub cost: CostRecord,
127    /// The verdict, where the step reached one.
128    pub evidence: Option<Evidence>,
129    /// Where the journalled nondeterministic inputs live.
130    pub journal: Option<JournalRef>,
131    /// What actually confined the step, where the runner said (US-0508).
132    ///
133    /// In the entry rather than beside it, because it is a fact about the
134    /// step's *authority* and belongs where [`GrantDelta`] is: the chain
135    /// commits to it, so a confinement record cannot be edited afterwards
136    /// without giving itself away. That is the whole difference between "this
137    /// ran confined" being checkable and being trusted.
138    ///
139    /// `None` means the runner did not say, and is deliberately distinct from
140    /// [`Confinement::NONE`](crate::Confinement::NONE), which means it ran with
141    /// nothing enforced. Skipped when absent, so an entry from a runner that
142    /// says nothing encodes exactly as it did before this field existed and
143    /// every digest already written stays valid.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub isolation: Option<Isolation>,
146}
147
148/// The digest a genesis entry links back to: no predecessor.
149pub const GENESIS_PREV: Digest = Digest::from_bytes([0u8; 32]);
150
151impl LedgerEntry {
152    /// The first entry in a chain.
153    pub fn genesis(
154        run: RunId,
155        step: StepId,
156        grant: GrantDelta,
157        cost: CostRecord,
158        evidence: Option<Evidence>,
159        journal: Option<JournalRef>,
160    ) -> Self {
161        Self {
162            seq: 0,
163            prev: GENESIS_PREV,
164            run,
165            step,
166            grant,
167            cost,
168            evidence,
169            journal,
170            isolation: None,
171        }
172    }
173
174    /// The next entry after `previous`, linked to it by digest.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`CanonicalError`] if `previous` cannot be canonically encoded,
179    /// which would make the link uncomputable.
180    pub fn following(
181        previous: &Self,
182        step: StepId,
183        grant: GrantDelta,
184        cost: CostRecord,
185        evidence: Option<Evidence>,
186        journal: Option<JournalRef>,
187    ) -> Result<Self, CanonicalError> {
188        Ok(Self {
189            seq: previous.seq + 1,
190            prev: previous.digest()?,
191            run: previous.run,
192            step,
193            grant,
194            cost,
195            evidence,
196            journal,
197            isolation: None,
198        })
199    }
200
201    /// Record what actually confined the step (US-0508).
202    ///
203    /// A builder rather than a seventh constructor argument: the two
204    /// constructors are what every sink calls, most transitions are not an
205    /// execution, and a parameter that was `None` on five appends out of six
206    /// would be noise at every call site that never had one to give.
207    ///
208    /// Setting it changes the entry's digest, so this belongs before the entry
209    /// is handed to a chain rather than after.
210    #[must_use]
211    pub fn confined_by(mut self, isolation: Isolation) -> Self {
212        self.isolation = Some(isolation);
213        self
214    }
215
216    /// What confined this step, where the runner said.
217    ///
218    /// `None` means unrecorded. See [`isolation`](Self::isolation): a step that
219    /// ran with nothing enforced returns `Some`.
220    pub const fn confinement(&self) -> Option<Isolation> {
221        self.isolation
222    }
223
224    /// Whether this entry records what confined the step at all.
225    pub const fn is_confinement_recorded(&self) -> bool {
226        self.isolation.is_some()
227    }
228
229    /// Whether this step ran in a virtualising tier — what Profile C asks for.
230    ///
231    /// `false` for a step whose confinement was never recorded, because an
232    /// unrecorded step is not evidence that anything virtualised it. A caller
233    /// that needs to tell the two apart reads
234    /// [`is_confinement_recorded`](Self::is_confinement_recorded).
235    pub const fn ran_virtualised(&self) -> bool {
236        match self.isolation {
237            Some(isolation) => isolation.is_virtualising(),
238            None => false,
239        }
240    }
241
242    /// Whether this entry genuinely follows `previous`.
243    ///
244    /// Checks both the sequence number and the digest link. A gap or a broken
245    /// link means the chain has been altered.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`CanonicalError`] if `previous` cannot be canonically encoded.
250    pub fn follows(&self, previous: &Self) -> Result<bool, CanonicalError> {
251        Ok(self.seq == previous.seq + 1 && self.prev == previous.digest()?)
252    }
253
254    /// Whether this is the first entry in a chain.
255    pub fn is_genesis(&self) -> bool {
256        self.seq == 0 && self.prev == GENESIS_PREV
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::evidence::{CheckerId, EvidenceKind, Signature, SigningKeyId, Verdict};
264
265    fn delta() -> GrantDelta {
266        GrantDelta::new(GrantSet::EMPTY, GrantSet::EMPTY, GrantSet::EMPTY)
267    }
268
269    fn step(tag: &[u8]) -> StepId {
270        StepId::new(Digest::of(tag))
271    }
272
273    fn chain(len: usize) -> Vec<LedgerEntry> {
274        let run = RunId::new();
275        let mut out = vec![LedgerEntry::genesis(
276            run,
277            step(b"0"),
278            delta(),
279            CostRecord::default(),
280            None,
281            None,
282        )];
283        for i in 1..len {
284            let prev = out.last().unwrap();
285            out.push(
286                LedgerEntry::following(
287                    prev,
288                    step(format!("{i}").as_bytes()),
289                    delta(),
290                    CostRecord::default(),
291                    None,
292                    None,
293                )
294                .unwrap(),
295            );
296        }
297        out
298    }
299
300    #[test]
301    fn carries_every_documented_field() {
302        let e = LedgerEntry::genesis(
303            RunId::new(),
304            step(b"s"),
305            delta(),
306            CostRecord {
307                tokens_in: 100,
308                tokens_out: 20,
309                usd: Decimal::new(41, 2),
310                wall_ms: 1_200,
311                tool_ms: 300,
312            },
313            Some(Evidence::new(
314                Verdict::Pass,
315                CheckerId::new("c", 1),
316                [Digest::of(b"x")],
317                Signature::from_bytes([0u8; 64]),
318                SigningKeyId("k".into()),
319                EvidenceKind::Deterministic,
320            )),
321            Some(JournalRef {
322                digest: Digest::of(b"j"),
323                entries: 3,
324            }),
325        );
326        assert_eq!(e.seq, 0);
327        assert_eq!(e.cost.total_tokens(), 120);
328        assert_eq!(e.cost.usd, Decimal::new(41, 2));
329        assert!(e.evidence.is_some());
330        assert!(e.journal.is_some());
331    }
332
333    #[test]
334    fn genesis_links_to_nothing() {
335        let g = &chain(1)[0];
336        assert!(g.is_genesis());
337        assert_eq!(g.prev, GENESIS_PREV);
338    }
339
340    #[test]
341    fn each_entry_commits_to_its_predecessor() {
342        let c = chain(5);
343        for pair in c.windows(2) {
344            assert!(pair[1].follows(&pair[0]).unwrap());
345        }
346    }
347
348    #[test]
349    fn sequence_numbers_are_gapless() {
350        let c = chain(5);
351        for (i, e) in c.iter().enumerate() {
352            assert_eq!(e.seq, i as u64);
353        }
354    }
355
356    #[test]
357    fn altering_an_entry_breaks_every_link_after_it() {
358        let mut c = chain(5);
359
360        // Tamper with entry 2: make it look cheaper than it was.
361        c[2].cost.tokens_in = 999_999;
362
363        // Its own link backwards still checks out...
364        assert!(c[2].follows(&c[1]).unwrap());
365        // ...but entry 3 no longer follows the altered entry 2.
366        assert!(
367            !c[3].follows(&c[2]).unwrap(),
368            "tampering must invalidate the rest of the chain"
369        );
370    }
371
372    #[test]
373    fn a_reordered_chain_does_not_verify() {
374        let c = chain(4);
375        assert!(
376            !c[3].follows(&c[1]).unwrap(),
377            "entries cannot be resequenced"
378        );
379    }
380
381    #[test]
382    fn grant_delta_records_attenuation() {
383        use crate::grant::GrantAtom;
384        let d = GrantDelta::new(
385            GrantSet::new([GrantAtom::new("fs", "write", "/**")]),
386            GrantSet::new([GrantAtom::new("fs", "write", "/build/**")]),
387            GrantSet::new([GrantAtom::new("fs", "write", "/**")]),
388        );
389        assert!(d.was_attenuated());
390        assert!(!delta().was_attenuated());
391    }
392
393    #[test]
394    fn cost_is_decimal_not_float() {
395        // 0.1 + 0.2 must be exactly 0.3, which f64 cannot promise.
396        let a = Decimal::new(1, 1);
397        let b = Decimal::new(2, 1);
398        assert_eq!(a + b, Decimal::new(3, 1));
399    }
400
401    #[test]
402    fn round_trips_through_canonical_cbor() {
403        let e = chain(2).pop().unwrap();
404        let bytes = crate::to_canonical_cbor(&e).unwrap();
405        let back: LedgerEntry = crate::from_canonical_cbor(&bytes).unwrap();
406        assert_eq!(e, back);
407    }
408}