Skip to main content

made_core/entities/
audit_chain.rs

1//! [`AuditChain`] — verification of a ceremony's journal.
2//!
3//! The verifier depends on nothing but the records. It does not know
4//! which store produced them, so a host cannot satisfy it by asserting
5//! its own integrity: the answer comes from the bytes that were
6//! written, which is the point of chaining them in the first place.
7
8use crate::value_objects::{AuditChainDefect, AuditChainVerdict, AuditSequence};
9
10use super::AuditRecord;
11
12/// Stateless verification of an ordered journal.
13#[derive(Debug)]
14pub struct AuditChain;
15
16impl AuditChain {
17    /// Verify records given in the order the journal returned them.
18    ///
19    /// An empty journal is intact: nothing was written, so nothing was
20    /// tampered with. Verification stops at the first defect — past it,
21    /// no statement about the remaining records would be sound.
22    #[must_use]
23    pub fn verify(records: &[AuditRecord]) -> AuditChainVerdict {
24        let Some((first, rest)) = records.split_first() else {
25            return AuditChainVerdict::Intact;
26        };
27
28        if !first.sequence().is_first() {
29            return AuditChainVerdict::Broken(AuditChainDefect::DoesNotStartAtTheBeginning {
30                found: first.sequence(),
31            });
32        }
33        if first.previous_record_hash().is_some() {
34            return AuditChainVerdict::Broken(AuditChainDefect::UnexpectedRoot {
35                at: first.sequence(),
36            });
37        }
38        if let Some(verdict) = digest_defect(first) {
39            return verdict;
40        }
41
42        let mut previous = first;
43        for record in rest {
44            if record.ceremony_id() != previous.ceremony_id() {
45                return AuditChainVerdict::Broken(AuditChainDefect::ForeignCeremony {
46                    at: record.sequence(),
47                });
48            }
49            if !record.sequence().follows(previous.sequence()) {
50                return AuditChainVerdict::Broken(AuditChainDefect::SequenceBroken {
51                    expected: previous.sequence().next(),
52                    found: record.sequence(),
53                });
54            }
55            match record.previous_record_hash() {
56                None => {
57                    return AuditChainVerdict::Broken(AuditChainDefect::UnexpectedRoot {
58                        at: record.sequence(),
59                    })
60                }
61                Some(hash) if hash != previous.record_hash() => {
62                    return AuditChainVerdict::Broken(AuditChainDefect::LinkBroken {
63                        at: record.sequence(),
64                    })
65                }
66                Some(_) => {}
67            }
68            if let Some(verdict) = digest_defect(record) {
69                return verdict;
70            }
71            previous = record;
72        }
73
74        AuditChainVerdict::Intact
75    }
76
77    /// The position the next record must occupy.
78    #[must_use]
79    pub fn next_sequence(records: &[AuditRecord]) -> AuditSequence {
80        records
81            .last()
82            .map_or(AuditSequence::FIRST, |record| record.sequence().next())
83    }
84}
85
86/// A record whose digest cannot even be recomputed is treated as
87/// altered: an unrenderable timestamp means the stored bytes are not
88/// what this implementation would have written.
89fn digest_defect(record: &AuditRecord) -> Option<AuditChainVerdict> {
90    match record.digest_is_intact() {
91        Ok(true) => None,
92        Ok(false) | Err(_) => Some(AuditChainVerdict::Broken(AuditChainDefect::DigestAltered {
93            at: record.sequence(),
94        })),
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::entities::ceremony_events::StepCompleted;
102    use crate::entities::{AuditFact, CeremonyEvent};
103    use crate::value_objects::{
104        AuditActor, AuditActorKind, CeremonyId, CeremonyName, CeremonyVersion, EventId, RoleId,
105        StepAttempt, StepId, StepIteration, StepOutput, StepResult,
106    };
107    use serde_json::Value;
108    use time::macros::datetime;
109
110    fn fact(event_id: &str, ceremony: &str) -> AuditFact {
111        AuditFact {
112            event_id: EventId::new(event_id).unwrap(),
113            event: CeremonyEvent::StepCompleted(StepCompleted {
114                step_id: StepId::new("draft").unwrap(),
115                state_iteration: None,
116                iteration: StepIteration::FIRST,
117                attempt: StepAttempt::FIRST,
118                result: StepResult::completed(StepOutput::empty()).unwrap(),
119                next_iteration: None,
120                finished_by: RoleId::new("author").unwrap(),
121                finished_at: datetime!(2026-07-29 09:00:00 UTC),
122            }),
123            ceremony_id: CeremonyId::new(ceremony).unwrap(),
124            definition_name: CeremonyName::new("planning_ceremony").unwrap(),
125            definition_version: CeremonyVersion::v1(),
126            occurred_at: datetime!(2026-07-29 09:00:00 UTC),
127            actor: AuditActor::new("engineer-1", AuditActorKind::Human, None).unwrap(),
128            correlation_id: None,
129            causation_id: None,
130            trace: None,
131        }
132    }
133
134    fn chain() -> Vec<AuditRecord> {
135        let first = AuditRecord::first(fact("e1", "ceremony-1")).unwrap();
136        let second = AuditRecord::following(fact("e2", "ceremony-1"), &first).unwrap();
137        let third = AuditRecord::following(fact("e3", "ceremony-1"), &second).unwrap();
138        vec![first, second, third]
139    }
140
141    fn tampered(record: &AuditRecord, mutate: impl FnOnce(&mut Value)) -> AuditRecord {
142        let mut json = serde_json::to_value(record).unwrap();
143        mutate(&mut json);
144        serde_json::from_value(json).unwrap()
145    }
146
147    #[test]
148    fn an_empty_journal_is_intact() {
149        assert!(AuditChain::verify(&[]).is_intact());
150        assert_eq!(AuditChain::next_sequence(&[]), AuditSequence::FIRST);
151    }
152
153    #[test]
154    fn a_well_formed_journal_is_intact() {
155        let records = chain();
156
157        assert!(AuditChain::verify(&records).is_intact());
158        assert_eq!(AuditChain::next_sequence(&records).value(), 4);
159    }
160
161    #[test]
162    fn a_journal_missing_its_opening_records_is_detected() {
163        let records = chain();
164
165        assert_eq!(
166            AuditChain::verify(&records[1..]).defect(),
167            Some(AuditChainDefect::DoesNotStartAtTheBeginning {
168                found: AuditSequence::new(2).unwrap()
169            })
170        );
171    }
172
173    #[test]
174    fn a_record_removed_from_the_middle_is_detected() {
175        let records = chain();
176        let gapped = vec![records[0].clone(), records[2].clone()];
177
178        assert_eq!(
179            AuditChain::verify(&gapped).defect(),
180            Some(AuditChainDefect::SequenceBroken {
181                expected: AuditSequence::new(2).unwrap(),
182                found: AuditSequence::new(3).unwrap(),
183            })
184        );
185    }
186
187    #[test]
188    fn an_altered_record_is_detected_at_its_own_position() {
189        let mut records = chain();
190        records[1] = tampered(&records[1], |json| {
191            json["actor"]["actor_id"] = "someone-else".into();
192        });
193
194        assert_eq!(
195            AuditChain::verify(&records).defect(),
196            Some(AuditChainDefect::DigestAltered {
197                at: AuditSequence::new(2).unwrap()
198            })
199        );
200    }
201
202    #[test]
203    fn an_edited_event_payload_is_detected_at_its_own_position() {
204        // The chain now covers what happened: rewriting a step's output
205        // in the stored record is caught exactly like rewriting who did
206        // it.
207        let mut records = chain();
208        records[1] = tampered(&records[1], |json| {
209            json["event"]["result"]["output"]["rewritten"] = "after the fact".into();
210        });
211
212        assert_eq!(
213            AuditChain::verify(&records).defect(),
214            Some(AuditChainDefect::DigestAltered {
215                at: AuditSequence::new(2).unwrap()
216            })
217        );
218    }
219
220    #[test]
221    fn a_record_that_lost_its_event_is_detected_at_its_own_position() {
222        let mut records = chain();
223        records[2] = tampered(&records[2], |json| {
224            json["event"] = Value::Null;
225        });
226
227        assert_eq!(
228            AuditChain::verify(&records).defect(),
229            Some(AuditChainDefect::DigestAltered {
230                at: AuditSequence::new(3).unwrap()
231            })
232        );
233    }
234
235    #[test]
236    fn a_substituted_record_breaks_the_link_of_the_one_after_it() {
237        let records = chain();
238        let forged = AuditRecord::following(fact("forged", "ceremony-1"), &records[0]).unwrap();
239        let substituted = vec![records[0].clone(), forged, records[2].clone()];
240
241        // The forgery is internally sound — it is the third record,
242        // still naming the digest of the record it really followed,
243        // that exposes the substitution.
244        assert_eq!(
245            AuditChain::verify(&substituted).defect(),
246            Some(AuditChainDefect::LinkBroken {
247                at: AuditSequence::new(3).unwrap()
248            })
249        );
250    }
251
252    #[test]
253    fn a_grafted_foreign_record_is_detected() {
254        let records = chain();
255        let foreign = AuditRecord::first(fact("f1", "ceremony-2")).unwrap();
256        let grafted = vec![records[0].clone(), foreign];
257
258        assert_eq!(
259            AuditChain::verify(&grafted).defect(),
260            Some(AuditChainDefect::ForeignCeremony {
261                at: AuditSequence::FIRST
262            })
263        );
264    }
265
266    #[test]
267    fn an_opening_record_that_claims_a_predecessor_is_detected() {
268        let records = chain();
269        let rooted = tampered(&records[0], |json| {
270            json["previous_record_hash"] = serde_json::to_value([9_u8; 32]).unwrap();
271        });
272
273        assert_eq!(
274            AuditChain::verify(&[rooted]).defect(),
275            Some(AuditChainDefect::UnexpectedRoot {
276                at: AuditSequence::FIRST
277            })
278        );
279    }
280
281    #[test]
282    fn a_later_record_that_claims_no_predecessor_is_detected() {
283        let mut records = chain();
284        records[1] = tampered(&records[1], |json| {
285            json["previous_record_hash"] = Value::Null;
286        });
287
288        assert_eq!(
289            AuditChain::verify(&records).defect(),
290            Some(AuditChainDefect::UnexpectedRoot {
291                at: AuditSequence::new(2).unwrap()
292            })
293        );
294    }
295
296    #[test]
297    fn verification_stops_at_the_first_defect() {
298        let mut records = chain();
299        records[1] = tampered(&records[1], |json| {
300            json["actor"]["actor_id"] = "someone-else".into();
301        });
302        records[2] = tampered(&records[2], |json| {
303            json["actor"]["actor_id"] = "someone-else".into();
304        });
305
306        // Both are altered; only the earlier one is reported, because
307        // past a break nothing further can be trusted.
308        assert_eq!(
309            AuditChain::verify(&records)
310                .defect()
311                .map(AuditChainDefect::at),
312            Some(AuditSequence::new(2).unwrap())
313        );
314    }
315}