use crate::error::DomainError;
use crate::value_objects::{AuditEventType, EventSchemaVersion};
use super::CeremonyEvent;
#[derive(Debug)]
pub struct CeremonyEventReader;
impl CeremonyEventReader {
pub fn read(
event_type: AuditEventType,
version: EventSchemaVersion,
raw: serde_json::Value,
) -> Result<CeremonyEvent, DomainError> {
let supported = match version {
EventSchemaVersion::V1 => true,
EventSchemaVersion::V2 => matches!(
event_type,
AuditEventType::StepStarted
| AuditEventType::StepCompleted
| AuditEventType::StepFailed
| AuditEventType::TransitionApplied
| AuditEventType::ContextWritten
| AuditEventType::StateIterationStarted
),
EventSchemaVersion::V3 => matches!(
event_type,
AuditEventType::StepStarted
| AuditEventType::StepCompleted
| AuditEventType::StepFailed
| AuditEventType::TransitionApplied
),
EventSchemaVersion::V4 => event_type == AuditEventType::StepStarted,
_ => false,
};
if !supported {
return Err(unreadable(
event_type,
version,
"no reader exists for this schema version",
));
}
let event = serde_json::from_value::<CeremonyEvent>(raw).map_err(|_| {
unreadable(
event_type,
version,
"the payload does not deserialize as a ceremony event",
)
})?;
if event.event_type() != event_type {
return Err(unreadable(
event_type,
version,
"the payload's tag names a different event type",
));
}
if let CeremonyEvent::StepStarted(started) = &event {
if started.role_from.is_some() && started.sealed_role.is_some() {
return Err(unreadable(
event_type,
version,
"a step start cannot carry both dynamic and static role seals",
));
}
if started
.sealed_role
.as_ref()
.is_some_and(|sealed| sealed != &started.started_by)
{
return Err(unreadable(
event_type,
version,
"the sealed static role differs from started_by",
));
}
}
let coordinates_valid = match &event {
CeremonyEvent::StepStarted(e) => e.state_visit.is_none() || e.state_iteration.is_some(),
CeremonyEvent::StepCompleted(e) => {
e.state_visit.is_none() || e.state_iteration.is_some()
}
CeremonyEvent::StepFailed(e) => e.state_visit.is_none() || e.state_iteration.is_some(),
CeremonyEvent::TransitionApplied(e) => match &e.destination {
Some(destination) => {
e.transition.has_explicit_state_visit()
&& e.transition.has_explicit_state_iteration()
&& e.transition.state_visit().next().ok() == Some(destination.state_visit)
&& destination
.step_ids
.iter()
.collect::<std::collections::BTreeSet<_>>()
.len()
== destination.step_ids.len()
}
None => !e.transition.has_explicit_state_visit(),
},
_ => true,
};
if !coordinates_valid {
return Err(unreadable(
event_type,
version,
"inconsistent state visit coordinates or destination reset",
));
}
if event.schema_version() != version {
return Err(unreadable(
event_type,
version,
"the payload shape does not match its schema version",
));
}
Ok(event)
}
}
fn unreadable(
event_type: AuditEventType,
version: EventSchemaVersion,
reason: &'static str,
) -> DomainError {
DomainError::UnreadableCeremonyEvent {
event_type: event_type.as_str(),
version: version.get(),
reason,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entities::ceremony_events::CeremonyCompleted;
use crate::value_objects::StateId;
use serde_json::json;
use time::macros::datetime;
fn completed_json() -> serde_json::Value {
json!({
"type": "ceremony_completed",
"final_state": "DONE",
"completed_at": "2026-07-29T09:00:00Z",
})
}
fn step_started_json() -> serde_json::Value {
json!({
"type": "step_started",
"step_id": "draft",
"iteration": 1,
"attempt": 1,
"lease": {
"owner_id": "host-1",
"idempotency_key": "ceremony-1:draft:1",
"acquired_at": "2026-07-29T09:00:00Z",
"expires_at": "2026-07-29T09:01:00Z"
},
"started_by": "writer",
"started_at": "2026-07-29T09:00:00Z"
})
}
#[test]
fn version_one_reads_directly() {
let event = CeremonyEventReader::read(
AuditEventType::CeremonyCompleted,
EventSchemaVersion::V1,
completed_json(),
)
.unwrap();
assert_eq!(
event,
CeremonyEvent::CeremonyCompleted(CeremonyCompleted {
final_state: StateId::new("DONE").unwrap(),
completed_at: datetime!(2026-07-29 09:00:00 UTC),
})
);
}
#[test]
fn an_unknown_schema_version_is_refused_by_name() {
let error = CeremonyEventReader::read(
AuditEventType::CeremonyCompleted,
EventSchemaVersion::new(2).unwrap(),
completed_json(),
)
.unwrap_err();
assert!(matches!(
error,
DomainError::UnreadableCeremonyEvent {
event_type: "ceremony_completed",
version: 2,
..
}
));
assert!(error.to_string().contains("ceremony_completed"));
assert!(error.to_string().contains("version 2"));
}
#[test]
fn a_tag_that_disagrees_with_the_record_is_refused() {
let error = CeremonyEventReader::read(
AuditEventType::StepCompleted,
EventSchemaVersion::V1,
completed_json(),
)
.unwrap_err();
assert!(matches!(
error,
DomainError::UnreadableCeremonyEvent {
event_type: "step_completed",
version: 1,
..
}
));
}
#[test]
fn a_payload_that_is_not_an_event_is_refused() {
let error = CeremonyEventReader::read(
AuditEventType::CeremonyCompleted,
EventSchemaVersion::V1,
json!({ "type": "ceremony_completed" }),
)
.unwrap_err();
assert!(matches!(
error,
DomainError::UnreadableCeremonyEvent {
event_type: "ceremony_completed",
..
}
));
}
#[test]
fn version_one_refuses_a_version_two_coordinate() {
let mut raw = step_started_json();
raw["state_iteration"] = json!(1);
let error =
CeremonyEventReader::read(AuditEventType::StepStarted, EventSchemaVersion::V1, raw)
.unwrap_err();
assert!(matches!(
error,
DomainError::UnreadableCeremonyEvent {
event_type: "step_started",
version: 1,
..
}
));
}
#[test]
fn version_two_requires_an_explicit_coordinate() {
let error = CeremonyEventReader::read(
AuditEventType::StepStarted,
EventSchemaVersion::V2,
step_started_json(),
)
.unwrap_err();
assert!(matches!(
error,
DomainError::UnreadableCeremonyEvent {
event_type: "step_started",
version: 2,
..
}
));
}
#[test]
fn version_three_refuses_contradictory_static_role_seals() {
let mut raw = step_started_json();
raw["state_iteration"] = json!(1);
raw["sealed_role"] = json!("reviewer");
assert!(CeremonyEventReader::read(
AuditEventType::StepStarted,
EventSchemaVersion::V3,
raw,
)
.is_err());
}
#[test]
fn version_three_refuses_two_role_seal_markers() {
let mut raw = step_started_json();
raw["state_iteration"] = json!(1);
raw["role_from"] = json!("next_role");
raw["sealed_role"] = json!("writer");
assert!(CeremonyEventReader::read(
AuditEventType::StepStarted,
EventSchemaVersion::V3,
raw,
)
.is_err());
}
}