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                    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    /// Round-trip through JSON so a field can be altered exactly the way
607    /// someone editing the stored record would.
608    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        // Sealed under version 2, then relabelled as version 1 with the
935        // event still attached: neither shape's preimage matches, and
936        // the legacy shape refuses to carry a payload at all.
937        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        // Two records identical but for the payload version they claim
955        // must not share a digest, or a payload could be reread under a
956        // shape it was not written in without the chain noticing.
957        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        // The forgery is a well-formed successor of the first record...
989        assert!(forged.continues(&first));
990        // ...but the record that really followed still names the first
991        // one, so both cannot occupy the position.
992        assert!(second.continues(&first));
993        assert_eq!(forged.sequence(), second.sequence());
994        assert_ne!(forged.record_hash(), second.record_hash());
995        // And nothing that followed the genuine record follows the
996        // forgery.
997        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        // The classic attack on a concatenated digest: move a character
1015        // from one field into the next and keep the same byte stream.
1016        // Length prefixes are what make these two records differ.
1017        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}