1use sha2::{Digest, Sha256};
15use time::{format_description::well_known::Rfc3339, OffsetDateTime};
16
17use crate::entities::{AuditFact, AuthorizedAuditFact, CeremonyEvent};
18use crate::error::DomainError;
19use crate::value_objects::{
20 AuditActor, AuditEventType, AuditRecordHash, AuditSequence, AuthorizationEvidence, CeremonyId,
21 CeremonyName, CeremonyVersion, EventId, EventSchemaVersion,
22};
23
24mod audit_record_serde;
25mod audit_record_wire;
26
27const CANONICAL_SCHEME_V1: &[u8] = b"underpass.made.audit-record.v1";
30
31const CANONICAL_SCHEME_V2: &[u8] = b"underpass.made.audit-record.v2";
35
36const CANONICAL_SCHEME_V3: &[u8] = b"underpass.made.audit-record.v3";
38
39pub(super) const LEGACY_SCHEMA_VERSION: u32 = 1;
42
43pub(super) const EVENT_BEARING_SCHEMA_VERSION: u32 = 2;
45
46pub(super) const AUTHORIZED_SCHEMA_VERSION: u32 = 3;
48
49pub const AUDIT_RECORD_SCHEMA_VERSION: u32 = AUTHORIZED_SCHEMA_VERSION;
54
55#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct AuditRecord {
70 event_id: EventId,
71 event_type: AuditEventType,
72 schema_version: u32,
73 ceremony_id: CeremonyId,
74 definition_name: CeremonyName,
75 definition_version: CeremonyVersion,
76 sequence: AuditSequence,
77 occurred_at: OffsetDateTime,
78 actor: AuditActor,
79 correlation_id: Option<EventId>,
80 causation_id: Option<EventId>,
81 trace_id: Option<String>,
82 event_schema_version: Option<EventSchemaVersion>,
83 event: Option<CeremonyEvent>,
84 authorization_evidence: Option<AuthorizationEvidence>,
85 previous_record_hash: Option<AuditRecordHash>,
86 record_hash: AuditRecordHash,
87}
88
89impl AuditRecord {
90 pub fn first(fact: AuditFact) -> Result<Self, DomainError> {
92 Self::seal(
93 fact,
94 None,
95 EVENT_BEARING_SCHEMA_VERSION,
96 AuditSequence::FIRST,
97 None,
98 )
99 }
100
101 pub fn first_authorized(fact: AuthorizedAuditFact) -> Result<Self, DomainError> {
103 let (fact, evidence) = fact.into_parts();
104 Self::seal(
105 fact,
106 Some(evidence),
107 AUTHORIZED_SCHEMA_VERSION,
108 AuditSequence::FIRST,
109 None,
110 )
111 }
112
113 pub fn following(fact: AuditFact, previous: &Self) -> Result<Self, DomainError> {
119 Self::validate_predecessor(previous)?;
120 if fact.ceremony_id != previous.ceremony_id {
121 return Err(DomainError::InvariantViolated {
122 reason: "an audit record must belong to the same ceremony as its predecessor",
123 });
124 }
125 Self::seal(
126 fact,
127 None,
128 EVENT_BEARING_SCHEMA_VERSION,
129 previous.sequence.next(),
130 Some(previous.record_hash),
131 )
132 }
133
134 pub fn following_authorized(
136 fact: AuthorizedAuditFact,
137 previous: &Self,
138 ) -> Result<Self, DomainError> {
139 Self::validate_predecessor(previous)?;
140 let (fact, evidence) = fact.into_parts();
141 if fact.ceremony_id != previous.ceremony_id {
142 return Err(DomainError::InvariantViolated {
143 reason: "an audit record must belong to the same ceremony as its predecessor",
144 });
145 }
146 Self::seal(
147 fact,
148 Some(evidence),
149 AUTHORIZED_SCHEMA_VERSION,
150 previous.sequence.next(),
151 Some(previous.record_hash),
152 )
153 }
154
155 fn seal(
156 fact: AuditFact,
157 authorization_evidence: Option<AuthorizationEvidence>,
158 schema_version: u32,
159 sequence: AuditSequence,
160 previous_record_hash: Option<AuditRecordHash>,
161 ) -> Result<Self, DomainError> {
162 if let Some(evidence) = authorization_evidence.as_ref() {
163 Self::validate_authorization_evidence(evidence, fact.occurred_at)?;
164 }
165 let trace_id = fact.trace.map(|trace| trace.trace_id().to_owned());
166 let mut record = Self {
167 event_id: fact.event_id,
168 event_type: fact.event.event_type(),
169 schema_version,
170 ceremony_id: fact.ceremony_id,
171 definition_name: fact.definition_name,
172 definition_version: fact.definition_version,
173 sequence,
174 occurred_at: fact.occurred_at,
175 actor: fact.actor,
176 correlation_id: fact.correlation_id,
177 causation_id: fact.causation_id,
178 trace_id,
179 event_schema_version: Some(fact.event.schema_version()),
180 event: Some(fact.event),
181 authorization_evidence,
182 previous_record_hash,
183 record_hash: AuditRecordHash::from_bytes([0; 32]),
184 };
185 record.record_hash = record.compute_hash()?;
186 Ok(record)
187 }
188
189 fn validate_predecessor(previous: &Self) -> Result<(), DomainError> {
190 if !previous.digest_is_intact()? {
191 return Err(DomainError::InvariantViolated {
192 reason: "an audit record cannot follow an unsupported or altered predecessor",
193 });
194 }
195 Ok(())
196 }
197
198 fn validate_authorization_evidence(
199 evidence: &AuthorizationEvidence,
200 occurred_at: OffsetDateTime,
201 ) -> Result<(), DomainError> {
202 if evidence.valid_until() <= evidence.admitted_at() {
203 return Err(DomainError::InvariantViolated {
204 reason: "audit authorization evidence expiry must follow admission",
205 });
206 }
207 if occurred_at < evidence.admitted_at() || occurred_at >= evidence.valid_until() {
208 return Err(DomainError::InvariantViolated {
209 reason: "audit fact must occur while its authorization evidence is live",
210 });
211 }
212 Ok(())
213 }
214
215 #[must_use]
216 pub fn event_id(&self) -> &EventId {
217 &self.event_id
218 }
219
220 #[must_use]
221 pub fn event_type(&self) -> AuditEventType {
222 self.event_type
223 }
224
225 #[must_use]
226 pub fn schema_version(&self) -> u32 {
227 self.schema_version
228 }
229
230 #[must_use]
231 pub fn ceremony_id(&self) -> &CeremonyId {
232 &self.ceremony_id
233 }
234
235 #[must_use]
236 pub fn definition_name(&self) -> &CeremonyName {
237 &self.definition_name
238 }
239
240 #[must_use]
241 pub fn definition_version(&self) -> &CeremonyVersion {
242 &self.definition_version
243 }
244
245 #[must_use]
246 pub fn sequence(&self) -> AuditSequence {
247 self.sequence
248 }
249
250 #[must_use]
251 pub fn occurred_at(&self) -> OffsetDateTime {
252 self.occurred_at
253 }
254
255 #[must_use]
256 pub fn actor(&self) -> &AuditActor {
257 &self.actor
258 }
259
260 #[must_use]
261 pub fn correlation_id(&self) -> Option<&EventId> {
262 self.correlation_id.as_ref()
263 }
264
265 #[must_use]
266 pub fn causation_id(&self) -> Option<&EventId> {
267 self.causation_id.as_ref()
268 }
269
270 #[must_use]
271 pub fn trace_id(&self) -> Option<&str> {
272 self.trace_id.as_deref()
273 }
274
275 #[must_use]
279 pub fn event_schema_version(&self) -> Option<EventSchemaVersion> {
280 self.event_schema_version
281 }
282
283 #[must_use]
288 pub fn event(&self) -> Option<&CeremonyEvent> {
289 self.event.as_ref()
290 }
291
292 #[must_use]
297 pub fn authorization_evidence(&self) -> Option<&AuthorizationEvidence> {
298 self.authorization_evidence.as_ref()
299 }
300
301 #[must_use]
302 pub fn previous_record_hash(&self) -> Option<AuditRecordHash> {
303 self.previous_record_hash
304 }
305
306 #[must_use]
307 pub fn record_hash(&self) -> AuditRecordHash {
308 self.record_hash
309 }
310
311 pub fn digest_is_intact(&self) -> Result<bool, DomainError> {
318 if !self.has_canonical_shape() {
319 return Ok(false);
320 }
321 Ok(self.compute_hash()? == self.record_hash)
322 }
323
324 #[must_use]
330 pub fn continues(&self, previous: &Self) -> bool {
331 self.ceremony_id == previous.ceremony_id
332 && self.sequence.follows(previous.sequence)
333 && self.previous_record_hash == Some(previous.record_hash)
334 }
335
336 fn has_canonical_shape(&self) -> bool {
338 match self.schema_version {
339 LEGACY_SCHEMA_VERSION => {
340 self.event.is_none()
341 && self.event_schema_version.is_none()
342 && self.authorization_evidence.is_none()
343 }
344 EVENT_BEARING_SCHEMA_VERSION => {
345 self.event.is_some()
346 && self.event_schema_version.is_some()
347 && self.authorization_evidence.is_none()
348 }
349 AUTHORIZED_SCHEMA_VERSION => {
350 self.event.is_some()
351 && self.event_schema_version.is_some()
352 && self.authorization_evidence.is_some()
353 }
354 _ => false,
355 }
356 }
357
358 fn compute_hash(&self) -> Result<AuditRecordHash, DomainError> {
366 let occurred_at =
367 self.occurred_at
368 .format(&Rfc3339)
369 .map_err(|_| DomainError::InvariantViolated {
370 reason: "audit record timestamp cannot be rendered canonically",
371 })?;
372
373 let mut canonical = Vec::new();
374 match self.schema_version {
375 LEGACY_SCHEMA_VERSION => canonical.extend_from_slice(CANONICAL_SCHEME_V1),
376 EVENT_BEARING_SCHEMA_VERSION => canonical.extend_from_slice(CANONICAL_SCHEME_V2),
377 AUTHORIZED_SCHEMA_VERSION => canonical.extend_from_slice(CANONICAL_SCHEME_V3),
378 _ => {
379 return Err(DomainError::InvariantViolated {
380 reason: "audit record schema version has no canonical form",
381 })
382 }
383 }
384 canonical.extend_from_slice(&self.schema_version.to_be_bytes());
385 write_field(&mut canonical, self.event_id.as_str().as_bytes());
386 write_field(&mut canonical, self.event_type.as_str().as_bytes());
387 write_field(&mut canonical, self.ceremony_id.as_str().as_bytes());
388 write_field(&mut canonical, self.definition_name.as_str().as_bytes());
389 write_field(&mut canonical, self.definition_version.as_str().as_bytes());
390 canonical.extend_from_slice(&self.sequence.value().to_be_bytes());
391 write_field(&mut canonical, occurred_at.as_bytes());
392 write_field(&mut canonical, self.actor.actor_id().as_bytes());
393 write_field(&mut canonical, self.actor.kind().as_str().as_bytes());
394 write_optional(
395 &mut canonical,
396 self.actor.role_id().map(|role| role.as_str().as_bytes()),
397 );
398 write_optional(
399 &mut canonical,
400 self.correlation_id
401 .as_ref()
402 .map(|id| id.as_str().as_bytes()),
403 );
404 write_optional(
405 &mut canonical,
406 self.causation_id.as_ref().map(|id| id.as_str().as_bytes()),
407 );
408 write_optional(&mut canonical, self.trace_id.as_deref().map(str::as_bytes));
409 write_optional(
410 &mut canonical,
411 self.previous_record_hash
412 .as_ref()
413 .map(|hash| hash.as_bytes().as_slice()),
414 );
415
416 if matches!(
417 self.schema_version,
418 EVENT_BEARING_SCHEMA_VERSION | AUTHORIZED_SCHEMA_VERSION
419 ) {
420 let (Some(version), Some(event)) = (self.event_schema_version, self.event.as_ref())
421 else {
422 return Err(DomainError::InvariantViolated {
423 reason: "a version-2 audit record must carry its event",
424 });
425 };
426 canonical.extend_from_slice(&version.get().to_be_bytes());
427 let payload =
428 serde_json::to_vec(event).map_err(|_| DomainError::InvariantViolated {
429 reason: "ceremony event cannot be rendered canonically",
430 })?;
431 write_field(&mut canonical, &payload);
432 }
433
434 if self.schema_version == AUTHORIZED_SCHEMA_VERSION {
435 let Some(evidence) = self.authorization_evidence.as_ref() else {
436 return Err(DomainError::InvariantViolated {
437 reason: "a version-3 audit record must carry authorization evidence",
438 });
439 };
440 let authorization =
441 serde_json::to_vec(evidence).map_err(|_| DomainError::InvariantViolated {
442 reason: "authorization evidence cannot be rendered canonically",
443 })?;
444 write_field(&mut canonical, &authorization);
445 }
446
447 let digest = Sha256::digest(&canonical);
448 Ok(AuditRecordHash::from_bytes(digest.into()))
449 }
450}
451
452fn write_field(buffer: &mut Vec<u8>, value: &[u8]) {
455 buffer.extend_from_slice(&(value.len() as u64).to_be_bytes());
456 buffer.extend_from_slice(value);
457}
458
459fn write_optional(buffer: &mut Vec<u8>, value: Option<&[u8]>) {
462 match value {
463 None => buffer.push(0),
464 Some(value) => {
465 buffer.push(1);
466 write_field(buffer, value);
467 }
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use std::collections::BTreeSet;
474
475 use super::*;
476 use crate::entities::ceremony_events::{
477 CeremonyCompleted, CeremonyInstanceStarted, StepCompleted, StepFailed, StepStarted,
478 };
479 use crate::value_objects::{
480 AuditActorKind, BudgetAccountId, BudgetReservationId, CeremonyContext, IdempotencyKey,
481 LeaseOwnerId, RoleId, StateId, StateIteration, StateVisit, StepAttempt, StepErrorMessage,
482 StepId, StepIteration, StepLease, StepOutput, StepResult,
483 };
484 use serde_json::Value;
485 use time::macros::datetime;
486
487 type Tampering = Box<dyn FnOnce(&mut Value)>;
489
490 const AT: OffsetDateTime = datetime!(2026-07-29 09:00:00 UTC);
491
492 fn event(event_type: AuditEventType) -> CeremonyEvent {
493 let step_id = StepId::new("draft").unwrap();
494 let author = RoleId::new("author").unwrap();
495 match event_type {
496 AuditEventType::CeremonyInstanceStarted => {
497 CeremonyEvent::CeremonyInstanceStarted(CeremonyInstanceStarted {
498 ceremony_id: CeremonyId::new("ceremony-1").unwrap(),
499 definition_name: CeremonyName::new("planning_ceremony").unwrap(),
500 definition_version: CeremonyVersion::v1(),
501 initial_state: StateId::new("OPEN").unwrap(),
502 step_ids: BTreeSet::from([step_id]),
503 context: CeremonyContext::empty(),
504 bound_definition: None,
505 lineage: None,
506 budget_account_id: None,
507 ceremony_deadline: None,
508 state_deadline: None,
509 created_at: AT,
510 })
511 }
512 AuditEventType::StepStarted => CeremonyEvent::StepStarted(StepStarted {
513 state_visit: None,
514 step_id,
515 state_iteration: Some(StateIteration::FIRST),
516 iteration: StepIteration::FIRST,
517 attempt: StepAttempt::FIRST,
518 lease: StepLease::new(
519 LeaseOwnerId::new("host-1").unwrap(),
520 IdempotencyKey::new("key-1").unwrap(),
521 AT,
522 datetime!(2026-07-29 10:00:00 UTC),
523 )
524 .unwrap(),
525 started_by: author,
526 role_from: None,
527 sealed_role: None,
528 deadline: None,
529 budget_reservation_id: None,
530 started_at: AT,
531 }),
532 AuditEventType::StepCompleted => CeremonyEvent::StepCompleted(StepCompleted {
533 state_visit: None,
534 step_id,
535 state_iteration: Some(StateIteration::FIRST),
536 iteration: StepIteration::FIRST,
537 attempt: StepAttempt::FIRST,
538 result: StepResult::completed(StepOutput::empty()).unwrap(),
539 next_iteration: None,
540 finished_by: author,
541 finished_at: AT,
542 }),
543 AuditEventType::StepFailed => CeremonyEvent::StepFailed(StepFailed {
544 state_visit: None,
545 step_id,
546 state_iteration: Some(StateIteration::FIRST),
547 iteration: StepIteration::FIRST,
548 attempt: StepAttempt::FIRST,
549 result: StepResult::failed(StepErrorMessage::new("boom").unwrap()).unwrap(),
550 finished_by: author,
551 finished_at: AT,
552 }),
553 AuditEventType::CeremonyCompleted => {
554 CeremonyEvent::CeremonyCompleted(CeremonyCompleted {
555 final_state: StateId::new("DONE").unwrap(),
556 completed_at: AT,
557 })
558 }
559 other => panic!("no sample event for {other:?}"),
560 }
561 }
562
563 fn fact(event_id: &str, event_type: AuditEventType) -> AuditFact {
564 AuditFact {
565 event_id: EventId::new(event_id).unwrap(),
566 event: event(event_type),
567 ceremony_id: CeremonyId::new("ceremony-1").unwrap(),
568 definition_name: CeremonyName::new("planning_ceremony").unwrap(),
569 definition_version: CeremonyVersion::v1(),
570 occurred_at: AT,
571 actor: AuditActor::new("engineer-1", AuditActorKind::Human, None).unwrap(),
572 correlation_id: None,
573 causation_id: None,
574 trace: None,
575 }
576 }
577
578 fn authorization_evidence() -> AuthorizationEvidence {
579 serde_json::from_value(serde_json::json!({
580 "decision_id": "a".repeat(64),
581 "request_id": "request-1",
582 "principal_id": "principal-1",
583 "action": "run_ceremony_step",
584 "scope": {
585 "kind": "ceremony",
586 "ceremony_id": "ceremony-1"
587 },
588 "target_digest": "b".repeat(64),
589 "policy_version": 7,
590 "admitted_at": "2026-07-29T08:59:00Z",
591 "valid_until": "2026-07-29T09:05:00Z"
592 }))
593 .unwrap()
594 }
595
596 fn chain_of_three() -> [AuditRecord; 3] {
597 let first =
598 AuditRecord::first(fact("e1", AuditEventType::CeremonyInstanceStarted)).unwrap();
599 let second =
600 AuditRecord::following(fact("e2", AuditEventType::StepStarted), &first).unwrap();
601 let third =
602 AuditRecord::following(fact("e3", AuditEventType::StepCompleted), &second).unwrap();
603 [first, second, third]
604 }
605
606 fn tampered(record: &AuditRecord, mutate: impl FnOnce(&mut Value)) -> AuditRecord {
609 let mut json = serde_json::to_value(record).unwrap();
610 mutate(&mut json);
611 serde_json::from_value(json).unwrap()
612 }
613
614 #[test]
615 fn the_first_record_opens_the_chain() {
616 let record =
617 AuditRecord::first(fact("e1", AuditEventType::CeremonyInstanceStarted)).unwrap();
618
619 assert!(record.sequence().is_first());
620 assert!(record.previous_record_hash().is_none());
621 assert!(record.digest_is_intact().unwrap());
622 assert_eq!(record.schema_version(), EVENT_BEARING_SCHEMA_VERSION);
623 }
624
625 #[test]
626 fn a_sealed_record_carries_its_event_and_derives_its_type() {
627 let sealed = fact("e1", AuditEventType::StepFailed);
628 let record = AuditRecord::first(sealed.clone()).unwrap();
629
630 assert_eq!(record.event(), Some(&sealed.event));
631 assert_eq!(record.event_type(), AuditEventType::StepFailed);
632 assert_eq!(record.event_schema_version(), Some(EventSchemaVersion::V2));
633 }
634
635 #[test]
636 fn a_sealed_record_round_trips_through_serde_and_stays_intact() {
637 let [first, second, third] = chain_of_three();
638
639 for record in [first, second, third] {
640 let json = serde_json::to_string(&record).unwrap();
641 let restored: AuditRecord = serde_json::from_str(&json).unwrap();
642
643 assert_eq!(restored, record);
644 assert!(restored.digest_is_intact().unwrap());
645 assert_eq!(restored.event(), record.event());
646 }
647 }
648
649 #[test]
650 fn budgeted_start_and_claim_round_trip_with_hashes_and_fold() {
651 let account = BudgetAccountId::new("ceremony-1").unwrap();
652 let reservation = BudgetReservationId::new("reservation-1").unwrap();
653 let mut started = fact("budgeted-start", AuditEventType::CeremonyInstanceStarted);
654 let CeremonyEvent::CeremonyInstanceStarted(event) = &mut started.event else {
655 unreachable!();
656 };
657 event.budget_account_id = Some(account.clone());
658 let first = AuditRecord::first(started).unwrap();
659
660 let mut claimed = fact("budgeted-claim", AuditEventType::StepStarted);
661 let CeremonyEvent::StepStarted(event) = &mut claimed.event else {
662 unreachable!();
663 };
664 event.state_visit = Some(StateVisit::FIRST);
665 event.budget_reservation_id = Some(reservation.clone());
666 let second = AuditRecord::following(claimed, &first).unwrap();
667
668 assert_eq!(first.event_schema_version(), Some(EventSchemaVersion::V4));
669 assert_eq!(second.event_schema_version(), Some(EventSchemaVersion::V6));
670 let encoded = serde_json::to_vec(&[first.clone(), second.clone()]).unwrap();
671 let restored: Vec<AuditRecord> = serde_json::from_slice(&encoded).unwrap();
672 assert_eq!(restored, [first, second]);
673 assert!(restored
674 .iter()
675 .all(|record| record.digest_is_intact().unwrap()));
676
677 let events: Vec<_> = restored
678 .iter()
679 .map(|record| record.event().unwrap())
680 .collect();
681 let instance = crate::entities::CeremonyInstance::rehydrate(events).unwrap();
682 assert_eq!(instance.budget_account_id(), Some(&account));
683 assert_eq!(
684 instance
685 .step_record(&StepId::new("draft").unwrap())
686 .unwrap()
687 .budget_reservation_id(),
688 Some(&reservation)
689 );
690 }
691
692 #[test]
693 fn authorized_record_uses_the_version_three_envelope() {
694 let evidence = authorization_evidence();
695 let record = AuditRecord::first_authorized(
696 fact("e1", AuditEventType::StepStarted).authorized(evidence.clone()),
697 )
698 .unwrap();
699
700 assert_eq!(record.schema_version(), AUDIT_RECORD_SCHEMA_VERSION);
701 assert_eq!(record.authorization_evidence(), Some(&evidence));
702 assert!(record.digest_is_intact().unwrap());
703 assert_eq!(
704 record.record_hash().to_string(),
705 "0b50be46717d750c0b4ff442300f613f2b21704f1904175c50cac4500beb0747"
706 );
707
708 let json = serde_json::to_value(&record).unwrap();
709 assert_eq!(json["schema_version"], AUTHORIZED_SCHEMA_VERSION);
710 assert!(json.get("event_id").is_none());
711 assert_eq!(json["record"]["event_id"], "e1");
712 assert_eq!(json["authorization"]["request_id"], "request-1");
713
714 let restored: AuditRecord = serde_json::from_value(json).unwrap();
715 assert_eq!(restored, record);
716 assert!(restored.digest_is_intact().unwrap());
717 }
718
719 #[test]
720 fn version_three_requires_the_envelope_and_authorization() {
721 let flat = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
722 let mut flat_v3 = serde_json::to_value(&flat).unwrap();
723 flat_v3["schema_version"] = AUTHORIZED_SCHEMA_VERSION.into();
724 assert!(serde_json::from_value::<AuditRecord>(flat_v3).is_err());
725
726 let authorized = AuditRecord::first_authorized(
727 fact("e1", AuditEventType::StepStarted).authorized(authorization_evidence()),
728 )
729 .unwrap();
730 let mut missing_evidence = serde_json::to_value(authorized).unwrap();
731 missing_evidence
732 .as_object_mut()
733 .unwrap()
734 .remove("authorization");
735 assert!(serde_json::from_value::<AuditRecord>(missing_evidence).is_err());
736
737 let mut evidence_on_v2 = serde_json::to_value(flat).unwrap();
738 evidence_on_v2["authorization"] = serde_json::to_value(authorization_evidence()).unwrap();
739 assert!(serde_json::from_value::<AuditRecord>(evidence_on_v2).is_err());
740 }
741
742 #[test]
743 fn authorization_must_be_live_when_the_fact_occurs() {
744 let mut evidence = serde_json::to_value(authorization_evidence()).unwrap();
745 evidence["valid_until"] = "2026-07-29T08:59:30Z".into();
746 let expired: AuthorizationEvidence = serde_json::from_value(evidence).unwrap();
747
748 assert!(AuditRecord::first_authorized(
749 fact("e1", AuditEventType::StepStarted).authorized(expired)
750 )
751 .is_err());
752
753 let authorized = AuditRecord::first_authorized(
754 fact("e1", AuditEventType::StepStarted).authorized(authorization_evidence()),
755 )
756 .unwrap();
757 let mut stored = serde_json::to_value(authorized).unwrap();
758 stored["authorization"]["valid_until"] = "2026-07-29T08:59:30Z".into();
759 assert!(serde_json::from_value::<AuditRecord>(stored).is_err());
760 }
761
762 #[test]
763 fn old_flat_reader_rejects_the_version_three_envelope() {
764 #[derive(serde::Deserialize)]
765 struct VersionTwoFlatReader {
766 #[serde(rename = "event_id")]
767 _event_id: EventId,
768 #[serde(rename = "schema_version")]
769 _schema_version: u32,
770 }
771
772 let authorized = AuditRecord::first_authorized(
773 fact("e1", AuditEventType::StepStarted).authorized(authorization_evidence()),
774 )
775 .unwrap();
776 let bytes = serde_json::to_vec(&authorized).unwrap();
777
778 assert!(serde_json::from_slice::<VersionTwoFlatReader>(&bytes).is_err());
779 }
780
781 #[test]
782 fn changing_authorization_evidence_breaks_the_version_three_digest() {
783 let authorized = AuditRecord::first_authorized(
784 fact("e1", AuditEventType::StepStarted).authorized(authorization_evidence()),
785 )
786 .unwrap();
787 let mut json = serde_json::to_value(authorized).unwrap();
788 json["authorization"]["decision_id"] = "c".repeat(64).into();
789
790 let altered: AuditRecord = serde_json::from_value(json).unwrap();
791 assert!(!altered.digest_is_intact().unwrap());
792 }
793
794 #[test]
795 fn no_record_can_follow_an_altered_predecessor() {
796 let first = AuditRecord::first_authorized(
797 fact("e1", AuditEventType::StepStarted).authorized(authorization_evidence()),
798 )
799 .unwrap();
800 let mut json = serde_json::to_value(first).unwrap();
801 json["authorization"]["decision_id"] = "c".repeat(64).into();
802 let altered: AuditRecord = serde_json::from_value(json).unwrap();
803
804 assert!(
805 AuditRecord::following(fact("e2", AuditEventType::StepCompleted), &altered).is_err()
806 );
807 assert!(AuditRecord::following_authorized(
808 fact("e2", AuditEventType::StepCompleted).authorized(authorization_evidence()),
809 &altered
810 )
811 .is_err());
812 }
813
814 #[test]
815 fn a_successor_continues_its_predecessor() {
816 let [first, second, third] = chain_of_three();
817
818 assert!(second.continues(&first));
819 assert!(third.continues(&second));
820 assert!(second.digest_is_intact().unwrap());
821 }
822
823 #[test]
824 fn sealing_the_same_fact_at_the_same_position_is_deterministic() {
825 let once = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
826 let twice = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
827
828 assert_eq!(once.record_hash(), twice.record_hash());
829 }
830
831 #[test]
832 fn altering_any_field_breaks_the_digest() {
833 let [first, ..] = chain_of_three();
834
835 let cases: Vec<(&str, Tampering)> = vec![
836 (
837 "actor identity",
838 Box::new(|json: &mut Value| json["actor"]["actor_id"] = "someone-else".into()),
839 ),
840 (
841 "actor kind",
842 Box::new(|json: &mut Value| json["actor"]["kind"] = "engine".into()),
843 ),
844 (
845 "timestamp",
846 Box::new(|json: &mut Value| {
847 json["occurred_at"] = "2026-07-29T10:00:00Z".into();
848 }),
849 ),
850 (
851 "sequence",
852 Box::new(|json: &mut Value| json["sequence"] = 7.into()),
853 ),
854 (
855 "ceremony",
856 Box::new(|json: &mut Value| json["ceremony_id"] = "ceremony-2".into()),
857 ),
858 (
859 "definition version",
860 Box::new(|json: &mut Value| json["definition_version"] = "2.0".into()),
861 ),
862 (
863 "event payload",
864 Box::new(|json: &mut Value| json["event"]["initial_state"] = "ELSEWHERE".into()),
865 ),
866 (
867 "event payload context",
868 Box::new(|json: &mut Value| {
869 json["event"]["context"]["planted"] = "after the fact".into();
870 }),
871 ),
872 (
873 "event removed",
874 Box::new(|json: &mut Value| json["event"] = Value::Null),
875 ),
876 ];
877
878 for (label, mutate) in cases {
879 let altered = tampered(&first, mutate);
880
881 assert!(
882 !altered.digest_is_intact().unwrap(),
883 "altering the {label} left the digest intact"
884 );
885 }
886 }
887
888 #[test]
889 fn a_record_whose_type_disagrees_with_its_event_cannot_be_read() {
890 let [first, ..] = chain_of_three();
891 let mut json = serde_json::to_value(&first).unwrap();
892 json["event_type"] = "step_failed".into();
893
894 let error = serde_json::from_value::<AuditRecord>(json).unwrap_err();
895
896 assert!(error.to_string().contains("step_failed"));
897 }
898
899 #[test]
900 fn a_record_read_under_another_payload_version_cannot_be_read() {
901 let [first, ..] = chain_of_three();
902 let mut json = serde_json::to_value(&first).unwrap();
903 json["event_schema_version"] = 2.into();
904
905 let error = serde_json::from_value::<AuditRecord>(json).unwrap_err();
906
907 assert!(error.to_string().contains("schema version 2"));
908 }
909
910 #[test]
911 fn an_event_without_a_payload_version_cannot_be_read() {
912 let [first, ..] = chain_of_three();
913 let mut json = serde_json::to_value(&first).unwrap();
914 json["event_schema_version"] = Value::Null;
915
916 assert!(serde_json::from_value::<AuditRecord>(json).is_err());
917 }
918
919 #[test]
920 fn a_version_two_record_without_its_event_is_not_intact() {
921 let [first, ..] = chain_of_three();
922 let stripped = tampered(&first, |json| {
923 json["event"] = Value::Null;
924 json["event_schema_version"] = Value::Null;
925 });
926
927 assert!(stripped.event().is_none());
928 assert_eq!(stripped.schema_version(), EVENT_BEARING_SCHEMA_VERSION);
929 assert!(!stripped.digest_is_intact().unwrap());
930 }
931
932 #[test]
933 fn a_version_one_record_that_gained_an_event_is_not_intact() {
934 let [first, ..] = chain_of_three();
938 let relabelled = tampered(&first, |json| json["schema_version"] = 1.into());
939
940 assert!(!relabelled.digest_is_intact().unwrap());
941 }
942
943 #[test]
944 fn an_unknown_record_schema_version_is_rejected() {
945 let [first, ..] = chain_of_three();
946 let mut json = serde_json::to_value(first).unwrap();
947 json["schema_version"] = 99.into();
948
949 assert!(serde_json::from_value::<AuditRecord>(json).is_err());
950 }
951
952 #[test]
953 fn the_payload_version_is_part_of_the_digest() {
954 let sealed = AuditRecord::first(fact("e1", AuditEventType::CeremonyCompleted)).unwrap();
958 let mut reclaimed = sealed.clone();
959 reclaimed.event_schema_version = Some(EventSchemaVersion::new(2).unwrap());
960
961 assert_ne!(
962 reclaimed.compute_hash().unwrap(),
963 sealed.compute_hash().unwrap()
964 );
965 }
966
967 #[test]
968 fn removing_a_record_breaks_the_chain() {
969 let [first, _removed, third] = chain_of_three();
970
971 assert!(!third.continues(&first));
972 }
973
974 #[test]
975 fn reordering_records_breaks_the_chain() {
976 let [first, second, third] = chain_of_three();
977
978 assert!(!second.continues(&third));
979 assert!(!first.continues(&second));
980 }
981
982 #[test]
983 fn an_inserted_record_cannot_be_woven_into_the_chain() {
984 let [first, second, _] = chain_of_three();
985 let forged =
986 AuditRecord::following(fact("forged", AuditEventType::StepFailed), &first).unwrap();
987
988 assert!(forged.continues(&first));
990 assert!(second.continues(&first));
993 assert_eq!(forged.sequence(), second.sequence());
994 assert_ne!(forged.record_hash(), second.record_hash());
995 assert!(!second.continues(&forged));
998 }
999
1000 #[test]
1001 fn a_record_from_another_ceremony_cannot_follow() {
1002 let first = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
1003 let mut foreign = fact("e2", AuditEventType::StepCompleted);
1004 foreign.ceremony_id = CeremonyId::new("ceremony-2").unwrap();
1005
1006 assert!(matches!(
1007 AuditRecord::following(foreign, &first),
1008 Err(DomainError::InvariantViolated { .. })
1009 ));
1010 }
1011
1012 #[test]
1013 fn field_boundaries_cannot_be_shifted_between_neighbours() {
1014 let mut left = fact("e1", AuditEventType::StepStarted);
1018 left.actor = AuditActor::new(
1019 "ab",
1020 AuditActorKind::Agent,
1021 Some(RoleId::new("reviewer").unwrap()),
1022 )
1023 .unwrap();
1024
1025 let mut right = fact("e1", AuditEventType::StepStarted);
1026 right.actor = AuditActor::new(
1027 "a",
1028 AuditActorKind::Agent,
1029 Some(RoleId::new("breviewer").unwrap()),
1030 )
1031 .unwrap();
1032
1033 let left = AuditRecord::first(left).unwrap();
1034 let right = AuditRecord::first(right).unwrap();
1035
1036 assert_ne!(left.record_hash(), right.record_hash());
1037 }
1038
1039 #[test]
1040 fn an_absent_optional_field_differs_from_a_present_one() {
1041 let without = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
1042 let mut with = fact("e1", AuditEventType::StepStarted);
1043 with.correlation_id = Some(EventId::new("c1").unwrap());
1044 let with = AuditRecord::first(with).unwrap();
1045
1046 assert_ne!(without.record_hash(), with.record_hash());
1047 }
1048}