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}