Skip to main content

ironflow_engine/config/
escalation.rs

1//! [`EscalationPolicy`] — what happens when an approval gate misses its deadline.
2//!
3//! An approval gate configured with
4//! [`ApprovalConfig::with_deadline`](super::ApprovalConfig::with_deadline) carries
5//! an SLA. When the deadline fires, the API server's escalator applies the policy
6//! attached with [`ApprovalConfig::on_timeout`](super::ApprovalConfig::on_timeout).
7//!
8//! Terminal policies ([`AutoApprove`](EscalationPolicy::AutoApprove),
9//! [`AutoReject`](EscalationPolicy::AutoReject)) resolve the gate. Repeating
10//! policies ([`Notify`](EscalationPolicy::Notify),
11//! [`Escalate`](EscalationPolicy::Escalate)) leave the gate open and restart the
12//! timer, so a bare one keeps firing until a human resolves the gate — every
13//! cycle leaves an audit entry, so the loop is never silent. Wrap them in a
14//! [`Chain`](EscalationPolicy::Chain) to advance one policy per expiry instead.
15
16use ironflow_store::entities::Assignee;
17use serde::{Deserialize, Serialize};
18use strum::IntoStaticStr;
19
20/// What to do when an approval gate misses its SLA deadline.
21///
22/// # Examples
23///
24/// ```
25/// use ironflow_engine::config::{EscalationPolicy, NotificationTarget};
26///
27/// // Warn the on-call channel after one hour, give up after two.
28/// let policy = EscalationPolicy::Chain(vec![
29///     EscalationPolicy::Notify(vec![NotificationTarget::Slack {
30///         webhook_url: "https://hooks.slack.com/services/T/B/X".to_string(),
31///         channel: "#deploys".to_string(),
32///     }]),
33///     EscalationPolicy::AutoReject,
34/// ]);
35/// assert_eq!(policy.len(), 2);
36/// ```
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, IntoStaticStr)]
38#[serde(rename_all = "snake_case")]
39#[strum(serialize_all = "snake_case")]
40pub enum EscalationPolicy {
41    /// Complete the step with `approved_by: "system:timeout"` and resume the run.
42    AutoApprove,
43    /// Fail the step and the run with the reason `"approval timeout"`.
44    AutoReject,
45    /// Notify each target without changing state, then restart the timer.
46    Notify(Vec<NotificationTarget>),
47    /// Reassign the approval to another user or group, then restart the timer.
48    Escalate(Assignee),
49    /// Apply the policies one per expiry, in order.
50    Chain(Vec<EscalationPolicy>),
51}
52
53/// Where an escalation notification is delivered.
54///
55/// Both variants are plain HTTP `POST`s, delivered with the engine's shared
56/// retry/backoff configuration. A failed delivery is logged and never blocks
57/// the timer reset.
58///
59/// # Examples
60///
61/// ```
62/// use ironflow_engine::config::NotificationTarget;
63///
64/// let target = NotificationTarget::Webhook {
65///     url: "https://example.com/sla".to_string(),
66/// };
67/// let json = serde_json::to_string(&target).expect("serialize");
68/// assert!(json.contains("webhook"));
69/// ```
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum NotificationTarget {
73    /// Plain HTTP `POST` of the escalation event as JSON.
74    Webhook {
75        /// Destination URL.
76        url: String,
77    },
78    /// Slack incoming webhook; `channel` is echoed in the payload.
79    Slack {
80        /// Slack incoming-webhook URL.
81        webhook_url: String,
82        /// Channel name, echoed in the posted payload.
83        channel: String,
84    },
85}
86
87impl EscalationPolicy {
88    /// The policy to apply at escalation stage `index`.
89    ///
90    /// For a [`Chain`](EscalationPolicy::Chain), this is the `index`-th link.
91    /// For any other variant, stage `0` is the policy itself and every later
92    /// stage is `None` — the chain is exhausted. A repeating policy never
93    /// reaches stage 1 outside a chain because it re-arms at the same stage;
94    /// see [`is_repeating`](Self::is_repeating).
95    ///
96    /// # Examples
97    ///
98    /// ```
99    /// use ironflow_engine::config::EscalationPolicy;
100    /// use ironflow_store::entities::Assignee;
101    ///
102    /// let single = EscalationPolicy::AutoReject;
103    /// assert_eq!(single.stage(0), Some(&EscalationPolicy::AutoReject));
104    /// assert_eq!(single.stage(1), None);
105    ///
106    /// let chain = EscalationPolicy::Chain(vec![
107    ///     EscalationPolicy::Escalate(Assignee::group("sre-oncall")),
108    ///     EscalationPolicy::AutoReject,
109    /// ]);
110    /// assert_eq!(chain.stage(1), Some(&EscalationPolicy::AutoReject));
111    /// assert_eq!(chain.stage(2), None);
112    /// ```
113    pub fn stage(&self, index: usize) -> Option<&EscalationPolicy> {
114        match self {
115            EscalationPolicy::Chain(policies) => policies.get(index),
116            other => (index == 0).then_some(other),
117        }
118    }
119
120    /// Whether this policy resolves the gate instead of leaving it open.
121    ///
122    /// # Examples
123    ///
124    /// ```
125    /// use ironflow_engine::config::EscalationPolicy;
126    /// use ironflow_store::entities::Assignee;
127    ///
128    /// assert!(EscalationPolicy::AutoApprove.is_terminal());
129    /// assert!(EscalationPolicy::AutoReject.is_terminal());
130    /// assert!(!EscalationPolicy::Escalate(Assignee::group("sre")).is_terminal());
131    /// ```
132    pub fn is_terminal(&self) -> bool {
133        matches!(
134            self,
135            EscalationPolicy::AutoApprove | EscalationPolicy::AutoReject
136        )
137    }
138
139    /// Whether this policy re-arms the timer at the *same* stage.
140    ///
141    /// Outside a [`Chain`](EscalationPolicy::Chain), a repeating policy fires
142    /// again at every expiry until a human resolves the gate. Inside a chain,
143    /// the stage still advances so the next link eventually runs.
144    ///
145    /// # Examples
146    ///
147    /// ```
148    /// use ironflow_engine::config::EscalationPolicy;
149    /// use ironflow_store::entities::Assignee;
150    ///
151    /// assert!(EscalationPolicy::Notify(Vec::new()).is_repeating());
152    /// assert!(EscalationPolicy::Escalate(Assignee::group("sre")).is_repeating());
153    /// assert!(!EscalationPolicy::AutoApprove.is_repeating());
154    /// ```
155    pub fn is_repeating(&self) -> bool {
156        matches!(
157            self,
158            EscalationPolicy::Notify(_) | EscalationPolicy::Escalate(_)
159        )
160    }
161
162    /// Number of stages this policy declares.
163    ///
164    /// A [`Chain`](EscalationPolicy::Chain) reports its length; every other
165    /// variant reports `1`.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use ironflow_engine::config::EscalationPolicy;
171    ///
172    /// assert_eq!(EscalationPolicy::AutoReject.len(), 1);
173    /// assert_eq!(
174    ///     EscalationPolicy::Chain(vec![EscalationPolicy::AutoReject]).len(),
175    ///     1
176    /// );
177    /// ```
178    pub fn len(&self) -> usize {
179        match self {
180            EscalationPolicy::Chain(policies) => policies.len(),
181            _ => 1,
182        }
183    }
184
185    /// Whether this policy declares no stage at all.
186    ///
187    /// Only an empty [`Chain`](EscalationPolicy::Chain) is empty: it never
188    /// escalates, and the gate stays open with no timer after the first expiry.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// use ironflow_engine::config::EscalationPolicy;
194    ///
195    /// assert!(EscalationPolicy::Chain(Vec::new()).is_empty());
196    /// assert!(!EscalationPolicy::AutoReject.is_empty());
197    /// ```
198    pub fn is_empty(&self) -> bool {
199        self.len() == 0
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn slack() -> NotificationTarget {
208        NotificationTarget::Slack {
209            webhook_url: "https://hooks.slack.com/services/T/B/X".to_string(),
210            channel: "#deploys".to_string(),
211        }
212    }
213
214    #[test]
215    fn auto_approve_serializes_as_a_bare_string() {
216        let json = serde_json::to_string(&EscalationPolicy::AutoApprove).expect("serialize");
217        assert_eq!(json, "\"auto_approve\"");
218    }
219
220    #[test]
221    fn auto_reject_serializes_as_a_bare_string() {
222        let json = serde_json::to_string(&EscalationPolicy::AutoReject).expect("serialize");
223        assert_eq!(json, "\"auto_reject\"");
224    }
225
226    #[test]
227    fn notify_is_externally_tagged() {
228        let policy = EscalationPolicy::Notify(vec![NotificationTarget::Webhook {
229            url: "https://example.com/sla".to_string(),
230        }]);
231        let json = serde_json::to_string(&policy).expect("serialize");
232        assert!(json.starts_with("{\"notify\":["), "got {json}");
233
234        let back: EscalationPolicy = serde_json::from_str(&json).expect("deserialize");
235        assert_eq!(back, policy);
236    }
237
238    #[test]
239    fn escalate_is_externally_tagged() {
240        let policy = EscalationPolicy::Escalate(Assignee::group("sre-oncall"));
241        let json = serde_json::to_string(&policy).expect("serialize");
242        assert_eq!(json, "{\"escalate\":\"group:sre-oncall\"}");
243
244        let back: EscalationPolicy = serde_json::from_str(&json).expect("deserialize");
245        assert_eq!(back, policy);
246    }
247
248    #[test]
249    fn chain_is_externally_tagged() {
250        let policy = EscalationPolicy::Chain(vec![
251            EscalationPolicy::Notify(vec![slack()]),
252            EscalationPolicy::AutoReject,
253        ]);
254        let json = serde_json::to_string(&policy).expect("serialize");
255        assert!(json.starts_with("{\"chain\":["), "got {json}");
256
257        let back: EscalationPolicy = serde_json::from_str(&json).expect("deserialize");
258        assert_eq!(back, policy);
259    }
260
261    #[test]
262    fn nested_chain_roundtrips() {
263        let policy = EscalationPolicy::Chain(vec![
264            EscalationPolicy::Chain(vec![EscalationPolicy::AutoApprove]),
265            EscalationPolicy::AutoReject,
266        ]);
267
268        let json = serde_json::to_string(&policy).expect("serialize");
269        let back: EscalationPolicy = serde_json::from_str(&json).expect("deserialize");
270        assert_eq!(back, policy);
271    }
272
273    #[test]
274    fn notification_targets_roundtrip() {
275        for target in [
276            NotificationTarget::Webhook {
277                url: "https://example.com/sla".to_string(),
278            },
279            slack(),
280        ] {
281            let json = serde_json::to_string(&target).expect("serialize");
282            let back: NotificationTarget = serde_json::from_str(&json).expect("deserialize");
283            assert_eq!(back, target);
284        }
285    }
286
287    #[test]
288    fn stage_of_a_single_policy_is_only_zero() {
289        let policy = EscalationPolicy::Notify(vec![slack()]);
290        assert_eq!(policy.stage(0), Some(&policy));
291        assert!(policy.stage(1).is_none());
292        assert!(policy.stage(99).is_none());
293    }
294
295    #[test]
296    fn stage_walks_a_chain_in_order() {
297        let policy = EscalationPolicy::Chain(vec![
298            EscalationPolicy::Notify(vec![slack()]),
299            EscalationPolicy::Escalate(Assignee::group("sre-oncall")),
300            EscalationPolicy::AutoReject,
301        ]);
302
303        assert_eq!(
304            policy.stage(0),
305            Some(&EscalationPolicy::Notify(vec![slack()]))
306        );
307        assert_eq!(
308            policy.stage(1),
309            Some(&EscalationPolicy::Escalate(Assignee::group("sre-oncall")))
310        );
311        assert_eq!(policy.stage(2), Some(&EscalationPolicy::AutoReject));
312        assert!(policy.stage(3).is_none());
313    }
314
315    #[test]
316    fn terminal_and_repeating_are_mutually_exclusive() {
317        let cases = [
318            (EscalationPolicy::AutoApprove, true, false),
319            (EscalationPolicy::AutoReject, true, false),
320            (EscalationPolicy::Notify(vec![slack()]), false, true),
321            (
322                EscalationPolicy::Escalate(Assignee::group("sre")),
323                false,
324                true,
325            ),
326            (EscalationPolicy::Chain(Vec::new()), false, false),
327        ];
328
329        for (policy, terminal, repeating) in cases {
330            assert_eq!(policy.is_terminal(), terminal, "{policy:?}");
331            assert_eq!(policy.is_repeating(), repeating, "{policy:?}");
332        }
333    }
334
335    #[test]
336    fn len_reports_chain_length_and_one_otherwise() {
337        assert_eq!(EscalationPolicy::AutoApprove.len(), 1);
338        assert_eq!(EscalationPolicy::Escalate(Assignee::group("sre")).len(), 1);
339        assert_eq!(
340            EscalationPolicy::Chain(vec![
341                EscalationPolicy::AutoApprove,
342                EscalationPolicy::AutoReject
343            ])
344            .len(),
345            2
346        );
347    }
348
349    #[test]
350    fn only_an_empty_chain_is_empty() {
351        assert!(EscalationPolicy::Chain(Vec::new()).is_empty());
352        assert!(!EscalationPolicy::Notify(Vec::new()).is_empty());
353        assert!(!EscalationPolicy::AutoReject.is_empty());
354    }
355}