made_core/entities/
audit_chain.rs1use crate::value_objects::{AuditChainDefect, AuditChainVerdict, AuditSequence};
9
10use super::AuditRecord;
11
12#[derive(Debug)]
14pub struct AuditChain;
15
16impl AuditChain {
17 #[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 #[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
86fn 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 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 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 assert_eq!(
310 AuditChain::verify(&records)
311 .defect()
312 .map(AuditChainDefect::at),
313 Some(AuditSequence::new(2).unwrap())
314 );
315 }
316}