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                state_visit: None,
115                step_id: StepId::new("draft").unwrap(),
116                state_iteration: None,
117                iteration: StepIteration::FIRST,
118                attempt: StepAttempt::FIRST,
119                result: StepResult::completed(StepOutput::empty()).unwrap(),
120                next_iteration: None,
121                finished_by: RoleId::new("author").unwrap(),
122                finished_at: datetime!(2026-07-29 09:00:00 UTC),
123            }),
124            ceremony_id: CeremonyId::new(ceremony).unwrap(),
125            definition_name: CeremonyName::new("planning_ceremony").unwrap(),
126            definition_version: CeremonyVersion::v1(),
127            occurred_at: datetime!(2026-07-29 09:00:00 UTC),
128            actor: AuditActor::new("engineer-1", AuditActorKind::Human, None).unwrap(),
129            correlation_id: None,
130            causation_id: None,
131            trace: None,
132        }
133    }
134
135    fn chain() -> Vec<AuditRecord> {
136        let first = AuditRecord::first(fact("e1", "ceremony-1")).unwrap();
137        let second = AuditRecord::following(fact("e2", "ceremony-1"), &first).unwrap();
138        let third = AuditRecord::following(fact("e3", "ceremony-1"), &second).unwrap();
139        vec![first, second, third]
140    }
141
142    fn tampered(record: &AuditRecord, mutate: impl FnOnce(&mut Value)) -> AuditRecord {
143        let mut json = serde_json::to_value(record).unwrap();
144        mutate(&mut json);
145        serde_json::from_value(json).unwrap()
146    }
147
148    #[test]
149    fn an_empty_journal_is_intact() {
150        assert!(AuditChain::verify(&[]).is_intact());
151        assert_eq!(AuditChain::next_sequence(&[]), AuditSequence::FIRST);
152    }
153
154    #[test]
155    fn a_well_formed_journal_is_intact() {
156        let records = chain();
157
158        assert!(AuditChain::verify(&records).is_intact());
159        assert_eq!(AuditChain::next_sequence(&records).value(), 4);
160    }
161
162    #[test]
163    fn a_journal_missing_its_opening_records_is_detected() {
164        let records = chain();
165
166        assert_eq!(
167            AuditChain::verify(&records[1..]).defect(),
168            Some(AuditChainDefect::DoesNotStartAtTheBeginning {
169                found: AuditSequence::new(2).unwrap()
170            })
171        );
172    }
173
174    #[test]
175    fn a_record_removed_from_the_middle_is_detected() {
176        let records = chain();
177        let gapped = vec![records[0].clone(), records[2].clone()];
178
179        assert_eq!(
180            AuditChain::verify(&gapped).defect(),
181            Some(AuditChainDefect::SequenceBroken {
182                expected: AuditSequence::new(2).unwrap(),
183                found: AuditSequence::new(3).unwrap(),
184            })
185        );
186    }
187
188    #[test]
189    fn an_altered_record_is_detected_at_its_own_position() {
190        let mut records = chain();
191        records[1] = tampered(&records[1], |json| {
192            json["actor"]["actor_id"] = "someone-else".into();
193        });
194
195        assert_eq!(
196            AuditChain::verify(&records).defect(),
197            Some(AuditChainDefect::DigestAltered {
198                at: AuditSequence::new(2).unwrap()
199            })
200        );
201    }
202
203    #[test]
204    fn an_edited_event_payload_is_detected_at_its_own_position() {
205        // The chain now covers what happened: rewriting a step's output
206        // in the stored record is caught exactly like rewriting who did
207        // it.
208        let mut records = chain();
209        records[1] = tampered(&records[1], |json| {
210            json["event"]["result"]["output"]["rewritten"] = "after the fact".into();
211        });
212
213        assert_eq!(
214            AuditChain::verify(&records).defect(),
215            Some(AuditChainDefect::DigestAltered {
216                at: AuditSequence::new(2).unwrap()
217            })
218        );
219    }
220
221    #[test]
222    fn a_record_that_lost_its_event_is_detected_at_its_own_position() {
223        let mut records = chain();
224        records[2] = tampered(&records[2], |json| {
225            json["event"] = Value::Null;
226        });
227
228        assert_eq!(
229            AuditChain::verify(&records).defect(),
230            Some(AuditChainDefect::DigestAltered {
231                at: AuditSequence::new(3).unwrap()
232            })
233        );
234    }
235
236    #[test]
237    fn a_substituted_record_breaks_the_link_of_the_one_after_it() {
238        let records = chain();
239        let forged = AuditRecord::following(fact("forged", "ceremony-1"), &records[0]).unwrap();
240        let substituted = vec![records[0].clone(), forged, records[2].clone()];
241
242        // The forgery is internally sound — it is the third record,
243        // still naming the digest of the record it really followed,
244        // that exposes the substitution.
245        assert_eq!(
246            AuditChain::verify(&substituted).defect(),
247            Some(AuditChainDefect::LinkBroken {
248                at: AuditSequence::new(3).unwrap()
249            })
250        );
251    }
252
253    #[test]
254    fn a_grafted_foreign_record_is_detected() {
255        let records = chain();
256        let foreign = AuditRecord::first(fact("f1", "ceremony-2")).unwrap();
257        let grafted = vec![records[0].clone(), foreign];
258
259        assert_eq!(
260            AuditChain::verify(&grafted).defect(),
261            Some(AuditChainDefect::ForeignCeremony {
262                at: AuditSequence::FIRST
263            })
264        );
265    }
266
267    #[test]
268    fn an_opening_record_that_claims_a_predecessor_is_detected() {
269        let records = chain();
270        let rooted = tampered(&records[0], |json| {
271            json["previous_record_hash"] = serde_json::to_value([9_u8; 32]).unwrap();
272        });
273
274        assert_eq!(
275            AuditChain::verify(&[rooted]).defect(),
276            Some(AuditChainDefect::UnexpectedRoot {
277                at: AuditSequence::FIRST
278            })
279        );
280    }
281
282    #[test]
283    fn a_later_record_that_claims_no_predecessor_is_detected() {
284        let mut records = chain();
285        records[1] = tampered(&records[1], |json| {
286            json["previous_record_hash"] = Value::Null;
287        });
288
289        assert_eq!(
290            AuditChain::verify(&records).defect(),
291            Some(AuditChainDefect::UnexpectedRoot {
292                at: AuditSequence::new(2).unwrap()
293            })
294        );
295    }
296
297    #[test]
298    fn verification_stops_at_the_first_defect() {
299        let mut records = chain();
300        records[1] = tampered(&records[1], |json| {
301            json["actor"]["actor_id"] = "someone-else".into();
302        });
303        records[2] = tampered(&records[2], |json| {
304            json["actor"]["actor_id"] = "someone-else".into();
305        });
306
307        // Both are altered; only the earlier one is reported, because
308        // past a break nothing further can be trusted.
309        assert_eq!(
310            AuditChain::verify(&records)
311                .defect()
312                .map(AuditChainDefect::at),
313            Some(AuditSequence::new(2).unwrap())
314        );
315    }
316}