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 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 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 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 assert_eq!(
309 AuditChain::verify(&records)
310 .defect()
311 .map(AuditChainDefect::at),
312 Some(AuditSequence::new(2).unwrap())
313 );
314 }
315}