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 succession: None,
507 budget_account_id: None,
508 ceremony_deadline: None,
509 state_deadline: None,
510 created_at: AT,
511 })
512 }
513 AuditEventType::StepStarted => CeremonyEvent::StepStarted(StepStarted {
514 state_visit: None,
515 step_id,
516 state_iteration: Some(StateIteration::FIRST),
517 iteration: StepIteration::FIRST,
518 attempt: StepAttempt::FIRST,
519 lease: StepLease::new(
520 LeaseOwnerId::new("host-1").unwrap(),
521 IdempotencyKey::new("key-1").unwrap(),
522 AT,
523 datetime!(2026-07-29 10:00:00 UTC),
524 )
525 .unwrap(),
526 started_by: author,
527 role_from: None,
528 sealed_role: None,
529 deadline: None,
530 budget_reservation_id: None,
531 started_at: AT,
532 }),
533 AuditEventType::StepCompleted => CeremonyEvent::StepCompleted(StepCompleted {
534 state_visit: None,
535 step_id,
536 state_iteration: Some(StateIteration::FIRST),
537 iteration: StepIteration::FIRST,
538 attempt: StepAttempt::FIRST,
539 result: StepResult::completed(StepOutput::empty()).unwrap(),
540 next_iteration: None,
541 finished_by: author,
542 finished_at: AT,
543 }),
544 AuditEventType::StepFailed => CeremonyEvent::StepFailed(StepFailed {
545 state_visit: None,
546 step_id,
547 state_iteration: Some(StateIteration::FIRST),
548 iteration: StepIteration::FIRST,
549 attempt: StepAttempt::FIRST,
550 result: StepResult::failed(StepErrorMessage::new("boom").unwrap()).unwrap(),
551 finished_by: author,
552 finished_at: AT,
553 }),
554 AuditEventType::CeremonyCompleted => {
555 CeremonyEvent::CeremonyCompleted(CeremonyCompleted {
556 final_state: StateId::new("DONE").unwrap(),
557 completed_at: AT,
558 })
559 }
560 other => panic!("no sample event for {other:?}"),
561 }
562 }
563
564 fn fact(event_id: &str, event_type: AuditEventType) -> AuditFact {
565 AuditFact {
566 event_id: EventId::new(event_id).unwrap(),
567 event: event(event_type),
568 ceremony_id: CeremonyId::new("ceremony-1").unwrap(),
569 definition_name: CeremonyName::new("planning_ceremony").unwrap(),
570 definition_version: CeremonyVersion::v1(),
571 occurred_at: AT,
572 actor: AuditActor::new("engineer-1", AuditActorKind::Human, None).unwrap(),
573 correlation_id: None,
574 causation_id: None,
575 trace: None,
576 }
577 }
578
579 fn authorization_evidence() -> AuthorizationEvidence {
580 serde_json::from_value(serde_json::json!({
581 "decision_id": "a".repeat(64),
582 "request_id": "request-1",
583 "principal_id": "principal-1",
584 "action": "run_ceremony_step",
585 "scope": {
586 "kind": "ceremony",
587 "ceremony_id": "ceremony-1"
588 },
589 "target_digest": "b".repeat(64),
590 "policy_version": 7,
591 "admitted_at": "2026-07-29T08:59:00Z",
592 "valid_until": "2026-07-29T09:05:00Z"
593 }))
594 .unwrap()
595 }
596
597 fn chain_of_three() -> [AuditRecord; 3] {
598 let first =
599 AuditRecord::first(fact("e1", AuditEventType::CeremonyInstanceStarted)).unwrap();
600 let second =
601 AuditRecord::following(fact("e2", AuditEventType::StepStarted), &first).unwrap();
602 let third =
603 AuditRecord::following(fact("e3", AuditEventType::StepCompleted), &second).unwrap();
604 [first, second, third]
605 }
606
607 fn tampered(record: &AuditRecord, mutate: impl FnOnce(&mut Value)) -> AuditRecord {
610 let mut json = serde_json::to_value(record).unwrap();
611 mutate(&mut json);
612 serde_json::from_value(json).unwrap()
613 }
614
615 #[test]
616 fn the_first_record_opens_the_chain() {
617 let record =
618 AuditRecord::first(fact("e1", AuditEventType::CeremonyInstanceStarted)).unwrap();
619
620 assert!(record.sequence().is_first());
621 assert!(record.previous_record_hash().is_none());
622 assert!(record.digest_is_intact().unwrap());
623 assert_eq!(record.schema_version(), EVENT_BEARING_SCHEMA_VERSION);
624 }
625
626 #[test]
627 fn a_sealed_record_carries_its_event_and_derives_its_type() {
628 let sealed = fact("e1", AuditEventType::StepFailed);
629 let record = AuditRecord::first(sealed.clone()).unwrap();
630
631 assert_eq!(record.event(), Some(&sealed.event));
632 assert_eq!(record.event_type(), AuditEventType::StepFailed);
633 assert_eq!(record.event_schema_version(), Some(EventSchemaVersion::V2));
634 }
635
636 #[test]
637 fn a_sealed_record_round_trips_through_serde_and_stays_intact() {
638 let [first, second, third] = chain_of_three();
639
640 for record in [first, second, third] {
641 let json = serde_json::to_string(&record).unwrap();
642 let restored: AuditRecord = serde_json::from_str(&json).unwrap();
643
644 assert_eq!(restored, record);
645 assert!(restored.digest_is_intact().unwrap());
646 assert_eq!(restored.event(), record.event());
647 }
648 }
649
650 #[test]
651 fn budgeted_start_and_claim_round_trip_with_hashes_and_fold() {
652 let account = BudgetAccountId::new("ceremony-1").unwrap();
653 let reservation = BudgetReservationId::new("reservation-1").unwrap();
654 let mut started = fact("budgeted-start", AuditEventType::CeremonyInstanceStarted);
655 let CeremonyEvent::CeremonyInstanceStarted(event) = &mut started.event else {
656 unreachable!();
657 };
658 event.budget_account_id = Some(account.clone());
659 let first = AuditRecord::first(started).unwrap();
660
661 let mut claimed = fact("budgeted-claim", AuditEventType::StepStarted);
662 let CeremonyEvent::StepStarted(event) = &mut claimed.event else {
663 unreachable!();
664 };
665 event.state_visit = Some(StateVisit::FIRST);
666 event.budget_reservation_id = Some(reservation.clone());
667 let second = AuditRecord::following(claimed, &first).unwrap();
668
669 assert_eq!(first.event_schema_version(), Some(EventSchemaVersion::V4));
670 assert_eq!(second.event_schema_version(), Some(EventSchemaVersion::V6));
671 let encoded = serde_json::to_vec(&[first.clone(), second.clone()]).unwrap();
672 let restored: Vec<AuditRecord> = serde_json::from_slice(&encoded).unwrap();
673 assert_eq!(restored, [first, second]);
674 assert!(restored
675 .iter()
676 .all(|record| record.digest_is_intact().unwrap()));
677
678 let events: Vec<_> = restored
679 .iter()
680 .map(|record| record.event().unwrap())
681 .collect();
682 let instance = crate::entities::CeremonyInstance::rehydrate(events).unwrap();
683 assert_eq!(instance.budget_account_id(), Some(&account));
684 assert_eq!(
685 instance
686 .step_record(&StepId::new("draft").unwrap())
687 .unwrap()
688 .budget_reservation_id(),
689 Some(&reservation)
690 );
691 }
692
693 #[test]
694 fn authorized_record_uses_the_version_three_envelope() {
695 let evidence = authorization_evidence();
696 let record = AuditRecord::first_authorized(
697 fact("e1", AuditEventType::StepStarted).authorized(evidence.clone()),
698 )
699 .unwrap();
700
701 assert_eq!(record.schema_version(), AUDIT_RECORD_SCHEMA_VERSION);
702 assert_eq!(record.authorization_evidence(), Some(&evidence));
703 assert!(record.digest_is_intact().unwrap());
704 assert_eq!(
705 record.record_hash().to_string(),
706 "0b50be46717d750c0b4ff442300f613f2b21704f1904175c50cac4500beb0747"
707 );
708
709 let json = serde_json::to_value(&record).unwrap();
710 assert_eq!(json["schema_version"], AUTHORIZED_SCHEMA_VERSION);
711 assert!(json.get("event_id").is_none());
712 assert_eq!(json["record"]["event_id"], "e1");
713 assert_eq!(json["authorization"]["request_id"], "request-1");
714
715 let restored: AuditRecord = serde_json::from_value(json).unwrap();
716 assert_eq!(restored, record);
717 assert!(restored.digest_is_intact().unwrap());
718 }
719
720 #[test]
721 fn version_three_requires_the_envelope_and_authorization() {
722 let flat = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
723 let mut flat_v3 = serde_json::to_value(&flat).unwrap();
724 flat_v3["schema_version"] = AUTHORIZED_SCHEMA_VERSION.into();
725 assert!(serde_json::from_value::<AuditRecord>(flat_v3).is_err());
726
727 let authorized = AuditRecord::first_authorized(
728 fact("e1", AuditEventType::StepStarted).authorized(authorization_evidence()),
729 )
730 .unwrap();
731 let mut missing_evidence = serde_json::to_value(authorized).unwrap();
732 missing_evidence
733 .as_object_mut()
734 .unwrap()
735 .remove("authorization");
736 assert!(serde_json::from_value::<AuditRecord>(missing_evidence).is_err());
737
738 let mut evidence_on_v2 = serde_json::to_value(flat).unwrap();
739 evidence_on_v2["authorization"] = serde_json::to_value(authorization_evidence()).unwrap();
740 assert!(serde_json::from_value::<AuditRecord>(evidence_on_v2).is_err());
741 }
742
743 #[test]
744 fn authorization_must_be_live_when_the_fact_occurs() {
745 let mut evidence = serde_json::to_value(authorization_evidence()).unwrap();
746 evidence["valid_until"] = "2026-07-29T08:59:30Z".into();
747 let expired: AuthorizationEvidence = serde_json::from_value(evidence).unwrap();
748
749 assert!(AuditRecord::first_authorized(
750 fact("e1", AuditEventType::StepStarted).authorized(expired)
751 )
752 .is_err());
753
754 let authorized = AuditRecord::first_authorized(
755 fact("e1", AuditEventType::StepStarted).authorized(authorization_evidence()),
756 )
757 .unwrap();
758 let mut stored = serde_json::to_value(authorized).unwrap();
759 stored["authorization"]["valid_until"] = "2026-07-29T08:59:30Z".into();
760 assert!(serde_json::from_value::<AuditRecord>(stored).is_err());
761 }
762
763 #[test]
764 fn old_flat_reader_rejects_the_version_three_envelope() {
765 #[derive(serde::Deserialize)]
766 struct VersionTwoFlatReader {
767 #[serde(rename = "event_id")]
768 _event_id: EventId,
769 #[serde(rename = "schema_version")]
770 _schema_version: u32,
771 }
772
773 let authorized = AuditRecord::first_authorized(
774 fact("e1", AuditEventType::StepStarted).authorized(authorization_evidence()),
775 )
776 .unwrap();
777 let bytes = serde_json::to_vec(&authorized).unwrap();
778
779 assert!(serde_json::from_slice::<VersionTwoFlatReader>(&bytes).is_err());
780 }
781
782 #[test]
783 fn changing_authorization_evidence_breaks_the_version_three_digest() {
784 let authorized = AuditRecord::first_authorized(
785 fact("e1", AuditEventType::StepStarted).authorized(authorization_evidence()),
786 )
787 .unwrap();
788 let mut json = serde_json::to_value(authorized).unwrap();
789 json["authorization"]["decision_id"] = "c".repeat(64).into();
790
791 let altered: AuditRecord = serde_json::from_value(json).unwrap();
792 assert!(!altered.digest_is_intact().unwrap());
793 }
794
795 #[test]
796 fn no_record_can_follow_an_altered_predecessor() {
797 let first = AuditRecord::first_authorized(
798 fact("e1", AuditEventType::StepStarted).authorized(authorization_evidence()),
799 )
800 .unwrap();
801 let mut json = serde_json::to_value(first).unwrap();
802 json["authorization"]["decision_id"] = "c".repeat(64).into();
803 let altered: AuditRecord = serde_json::from_value(json).unwrap();
804
805 assert!(
806 AuditRecord::following(fact("e2", AuditEventType::StepCompleted), &altered).is_err()
807 );
808 assert!(AuditRecord::following_authorized(
809 fact("e2", AuditEventType::StepCompleted).authorized(authorization_evidence()),
810 &altered
811 )
812 .is_err());
813 }
814
815 #[test]
816 fn a_successor_continues_its_predecessor() {
817 let [first, second, third] = chain_of_three();
818
819 assert!(second.continues(&first));
820 assert!(third.continues(&second));
821 assert!(second.digest_is_intact().unwrap());
822 }
823
824 #[test]
825 fn sealing_the_same_fact_at_the_same_position_is_deterministic() {
826 let once = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
827 let twice = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
828
829 assert_eq!(once.record_hash(), twice.record_hash());
830 }
831
832 #[test]
833 fn altering_any_field_breaks_the_digest() {
834 let [first, ..] = chain_of_three();
835
836 let cases: Vec<(&str, Tampering)> = vec![
837 (
838 "actor identity",
839 Box::new(|json: &mut Value| json["actor"]["actor_id"] = "someone-else".into()),
840 ),
841 (
842 "actor kind",
843 Box::new(|json: &mut Value| json["actor"]["kind"] = "engine".into()),
844 ),
845 (
846 "timestamp",
847 Box::new(|json: &mut Value| {
848 json["occurred_at"] = "2026-07-29T10:00:00Z".into();
849 }),
850 ),
851 (
852 "sequence",
853 Box::new(|json: &mut Value| json["sequence"] = 7.into()),
854 ),
855 (
856 "ceremony",
857 Box::new(|json: &mut Value| json["ceremony_id"] = "ceremony-2".into()),
858 ),
859 (
860 "definition version",
861 Box::new(|json: &mut Value| json["definition_version"] = "2.0".into()),
862 ),
863 (
864 "event payload",
865 Box::new(|json: &mut Value| json["event"]["initial_state"] = "ELSEWHERE".into()),
866 ),
867 (
868 "event payload context",
869 Box::new(|json: &mut Value| {
870 json["event"]["context"]["planted"] = "after the fact".into();
871 }),
872 ),
873 (
874 "event removed",
875 Box::new(|json: &mut Value| json["event"] = Value::Null),
876 ),
877 ];
878
879 for (label, mutate) in cases {
880 let altered = tampered(&first, mutate);
881
882 assert!(
883 !altered.digest_is_intact().unwrap(),
884 "altering the {label} left the digest intact"
885 );
886 }
887 }
888
889 #[test]
890 fn a_record_whose_type_disagrees_with_its_event_cannot_be_read() {
891 let [first, ..] = chain_of_three();
892 let mut json = serde_json::to_value(&first).unwrap();
893 json["event_type"] = "step_failed".into();
894
895 let error = serde_json::from_value::<AuditRecord>(json).unwrap_err();
896
897 assert!(error.to_string().contains("step_failed"));
898 }
899
900 #[test]
901 fn a_record_read_under_another_payload_version_cannot_be_read() {
902 let [first, ..] = chain_of_three();
903 let mut json = serde_json::to_value(&first).unwrap();
904 json["event_schema_version"] = 2.into();
905
906 let error = serde_json::from_value::<AuditRecord>(json).unwrap_err();
907
908 assert!(error.to_string().contains("schema version 2"));
909 }
910
911 #[test]
912 fn an_event_without_a_payload_version_cannot_be_read() {
913 let [first, ..] = chain_of_three();
914 let mut json = serde_json::to_value(&first).unwrap();
915 json["event_schema_version"] = Value::Null;
916
917 assert!(serde_json::from_value::<AuditRecord>(json).is_err());
918 }
919
920 #[test]
921 fn a_version_two_record_without_its_event_is_not_intact() {
922 let [first, ..] = chain_of_three();
923 let stripped = tampered(&first, |json| {
924 json["event"] = Value::Null;
925 json["event_schema_version"] = Value::Null;
926 });
927
928 assert!(stripped.event().is_none());
929 assert_eq!(stripped.schema_version(), EVENT_BEARING_SCHEMA_VERSION);
930 assert!(!stripped.digest_is_intact().unwrap());
931 }
932
933 #[test]
934 fn a_version_one_record_that_gained_an_event_is_not_intact() {
935 let [first, ..] = chain_of_three();
939 let relabelled = tampered(&first, |json| json["schema_version"] = 1.into());
940
941 assert!(!relabelled.digest_is_intact().unwrap());
942 }
943
944 #[test]
945 fn an_unknown_record_schema_version_is_rejected() {
946 let [first, ..] = chain_of_three();
947 let mut json = serde_json::to_value(first).unwrap();
948 json["schema_version"] = 99.into();
949
950 assert!(serde_json::from_value::<AuditRecord>(json).is_err());
951 }
952
953 #[test]
954 fn the_payload_version_is_part_of_the_digest() {
955 let sealed = AuditRecord::first(fact("e1", AuditEventType::CeremonyCompleted)).unwrap();
959 let mut reclaimed = sealed.clone();
960 reclaimed.event_schema_version = Some(EventSchemaVersion::new(2).unwrap());
961
962 assert_ne!(
963 reclaimed.compute_hash().unwrap(),
964 sealed.compute_hash().unwrap()
965 );
966 }
967
968 #[test]
969 fn removing_a_record_breaks_the_chain() {
970 let [first, _removed, third] = chain_of_three();
971
972 assert!(!third.continues(&first));
973 }
974
975 #[test]
976 fn reordering_records_breaks_the_chain() {
977 let [first, second, third] = chain_of_three();
978
979 assert!(!second.continues(&third));
980 assert!(!first.continues(&second));
981 }
982
983 #[test]
984 fn an_inserted_record_cannot_be_woven_into_the_chain() {
985 let [first, second, _] = chain_of_three();
986 let forged =
987 AuditRecord::following(fact("forged", AuditEventType::StepFailed), &first).unwrap();
988
989 assert!(forged.continues(&first));
991 assert!(second.continues(&first));
994 assert_eq!(forged.sequence(), second.sequence());
995 assert_ne!(forged.record_hash(), second.record_hash());
996 assert!(!second.continues(&forged));
999 }
1000
1001 #[test]
1002 fn a_record_from_another_ceremony_cannot_follow() {
1003 let first = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
1004 let mut foreign = fact("e2", AuditEventType::StepCompleted);
1005 foreign.ceremony_id = CeremonyId::new("ceremony-2").unwrap();
1006
1007 assert!(matches!(
1008 AuditRecord::following(foreign, &first),
1009 Err(DomainError::InvariantViolated { .. })
1010 ));
1011 }
1012
1013 #[test]
1014 fn field_boundaries_cannot_be_shifted_between_neighbours() {
1015 let mut left = fact("e1", AuditEventType::StepStarted);
1019 left.actor = AuditActor::new(
1020 "ab",
1021 AuditActorKind::Agent,
1022 Some(RoleId::new("reviewer").unwrap()),
1023 )
1024 .unwrap();
1025
1026 let mut right = fact("e1", AuditEventType::StepStarted);
1027 right.actor = AuditActor::new(
1028 "a",
1029 AuditActorKind::Agent,
1030 Some(RoleId::new("breviewer").unwrap()),
1031 )
1032 .unwrap();
1033
1034 let left = AuditRecord::first(left).unwrap();
1035 let right = AuditRecord::first(right).unwrap();
1036
1037 assert_ne!(left.record_hash(), right.record_hash());
1038 }
1039
1040 #[test]
1041 fn an_absent_optional_field_differs_from_a_present_one() {
1042 let without = AuditRecord::first(fact("e1", AuditEventType::StepStarted)).unwrap();
1043 let mut with = fact("e1", AuditEventType::StepStarted);
1044 with.correlation_id = Some(EventId::new("c1").unwrap());
1045 let with = AuditRecord::first(with).unwrap();
1046
1047 assert_ne!(without.record_hash(), with.record_hash());
1048 }
1049}