1use 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}