use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::entities::CeremonyEvidencePack;
use crate::error::DomainError;
use crate::value_objects::{
CeremonyInterventionContent, CeremonyInterventionId, CeremonyInterventionKind,
CeremonyInterventionProvenance, CeremonyInterventionResponse, CeremonyInterventionStatus,
CeremonyInterventionTarget, RoleId,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CeremonyIntervention {
id: CeremonyInterventionId,
kind: CeremonyInterventionKind,
requested_by: RoleId,
target: CeremonyInterventionTarget,
request: CeremonyInterventionContent,
#[serde(default)]
provenance: Option<CeremonyInterventionProvenance>,
responses: Vec<CeremonyInterventionResponse>,
status: CeremonyInterventionStatus,
#[serde(with = "time::serde::rfc3339")]
created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
updated_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
closed_at: Option<OffsetDateTime>,
}
impl CeremonyIntervention {
#[must_use]
pub fn open(
id: CeremonyInterventionId,
kind: CeremonyInterventionKind,
requested_by: RoleId,
target: CeremonyInterventionTarget,
request: CeremonyInterventionContent,
now: OffsetDateTime,
) -> Self {
Self::open_with_provenance(id, kind, requested_by, target, request, None, now)
}
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn open_with_provenance(
id: CeremonyInterventionId,
kind: CeremonyInterventionKind,
requested_by: RoleId,
target: CeremonyInterventionTarget,
request: CeremonyInterventionContent,
provenance: Option<CeremonyInterventionProvenance>,
now: OffsetDateTime,
) -> Self {
Self {
id,
kind,
requested_by,
target,
request,
provenance,
responses: Vec::new(),
status: CeremonyInterventionStatus::Open,
created_at: now,
updated_at: now,
closed_at: None,
}
}
pub fn respond(
&mut self,
role_id: RoleId,
content: CeremonyInterventionContent,
now: OffsetDateTime,
) -> Result<(), DomainError> {
self.ensure_can_respond(&role_id)?;
self.responses
.push(CeremonyInterventionResponse::new(role_id, content, now));
self.updated_at = now;
Ok(())
}
pub fn respond_with_evidence(
&mut self,
role_id: RoleId,
evidence_pack: CeremonyEvidencePack,
now: OffsetDateTime,
) -> Result<(), DomainError> {
self.ensure_can_respond(&role_id)?;
self.responses
.push(CeremonyInterventionResponse::from_evidence(
role_id,
evidence_pack,
now,
)?);
self.updated_at = now;
Ok(())
}
pub(crate) fn ensure_can_respond(&self, role_id: &RoleId) -> Result<(), DomainError> {
if !self.status.is_open() {
return Err(DomainError::InvariantViolated {
reason: "closed ceremony interventions cannot receive responses",
});
}
if !self.target.accepts(role_id) {
return Err(DomainError::InvariantViolated {
reason: "ceremony intervention does not target responding role",
});
}
if self
.responses
.iter()
.any(|response| response.role_id() == role_id)
{
return Err(DomainError::AlreadyExists {
what: "ceremony_intervention.response_role",
});
}
Ok(())
}
pub fn close(&mut self, role_id: &RoleId, now: OffsetDateTime) -> Result<(), DomainError> {
if !self.status.is_open() {
return Err(DomainError::InvariantViolated {
reason: "ceremony intervention is already closed",
});
}
if role_id != &self.requested_by {
return Err(DomainError::InvariantViolated {
reason: "only the requesting role can close a ceremony intervention",
});
}
self.status = CeremonyInterventionStatus::Closed;
self.updated_at = now;
self.closed_at = Some(now);
Ok(())
}
#[must_use]
pub fn id(&self) -> &CeremonyInterventionId {
&self.id
}
#[must_use]
pub const fn kind(&self) -> CeremonyInterventionKind {
self.kind
}
#[must_use]
pub fn requested_by(&self) -> &RoleId {
&self.requested_by
}
#[must_use]
pub fn target(&self) -> &CeremonyInterventionTarget {
&self.target
}
#[must_use]
pub fn request(&self) -> &CeremonyInterventionContent {
&self.request
}
#[must_use]
pub fn provenance(&self) -> Option<&CeremonyInterventionProvenance> {
self.provenance.as_ref()
}
#[must_use]
pub fn responses(&self) -> &[CeremonyInterventionResponse] {
&self.responses
}
#[must_use]
pub const fn status(&self) -> CeremonyInterventionStatus {
self.status
}
#[must_use]
pub fn created_at(&self) -> OffsetDateTime {
self.created_at
}
#[must_use]
pub fn updated_at(&self) -> OffsetDateTime {
self.updated_at
}
#[must_use]
pub fn closed_at(&self) -> Option<OffsetDateTime> {
self.closed_at
}
}
#[cfg(test)]
mod tests {
use time::macros::datetime;
use crate::value_objects::Attributes;
use super::*;
fn content(message: &str) -> CeremonyInterventionContent {
CeremonyInterventionContent::new(message, Attributes::empty()).unwrap()
}
#[test]
fn accepts_one_response_per_target_role_and_requester_controls_close() {
let now = datetime!(2026-07-20 12:00:00 UTC);
let engineer = RoleId::new("ENGINEER").unwrap();
let observer = RoleId::new("OBSERVER").unwrap();
let mut intervention = CeremonyIntervention::open(
CeremonyInterventionId::new("intervention-1").unwrap(),
CeremonyInterventionKind::Investigation,
engineer.clone(),
CeremonyInterventionTarget::roles([observer.clone()]).unwrap(),
content("Inspect the queue without consuming messages."),
now,
);
intervention
.respond(observer.clone(), content("Depth is stable."), now)
.unwrap();
assert!(intervention
.respond(observer.clone(), content("Duplicate."), now)
.is_err());
assert!(intervention.close(&observer, now).is_err());
intervention.close(&engineer, now).unwrap();
assert_eq!(intervention.status(), CeremonyInterventionStatus::Closed);
}
}