Skip to main content

made_core/entities/
audit_record.rs

1//! [`AuditRecord`] — one tamper-evident fact in a ceremony's journal.
2//!
3//! Publishing a typed event does not make an audit. A message can be
4//! lost, duplicated, published after persistence failed, or stored
5//! without a verifiable order. A record binds a fact to its position
6//! and to every fact before it, so removing, altering, reordering or
7//! inserting one is detectable without trusting the store that held it.
8//!
9//! Since schema version 2 the record carries the event itself, payload
10//! included, and the digest covers it: the chain now verifies what
11//! happened, not only that something did. Version 3 additionally seals
12//! the typed authorization decision that admitted the mutation.
13
14use 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
27/// Domain separator of the first record shape, which carried no
28/// payload. Records sealed under it keep verifying byte for byte.
29const CANONICAL_SCHEME_V1: &[u8] = b"underpass.made.audit-record.v1";
30
31/// Domain separator of the event-bearing flat shape. A digest computed under a
32/// different scheme can never collide with one computed under this
33/// version, and bumping it is how the algorithm is versioned.
34const CANONICAL_SCHEME_V2: &[u8] = b"underpass.made.audit-record.v2";
35
36/// Domain separator for the authorization-bearing envelope.
37const CANONICAL_SCHEME_V3: &[u8] = b"underpass.made.audit-record.v3";
38
39/// The shape records were sealed in before the event travelled inside
40/// them.
41pub(super) const LEGACY_SCHEMA_VERSION: u32 = 1;
42
43/// Flat record shape that carries the ceremony event but no admission proof.
44pub(super) const EVENT_BEARING_SCHEMA_VERSION: u32 = 2;
45
46/// Enveloped record shape with mandatory typed authorization evidence.
47pub(super) const AUTHORIZED_SCHEMA_VERSION: u32 = 3;
48
49/// Version of the record's field set. It participates in the digest,
50/// so records written under different shapes cannot be silently mixed
51/// into one chain. This is the latest readable shape; records sealed without
52/// authorization evidence deliberately remain at version 2.
53pub const AUDIT_RECORD_SCHEMA_VERSION: u32 = AUTHORIZED_SCHEMA_VERSION;
54
55/// One fact in a ceremony's audit journal.
56///
57/// Immutable by construction: the digest is computed once, from the
58/// content and the previous record's digest, and every accessor is
59/// read-only. There is no setter that could leave the digest stale.
60///
61/// # Three shapes in one type
62///
63/// Version 1 is the historical flat envelope without an event. Version 2 is
64/// the historical flat envelope carrying an event. Version 3 is an explicit
65/// envelope carrying both the event and mandatory authorization evidence.
66/// The optional fields keep all three generations readable in one domain
67/// type; [`Self::has_canonical_shape`] rejects every contradictory mixture.
68#[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    /// Seal a fact as the first record of a ceremony's journal.
91    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    /// Seal an authorized fact as the first record of a ceremony's journal.
102    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    /// Seal a fact as the record that follows `previous`.
114    ///
115    /// The successor's position and previous digest are taken from the
116    /// predecessor rather than supplied, so a caller cannot append a
117    /// record that claims to follow something it does not.
118    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    /// Seal an authorized fact after a verified predecessor.
135    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    /// The shape the sealed event's payload was written in.
276    ///
277    /// Absent only on records sealed under schema version 1.
278    #[must_use]
279    pub fn event_schema_version(&self) -> Option<EventSchemaVersion> {
280        self.event_schema_version
281    }
282
283    /// The event this record seals, payload included.
284    ///
285    /// Absent only on records sealed under schema version 1, which
286    /// recorded that something happened and not what.
287    #[must_use]
288    pub fn event(&self) -> Option<&CeremonyEvent> {
289        self.event.as_ref()
290    }
291
292    /// The allow decision that admitted this mutation.
293    ///
294    /// Present exactly on schema-version-3 records. Its absence on historical
295    /// records is history, not an implicit authorization decision.
296    #[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    /// Whether the digest still matches the content.
312    ///
313    /// A record that fails this was altered after it was sealed, or was
314    /// never sealed by this implementation — which includes a
315    /// version-2 record that has lost its event, and a version-1 record
316    /// that has gained one.
317    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    /// Whether this record legitimately continues `previous`.
325    ///
326    /// Checks the three ways a chain breaks at a join: the wrong
327    /// ceremony, a position that is not the immediate successor, and a
328    /// previous digest that does not match the record it names.
329    #[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    /// Whether the fields present match what the schema version seals.
337    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    /// The preimage of the digest.
359    ///
360    /// Under schema version 1 it is, byte for byte, what that version
361    /// wrote. Under version 2 the scheme changes and the event's payload
362    /// version and canonical JSON follow the envelope fields, so a
363    /// payload edit — or a payload reread under another version — is a
364    /// different digest.
365    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
452/// Length-prefixed so no field value can be mistaken for a boundary
453/// between fields, whatever bytes it contains.
454fn 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
459/// An absent field is one byte; a present one is tagged before its
460/// length, so absence and emptiness are distinguishable.
461fn 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    /// One way of editing a stored record, applied to its JSON.
488    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    /// Round-trip through JSON so a field can be altered exactly the way
608    /// someone editing the stored record would.
609    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        // Sealed under version 2, then relabelled as version 1 with the
936        // event still attached: neither shape's preimage matches, and
937        // the legacy shape refuses to carry a payload at all.
938        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        // Two records identical but for the payload version they claim
956        // must not share a digest, or a payload could be reread under a
957        // shape it was not written in without the chain noticing.
958        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        // The forgery is a well-formed successor of the first record...
990        assert!(forged.continues(&first));
991        // ...but the record that really followed still names the first
992        // one, so both cannot occupy the position.
993        assert!(second.continues(&first));
994        assert_eq!(forged.sequence(), second.sequence());
995        assert_ne!(forged.record_hash(), second.record_hash());
996        // And nothing that followed the genuine record follows the
997        // forgery.
998        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        // The classic attack on a concatenated digest: move a character
1016        // from one field into the next and keep the same byte stream.
1017        // Length prefixes are what make these two records differ.
1018        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}