Skip to main content

made_core/events/
trigger.rs

1//! [`TriggerEvent`] — inbound domain event requesting one or more
2//! deliberations.
3//!
4//! Domain-neutral: the event carries a free-form `kind`, a list of
5//! specialties whose councils should run, and an opaque payload.
6//! MADE does not interpret `kind` or `payload`; they
7//! are adapter / operator concerns.
8
9use serde::{Deserialize, Serialize};
10
11use crate::entities::{ExternalContextBundle, TaskConstraints};
12use crate::error::DomainError;
13use crate::events::envelope::EventEnvelope;
14use crate::value_objects::{Attributes, Specialty, TaskDescription};
15
16const MAX_KIND_LEN: usize = 128;
17
18/// An inbound event that fans out into deliberations.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct TriggerEvent {
21    #[serde(flatten)]
22    envelope: EventEnvelope,
23    kind: String,
24    requested_specialties: Vec<Specialty>,
25    task_description_template: Option<TaskDescription>,
26    #[serde(default)]
27    constraints: TaskConstraints,
28    #[serde(default)]
29    payload: Attributes,
30    external_context: Option<ExternalContextBundle>,
31}
32
33impl TriggerEvent {
34    /// Build a trigger event.
35    ///
36    /// Invariants:
37    /// - `kind` must be non-empty after trimming and within length bounds.
38    /// - `requested_specialties` must be non-empty and deduplicated by
39    ///   the caller is *not* required — we dedupe here so downstream
40    ///   dispatch cannot double-run a council.
41    pub fn new(
42        envelope: EventEnvelope,
43        kind: impl Into<String>,
44        requested_specialties: impl IntoIterator<Item = Specialty>,
45        task_description_template: Option<TaskDescription>,
46        constraints: TaskConstraints,
47        payload: Attributes,
48    ) -> Result<Self, DomainError> {
49        Self::new_with_context(
50            envelope,
51            kind,
52            requested_specialties,
53            task_description_template,
54            constraints,
55            payload,
56            None,
57        )
58    }
59
60    pub fn new_with_context(
61        envelope: EventEnvelope,
62        kind: impl Into<String>,
63        requested_specialties: impl IntoIterator<Item = Specialty>,
64        task_description_template: Option<TaskDescription>,
65        constraints: TaskConstraints,
66        payload: Attributes,
67        external_context: Option<ExternalContextBundle>,
68    ) -> Result<Self, DomainError> {
69        let kind = kind.into();
70        let trimmed = kind.trim();
71        if trimmed.is_empty() {
72            return Err(DomainError::EmptyField {
73                field: "trigger.kind",
74            });
75        }
76        if trimmed.len() > MAX_KIND_LEN {
77            return Err(DomainError::FieldTooLong {
78                field: "trigger.kind",
79                actual: trimmed.len(),
80                max: MAX_KIND_LEN,
81            });
82        }
83
84        let mut seen = std::collections::BTreeSet::new();
85        let mut unique = Vec::new();
86        for sp in requested_specialties {
87            if seen.insert(sp.clone()) {
88                unique.push(sp);
89            }
90        }
91        if unique.is_empty() {
92            return Err(DomainError::EmptyCollection {
93                field: "trigger.requested_specialties",
94            });
95        }
96
97        Ok(Self {
98            envelope,
99            kind: trimmed.to_owned(),
100            requested_specialties: unique,
101            task_description_template,
102            constraints,
103            payload,
104            external_context,
105        })
106    }
107
108    #[must_use]
109    pub fn envelope(&self) -> &EventEnvelope {
110        &self.envelope
111    }
112    #[must_use]
113    pub fn kind(&self) -> &str {
114        &self.kind
115    }
116    #[must_use]
117    pub fn requested_specialties(&self) -> &[Specialty] {
118        &self.requested_specialties
119    }
120    #[must_use]
121    pub fn task_description_template(&self) -> Option<&TaskDescription> {
122        self.task_description_template.as_ref()
123    }
124    #[must_use]
125    pub fn constraints(&self) -> &TaskConstraints {
126        &self.constraints
127    }
128    #[must_use]
129    pub fn payload(&self) -> &Attributes {
130        &self.payload
131    }
132
133    #[must_use]
134    pub fn external_context(&self) -> Option<&ExternalContextBundle> {
135        self.external_context.as_ref()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::value_objects::EventId;
143    use time::macros::datetime;
144
145    fn env() -> EventEnvelope {
146        EventEnvelope::new(
147            EventId::new("e1").unwrap(),
148            datetime!(2026-04-15 12:00:00 UTC),
149            "grafana",
150            None,
151        )
152        .unwrap()
153    }
154
155    fn sp(s: &str) -> Specialty {
156        Specialty::new(s).unwrap()
157    }
158
159    #[test]
160    fn empty_kind_is_rejected() {
161        let err = TriggerEvent::new(
162            env(),
163            "   ",
164            vec![sp("triage")],
165            None,
166            TaskConstraints::default(),
167            Attributes::empty(),
168        )
169        .unwrap_err();
170        assert!(matches!(
171            err,
172            DomainError::EmptyField {
173                field: "trigger.kind"
174            }
175        ));
176    }
177
178    #[test]
179    fn overlong_kind_is_rejected() {
180        let err = TriggerEvent::new(
181            env(),
182            "k".repeat(MAX_KIND_LEN + 1),
183            vec![sp("triage")],
184            None,
185            TaskConstraints::default(),
186            Attributes::empty(),
187        )
188        .unwrap_err();
189        assert!(matches!(err, DomainError::FieldTooLong { .. }));
190    }
191
192    #[test]
193    fn empty_specialty_list_is_rejected() {
194        let err = TriggerEvent::new(
195            env(),
196            "alert.fired",
197            Vec::<Specialty>::new(),
198            None,
199            TaskConstraints::default(),
200            Attributes::empty(),
201        )
202        .unwrap_err();
203        assert!(matches!(
204            err,
205            DomainError::EmptyCollection {
206                field: "trigger.requested_specialties"
207            }
208        ));
209    }
210
211    #[test]
212    fn duplicate_specialties_are_deduplicated() {
213        let ev = TriggerEvent::new(
214            env(),
215            "alert.fired",
216            vec![sp("triage"), sp("triage"), sp("reviewer")],
217            None,
218            TaskConstraints::default(),
219            Attributes::empty(),
220        )
221        .unwrap();
222        assert_eq!(ev.requested_specialties().len(), 2);
223    }
224
225    #[test]
226    fn json_shape_is_flat_per_asyncapi() {
227        // Regression test: AsyncAPI declares TriggerEvent via allOf
228        // composition with EventEnvelope, so the JSON on the wire has
229        // envelope fields at the top level next to `kind`,
230        // `requested_specialties`, etc. This test would fail if the
231        // `#[serde(flatten)]` attribute on `envelope` regressed.
232        let ev = TriggerEvent::new(
233            env(),
234            "alert.fired",
235            vec![sp("triage")],
236            None,
237            TaskConstraints::default(),
238            Attributes::empty(),
239        )
240        .unwrap();
241        let json = serde_json::to_value(&ev).unwrap();
242        let obj = json.as_object().unwrap();
243        assert!(obj.contains_key("event_id"));
244        assert!(obj.contains_key("source"));
245        assert!(obj.contains_key("emitted_at"));
246        assert!(obj.contains_key("kind"));
247        assert!(obj.contains_key("requested_specialties"));
248        assert!(
249            !obj.contains_key("envelope"),
250            "envelope must flatten into the root"
251        );
252    }
253
254    #[test]
255    fn kind_is_free_form_across_domains() {
256        for kind in [
257            "alert.fired",
258            "case.opened",
259            "shipment.delayed",
260            "protocol.deviation.detected",
261            "claim.submitted",
262        ] {
263            TriggerEvent::new(
264                env(),
265                kind,
266                vec![sp("x")],
267                None,
268                TaskConstraints::default(),
269                Attributes::empty(),
270            )
271            .unwrap();
272        }
273    }
274
275    #[test]
276    fn json_defaults_optional_constraints_and_payload() {
277        let json = serde_json::json!({
278            "event_id": "e1",
279            "kind": "alert.fired",
280            "source": "grafana",
281            "emitted_at": "2026-04-15T12:00:00Z",
282            "requested_specialties": ["triage"]
283        });
284
285        let ev: TriggerEvent = serde_json::from_value(json).unwrap();
286        assert_eq!(ev.constraints(), &TaskConstraints::default());
287        assert_eq!(ev.payload(), &Attributes::empty());
288    }
289
290    #[test]
291    fn json_accepts_empty_constraints_object() {
292        let json = serde_json::json!({
293            "event_id": "e1",
294            "kind": "alert.fired",
295            "source": "grafana",
296            "emitted_at": "2026-04-15T12:00:00Z",
297            "requested_specialties": ["triage"],
298            "constraints": {},
299            "payload": {}
300        });
301
302        let ev: TriggerEvent = serde_json::from_value(json).unwrap();
303        assert_eq!(ev.constraints(), &TaskConstraints::default());
304        assert_eq!(ev.payload(), &Attributes::empty());
305    }
306}