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}