Skip to main content

made_core/entities/
ceremony_intervention.rs

1//! Dynamic intervention owned by a running ceremony instance.
2
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5
6use crate::entities::CeremonyEvidencePack;
7use crate::error::DomainError;
8use crate::value_objects::{
9    CeremonyInterventionContent, CeremonyInterventionId, CeremonyInterventionKind,
10    CeremonyInterventionProvenance, CeremonyInterventionResponse, CeremonyInterventionStatus,
11    CeremonyInterventionTarget, RoleId,
12};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct CeremonyIntervention {
16    id: CeremonyInterventionId,
17    kind: CeremonyInterventionKind,
18    requested_by: RoleId,
19    target: CeremonyInterventionTarget,
20    request: CeremonyInterventionContent,
21    #[serde(default)]
22    provenance: Option<CeremonyInterventionProvenance>,
23    responses: Vec<CeremonyInterventionResponse>,
24    status: CeremonyInterventionStatus,
25    #[serde(with = "time::serde::rfc3339")]
26    created_at: OffsetDateTime,
27    #[serde(with = "time::serde::rfc3339")]
28    updated_at: OffsetDateTime,
29    #[serde(with = "time::serde::rfc3339::option")]
30    closed_at: Option<OffsetDateTime>,
31}
32
33impl CeremonyIntervention {
34    #[must_use]
35    pub fn open(
36        id: CeremonyInterventionId,
37        kind: CeremonyInterventionKind,
38        requested_by: RoleId,
39        target: CeremonyInterventionTarget,
40        request: CeremonyInterventionContent,
41        now: OffsetDateTime,
42    ) -> Self {
43        Self::open_with_provenance(id, kind, requested_by, target, request, None, now)
44    }
45
46    #[allow(clippy::too_many_arguments)]
47    #[must_use]
48    pub fn open_with_provenance(
49        id: CeremonyInterventionId,
50        kind: CeremonyInterventionKind,
51        requested_by: RoleId,
52        target: CeremonyInterventionTarget,
53        request: CeremonyInterventionContent,
54        provenance: Option<CeremonyInterventionProvenance>,
55        now: OffsetDateTime,
56    ) -> Self {
57        Self {
58            id,
59            kind,
60            requested_by,
61            target,
62            request,
63            provenance,
64            responses: Vec::new(),
65            status: CeremonyInterventionStatus::Open,
66            created_at: now,
67            updated_at: now,
68            closed_at: None,
69        }
70    }
71
72    pub fn respond(
73        &mut self,
74        role_id: RoleId,
75        content: CeremonyInterventionContent,
76        now: OffsetDateTime,
77    ) -> Result<(), DomainError> {
78        self.ensure_can_respond(&role_id)?;
79        self.responses
80            .push(CeremonyInterventionResponse::new(role_id, content, now));
81        self.updated_at = now;
82        Ok(())
83    }
84
85    pub fn respond_with_evidence(
86        &mut self,
87        role_id: RoleId,
88        evidence_pack: CeremonyEvidencePack,
89        now: OffsetDateTime,
90    ) -> Result<(), DomainError> {
91        self.ensure_can_respond(&role_id)?;
92        self.responses
93            .push(CeremonyInterventionResponse::from_evidence(
94                role_id,
95                evidence_pack,
96                now,
97            )?);
98        self.updated_at = now;
99        Ok(())
100    }
101
102    pub(crate) fn ensure_can_respond(&self, role_id: &RoleId) -> Result<(), DomainError> {
103        if !self.status.is_open() {
104            return Err(DomainError::InvariantViolated {
105                reason: "closed ceremony interventions cannot receive responses",
106            });
107        }
108        if !self.target.accepts(role_id) {
109            return Err(DomainError::InvariantViolated {
110                reason: "ceremony intervention does not target responding role",
111            });
112        }
113        if self
114            .responses
115            .iter()
116            .any(|response| response.role_id() == role_id)
117        {
118            return Err(DomainError::AlreadyExists {
119                what: "ceremony_intervention.response_role",
120            });
121        }
122        Ok(())
123    }
124
125    pub fn close(&mut self, role_id: &RoleId, now: OffsetDateTime) -> Result<(), DomainError> {
126        if !self.status.is_open() {
127            return Err(DomainError::InvariantViolated {
128                reason: "ceremony intervention is already closed",
129            });
130        }
131        if role_id != &self.requested_by {
132            return Err(DomainError::InvariantViolated {
133                reason: "only the requesting role can close a ceremony intervention",
134            });
135        }
136        self.status = CeremonyInterventionStatus::Closed;
137        self.updated_at = now;
138        self.closed_at = Some(now);
139        Ok(())
140    }
141
142    #[must_use]
143    pub fn id(&self) -> &CeremonyInterventionId {
144        &self.id
145    }
146
147    #[must_use]
148    pub const fn kind(&self) -> CeremonyInterventionKind {
149        self.kind
150    }
151
152    #[must_use]
153    pub fn requested_by(&self) -> &RoleId {
154        &self.requested_by
155    }
156
157    #[must_use]
158    pub fn target(&self) -> &CeremonyInterventionTarget {
159        &self.target
160    }
161
162    #[must_use]
163    pub fn request(&self) -> &CeremonyInterventionContent {
164        &self.request
165    }
166
167    #[must_use]
168    pub fn provenance(&self) -> Option<&CeremonyInterventionProvenance> {
169        self.provenance.as_ref()
170    }
171
172    #[must_use]
173    pub fn responses(&self) -> &[CeremonyInterventionResponse] {
174        &self.responses
175    }
176
177    #[must_use]
178    pub const fn status(&self) -> CeremonyInterventionStatus {
179        self.status
180    }
181
182    #[must_use]
183    pub fn created_at(&self) -> OffsetDateTime {
184        self.created_at
185    }
186
187    #[must_use]
188    pub fn updated_at(&self) -> OffsetDateTime {
189        self.updated_at
190    }
191
192    #[must_use]
193    pub fn closed_at(&self) -> Option<OffsetDateTime> {
194        self.closed_at
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use time::macros::datetime;
201
202    use crate::value_objects::Attributes;
203
204    use super::*;
205
206    fn content(message: &str) -> CeremonyInterventionContent {
207        CeremonyInterventionContent::new(message, Attributes::empty()).unwrap()
208    }
209
210    #[test]
211    fn accepts_one_response_per_target_role_and_requester_controls_close() {
212        let now = datetime!(2026-07-20 12:00:00 UTC);
213        let engineer = RoleId::new("ENGINEER").unwrap();
214        let observer = RoleId::new("OBSERVER").unwrap();
215        let mut intervention = CeremonyIntervention::open(
216            CeremonyInterventionId::new("intervention-1").unwrap(),
217            CeremonyInterventionKind::Investigation,
218            engineer.clone(),
219            CeremonyInterventionTarget::roles([observer.clone()]).unwrap(),
220            content("Inspect the queue without consuming messages."),
221            now,
222        );
223
224        intervention
225            .respond(observer.clone(), content("Depth is stable."), now)
226            .unwrap();
227
228        assert!(intervention
229            .respond(observer.clone(), content("Duplicate."), now)
230            .is_err());
231        assert!(intervention.close(&observer, now).is_err());
232        intervention.close(&engineer, now).unwrap();
233        assert_eq!(intervention.status(), CeremonyInterventionStatus::Closed);
234    }
235}