Skip to main content

ironflow_engine/
escalation.rs

1//! [`ApprovalEscalator`] — resolves approval gates whose SLA deadline expired.
2//!
3//! An approval gate configured with
4//! [`ApprovalConfig::with_deadline`](crate::config::ApprovalConfig::with_deadline)
5//! stores its deadline on the step itself, so the timer survives an API or
6//! worker restart: nothing is held in memory, and a brand-new process picks the
7//! gate up on its next tick.
8//!
9//! [`ApprovalEscalator::tick`] claims every gate whose deadline has passed and
10//! applies the configured [`EscalationPolicy`]. The claim clears the timer in
11//! the same transaction, so a deadline fires **at most once** even with several
12//! API instances running an escalator. A process that dies between the claim and
13//! the escalation leaves the gate open with no timer — the same trade-off the
14//! lease reaper accepts.
15//!
16//! Every firing publishes an [`Event::ApprovalEscalated`], which the
17//! [`AuditLogSubscriber`](crate::notify::AuditLogSubscriber) persists with the
18//! reason, so the escalation history of a gate is always reconstructable.
19//!
20//! A gate whose run is paused keeps its deadline: the escalator applies the
21//! policy while the run stays `Paused`. An auto-approval only makes the run
22//! resume to `Pending` when the operator resumes it, an auto-rejection fails
23//! it, a notification or a reassignment re-arms the timer, which the resume
24//! keeps.
25
26use std::sync::Arc;
27
28use chrono::{TimeDelta, Utc};
29use reqwest::Client;
30use serde_json::json;
31use tracing::{error, info, warn};
32use uuid::Uuid;
33
34use ironflow_store::models::{
35    Assignee, Run, RunStatus, RunUpdate, Step, StepKind, StepStatus, StepUpdate,
36};
37use strum::IntoStaticStr;
38
39use crate::config::{ApprovalConfig, EscalationPolicy, NotificationTarget};
40use crate::engine::{Engine, ExecutionMode};
41use crate::error::EngineError;
42use crate::notify::{
43    ApprovalEscalatedEvent, ApprovalGrantedEvent, ApprovalRejectedEvent, Event, RetryConfig,
44    deliver_with_retry, is_success_2xx,
45};
46
47/// Actor recorded on a gate resolved by the escalator rather than a human.
48pub const SYSTEM_TIMEOUT_ACTOR: &str = "system:timeout";
49
50/// Error recorded on a step and run auto-rejected after an SLA breach.
51pub const APPROVAL_TIMEOUT_ERROR: &str = "approval timeout";
52
53/// How many gates a single [`ApprovalEscalator::tick`] resolves.
54pub const DEFAULT_ESCALATION_BATCH_SIZE: u32 = 50;
55
56/// What an escalation did to a gate.
57///
58/// # Examples
59///
60/// ```
61/// use ironflow_engine::escalation::EscalationAction;
62///
63/// assert_eq!(EscalationAction::Notified(2), EscalationAction::Notified(2));
64/// assert_ne!(EscalationAction::Approved, EscalationAction::Rejected);
65/// ```
66#[derive(Debug, Clone, PartialEq, Eq, IntoStaticStr)]
67#[strum(serialize_all = "snake_case")]
68pub enum EscalationAction {
69    /// Gate auto-approved; the run was resumed.
70    Approved,
71    /// Gate auto-rejected; step and run failed.
72    Rejected,
73    /// `n` notifications sent; the timer was restarted.
74    Notified(usize),
75    /// Gate reassigned; the timer was restarted.
76    Reassigned(Assignee),
77    /// The policy chain ran out; the gate stays open with no timer.
78    Exhausted,
79    /// The gate resolved between the claim and the escalation — nothing to do.
80    Stale,
81}
82
83impl EscalationAction {
84    /// Short label recorded on the audit entry.
85    ///
86    /// # Examples
87    ///
88    /// ```
89    /// use ironflow_engine::escalation::EscalationAction;
90    ///
91    /// assert_eq!(EscalationAction::Approved.label(), "approved");
92    /// assert_eq!(EscalationAction::Notified(3).label(), "notified");
93    /// ```
94    pub fn label(&self) -> &'static str {
95        self.into()
96    }
97}
98
99/// Outcome of escalating one gate, returned by [`ApprovalEscalator::tick`].
100///
101/// # Examples
102///
103/// ```
104/// use ironflow_engine::escalation::{EscalationAction, EscalationRecord};
105/// use uuid::Uuid;
106///
107/// let record = EscalationRecord {
108///     run_id: Uuid::now_v7(),
109///     step_id: Uuid::now_v7(),
110///     stage: 0,
111///     action: EscalationAction::Rejected,
112///     reason: "approval deadline of 3600s expired".to_string(),
113/// };
114/// assert_eq!(record.action, EscalationAction::Rejected);
115/// ```
116#[derive(Debug, Clone)]
117pub struct EscalationRecord {
118    /// Run the gate belongs to.
119    pub run_id: Uuid,
120    /// The approval step that expired.
121    pub step_id: Uuid,
122    /// Escalation stage that fired (0-based).
123    pub stage: u32,
124    /// What the escalation did.
125    pub action: EscalationAction,
126    /// Why it fired.
127    pub reason: String,
128}
129
130/// Resolves approval gates whose deadline expired.
131///
132/// Drive it from a periodic loop (the API server ships one) or call
133/// [`tick`](Self::tick) directly from a test.
134///
135/// # Examples
136///
137/// ```no_run
138/// use std::sync::Arc;
139/// use ironflow_core::providers::claude::ClaudeCodeProvider;
140/// use ironflow_engine::engine::Engine;
141/// use ironflow_engine::escalation::ApprovalEscalator;
142/// use ironflow_store::memory::InMemoryStore;
143/// use ironflow_store::store::Store;
144///
145/// # async fn example() -> Result<(), ironflow_engine::error::EngineError> {
146/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
147/// let engine = Arc::new(Engine::new(store, Arc::new(ClaudeCodeProvider::new())));
148///
149/// let escalator = ApprovalEscalator::new(engine).batch_size(10);
150/// let records = escalator.tick().await?;
151/// println!("{} gates escalated", records.len());
152/// # Ok(())
153/// # }
154/// ```
155pub struct ApprovalEscalator {
156    engine: Arc<Engine>,
157    client: Client,
158    retry: RetryConfig,
159    batch_size: u32,
160}
161
162impl ApprovalEscalator {
163    /// Create an escalator with the default batch size and retry configuration.
164    ///
165    /// # Panics
166    ///
167    /// Panics if the TLS backend is unavailable, through
168    /// [`RetryConfig::build_client`](crate::notify::RetryConfig::build_client).
169    ///
170    /// # Examples
171    ///
172    /// ```no_run
173    /// use std::sync::Arc;
174    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
175    /// use ironflow_engine::engine::Engine;
176    /// use ironflow_engine::escalation::ApprovalEscalator;
177    /// use ironflow_store::memory::InMemoryStore;
178    /// use ironflow_store::store::Store;
179    ///
180    /// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
181    /// let engine = Arc::new(Engine::new(store, Arc::new(ClaudeCodeProvider::new())));
182    /// let escalator = ApprovalEscalator::new(engine);
183    /// ```
184    pub fn new(engine: Arc<Engine>) -> Self {
185        let retry = RetryConfig::default();
186        Self {
187            engine,
188            client: retry.build_client(),
189            retry,
190            batch_size: DEFAULT_ESCALATION_BATCH_SIZE,
191        }
192    }
193
194    /// Set how many gates a single [`tick`](Self::tick) resolves.
195    ///
196    /// # Examples
197    ///
198    /// ```no_run
199    /// use std::sync::Arc;
200    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
201    /// use ironflow_engine::engine::Engine;
202    /// use ironflow_engine::escalation::ApprovalEscalator;
203    /// use ironflow_store::memory::InMemoryStore;
204    /// use ironflow_store::store::Store;
205    ///
206    /// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
207    /// let engine = Arc::new(Engine::new(store, Arc::new(ClaudeCodeProvider::new())));
208    /// let escalator = ApprovalEscalator::new(engine).batch_size(10);
209    /// ```
210    pub fn batch_size(mut self, batch_size: u32) -> Self {
211        self.batch_size = batch_size;
212        self
213    }
214
215    /// Claim and escalate one batch of expired gates.
216    ///
217    /// A gate that fails to escalate is logged and reported as
218    /// [`EscalationAction::Stale`]: one broken gate never aborts the batch.
219    ///
220    /// A gate whose run is paused is escalated like any other: the run stays
221    /// `Paused` and the policy only changes the state it resumes to.
222    ///
223    /// An [`EscalationPolicy::AutoApprove`] resumes the run inline, so a long
224    /// workflow holds this call until it suspends or finishes. That blocks the
225    /// escalator loop, never the HTTP server, and escalations are rare.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`EngineError::Store`] if the batch cannot be claimed.
230    ///
231    /// # Examples
232    ///
233    /// ```no_run
234    /// use ironflow_engine::escalation::ApprovalEscalator;
235    ///
236    /// # use ironflow_engine::error::EngineError;
237    /// # async fn example(escalator: &ApprovalEscalator) -> Result<(), EngineError> {
238    /// for record in escalator.tick().await? {
239    ///     println!("{} -> {:?}", record.step_id, record.action);
240    /// }
241    /// # Ok(())
242    /// # }
243    /// ```
244    pub async fn tick(&self) -> Result<Vec<EscalationRecord>, EngineError> {
245        let steps = self
246            .engine
247            .store()
248            .claim_due_approval_deadlines(self.batch_size)
249            .await?;
250
251        let mut records = Vec::with_capacity(steps.len());
252        for step in &steps {
253            match self.escalate(step).await {
254                Ok(record) => records.push(record),
255                Err(err) => {
256                    error!(
257                        run_id = %step.run_id,
258                        step_id = %step.id,
259                        error = %err,
260                        "failed to escalate an expired approval gate"
261                    );
262                    records.push(EscalationRecord {
263                        run_id: step.run_id,
264                        step_id: step.id,
265                        stage: step.approval_stage,
266                        action: EscalationAction::Stale,
267                        reason: err.to_string(),
268                    });
269                }
270            }
271        }
272
273        Ok(records)
274    }
275
276    /// Apply the configured policy to one claimed gate.
277    async fn escalate(&self, step: &Step) -> Result<EscalationRecord, EngineError> {
278        let stage = step.approval_stage;
279
280        let Some(input) = step.input.as_ref() else {
281            error!(step_id = %step.id, "approval step has no stored configuration");
282            return Ok(self.stale(step, "approval step has no stored configuration"));
283        };
284        let config: ApprovalConfig = match serde_json::from_value(input.clone()) {
285            Ok(config) => config,
286            Err(err) => {
287                error!(step_id = %step.id, error = %err, "unreadable approval configuration");
288                return Ok(self.stale(step, "unreadable approval configuration"));
289            }
290        };
291
292        // A human may have resolved the gate between the claim and now.
293        let run = self.engine.store().get_run(step.run_id).await?;
294        let Some(run) = run else {
295            return Ok(self.stale(step, "run no longer exists"));
296        };
297        if !awaits_gate(&run) || step.status.state != StepStatus::AwaitingApproval {
298            return Ok(self.stale(step, "gate already resolved"));
299        }
300        let paused = run.status.state == RunStatus::Paused;
301
302        let policy = config.effective_policy();
303        let reason = format!(
304            "approval deadline of {}s expired",
305            config.effective_deadline_secs().unwrap_or(0)
306        );
307
308        let Some(current) = resolve_stage(&policy, stage)? else {
309            warn!(
310                run_id = %step.run_id,
311                step_id = %step.id,
312                stage,
313                "escalation chain exhausted; the approval gate stays open with no timer"
314            );
315            self.publish(
316                step,
317                stage,
318                "chain",
319                &EscalationAction::Exhausted,
320                &reason,
321                None,
322            );
323            return Ok(EscalationRecord {
324                run_id: step.run_id,
325                step_id: step.id,
326                stage,
327                action: EscalationAction::Exhausted,
328                reason,
329            });
330        };
331
332        let action = match current {
333            // A human input has no value to auto-fill: approving it would resume
334            // the handler without an answer. `HumanInputConfig::on_timeout`
335            // refuses this policy; a stored config that still carries it is
336            // treated as a rejection.
337            EscalationPolicy::AutoApprove if step.kind == StepKind::HumanInput => {
338                self.auto_reject(step).await?
339            }
340            EscalationPolicy::AutoApprove => self.auto_approve(step, &reason, paused).await?,
341            EscalationPolicy::AutoReject => self.auto_reject(step).await?,
342            EscalationPolicy::Notify(targets) => {
343                self.notify(step, &config, &policy, targets, &reason)
344                    .await?
345            }
346            EscalationPolicy::Escalate(assignee) => {
347                self.reassign(step, &config, &policy, assignee).await?
348            }
349            // `resolve_stage` already rejected a nested chain.
350            EscalationPolicy::Chain(_) => unreachable!("nested chains are rejected upfront"),
351        };
352
353        let assignee = match &action {
354            EscalationAction::Reassigned(to) => Some(to.clone()),
355            _ => step.approval_assignee.clone(),
356        };
357        self.publish(
358            step,
359            stage,
360            policy_label(current),
361            &action,
362            &reason,
363            assignee,
364        );
365
366        Ok(EscalationRecord {
367            run_id: step.run_id,
368            step_id: step.id,
369            stage,
370            action,
371            reason,
372        })
373    }
374
375    /// Record for a gate that resolved between the claim and the escalation.
376    fn stale(&self, step: &Step, reason: &str) -> EscalationRecord {
377        EscalationRecord {
378            run_id: step.run_id,
379            step_id: step.id,
380            stage: step.approval_stage,
381            action: EscalationAction::Stale,
382            reason: reason.to_string(),
383        }
384    }
385
386    /// Complete the gate as if a human had approved it, and resume the run.
387    async fn auto_approve(
388        &self,
389        step: &Step,
390        reason: &str,
391        paused: bool,
392    ) -> Result<EscalationAction, EngineError> {
393        let now = Utc::now();
394        let store = self.engine.store();
395
396        store
397            .update_step(
398                step.id,
399                StepUpdate {
400                    status: Some(StepStatus::Completed),
401                    output: Some(json!({
402                        "approved_by": SYSTEM_TIMEOUT_ACTOR,
403                        "escalated_at": now,
404                        "reason": reason,
405                    })),
406                    completed_at: Some(now),
407                    clear_approval_deadline: true,
408                    ..StepUpdate::default()
409                },
410            )
411            .await?;
412        // A paused run waits for its operator: the resume requeues it.
413        if paused {
414            store
415                .update_run(
416                    step.run_id,
417                    RunUpdate {
418                        resume_status: Some(RunStatus::Pending),
419                        ..RunUpdate::default()
420                    },
421                )
422                .await?;
423        } else {
424            let resume_status = match self.engine.execution_mode() {
425                ExecutionMode::Local => RunStatus::Running,
426                ExecutionMode::Workers => RunStatus::Pending,
427            };
428            store.update_run_status(step.run_id, resume_status).await?;
429        }
430
431        self.engine
432            .event_publisher()
433            .publish(Event::ApprovalGranted(ApprovalGrantedEvent {
434                run_id: step.run_id,
435                step_id: Some(step.id),
436                approved_by: SYSTEM_TIMEOUT_ACTOR.to_string(),
437                // No human decided: the deadline did. The audit log keeps the
438                // `SYSTEM_TIMEOUT_ACTOR` label and no user.
439                user_id: None,
440                approvals_received: step.approvals.len() as u32,
441                approvals_required: step
442                    .approval_requirement
443                    .as_ref()
444                    .map_or(1, |r| r.required_approvers),
445                requirement: step.approval_requirement.clone(),
446                at: now,
447            }));
448
449        // Under `ExecutionMode::Workers` the run is already `Pending` and a
450        // worker picks it up. Under `ExecutionMode::Local` it resumes here; the
451        // state change already happened, so a failed resume is reported, not
452        // rolled back. The run sits in `Running` without a lease, exactly like
453        // the human-approval path.
454        if paused {
455            info!(
456                run_id = %step.run_id,
457                "paused run will resume after its auto-approved gate"
458            );
459        } else if matches!(self.engine.execution_mode(), ExecutionMode::Local)
460            && let Err(err) = self.engine.resume_run(step.run_id).await
461        {
462            error!(
463                run_id = %step.run_id,
464                error = %err,
465                "failed to resume run after an auto-approved gate"
466            );
467        }
468
469        Ok(EscalationAction::Approved)
470    }
471
472    /// Fail the gate and the run. A paused run fails too (`Paused -> Failed`),
473    /// like a human rejection during a pause.
474    async fn auto_reject(&self, step: &Step) -> Result<EscalationAction, EngineError> {
475        let now = Utc::now();
476        let store = self.engine.store();
477
478        store
479            .update_step(
480                step.id,
481                StepUpdate {
482                    status: Some(StepStatus::Failed),
483                    error: Some(APPROVAL_TIMEOUT_ERROR.to_string()),
484                    completed_at: Some(now),
485                    clear_approval_deadline: true,
486                    ..StepUpdate::default()
487                },
488            )
489            .await?;
490        store
491            .update_run(
492                step.run_id,
493                RunUpdate {
494                    status: Some(RunStatus::Failed),
495                    error: Some(APPROVAL_TIMEOUT_ERROR.to_string()),
496                    completed_at: Some(now),
497                    ..RunUpdate::default()
498                },
499            )
500            .await?;
501
502        self.engine
503            .event_publisher()
504            .publish(Event::ApprovalRejected(ApprovalRejectedEvent {
505                run_id: step.run_id,
506                step_id: Some(step.id),
507                rejected_by: SYSTEM_TIMEOUT_ACTOR.to_string(),
508                // No human decided: the deadline did. The audit log keeps the
509                // `SYSTEM_TIMEOUT_ACTOR` label and no user.
510                user_id: None,
511                reason: None,
512                requirement: step.approval_requirement.clone(),
513                at: now,
514            }));
515
516        // A gate inside a sub-workflow fails the whole chain, like a human
517        // rejection. The gate is already rejected, so a failure is reported.
518        if let Err(err) = self
519            .engine
520            .fail_ancestors(step.run_id, APPROVAL_TIMEOUT_ERROR)
521            .await
522        {
523            error!(
524                run_id = %step.run_id,
525                error = %err,
526                "failed to fail the ancestors of an auto-rejected run"
527            );
528        }
529
530        Ok(EscalationAction::Rejected)
531    }
532
533    /// Notify every target, then restart the timer.
534    async fn notify(
535        &self,
536        step: &Step,
537        config: &ApprovalConfig,
538        policy: &EscalationPolicy,
539        targets: &[NotificationTarget],
540        reason: &str,
541    ) -> Result<EscalationAction, EngineError> {
542        let event = self.escalated_event(
543            step,
544            step.approval_stage,
545            "notify",
546            EscalationAction::Notified(targets.len()).label(),
547            reason,
548            step.approval_assignee.clone(),
549        );
550        self.deliver_notifications(targets, &event).await;
551
552        self.rearm(step, config, policy, None).await?;
553        Ok(EscalationAction::Notified(targets.len()))
554    }
555
556    /// Reassign the gate, then restart the timer.
557    async fn reassign(
558        &self,
559        step: &Step,
560        config: &ApprovalConfig,
561        policy: &EscalationPolicy,
562        assignee: &Assignee,
563    ) -> Result<EscalationAction, EngineError> {
564        self.rearm(step, config, policy, Some(assignee)).await?;
565        Ok(EscalationAction::Reassigned(assignee.clone()))
566    }
567
568    /// Restart the timer for a policy that left the gate open.
569    ///
570    /// Outside a [`EscalationPolicy::Chain`], a repeating policy re-arms at the
571    /// same stage and therefore fires again; inside a chain, the stage advances
572    /// so the next link runs at the next expiry.
573    async fn rearm(
574        &self,
575        step: &Step,
576        config: &ApprovalConfig,
577        policy: &EscalationPolicy,
578        assignee: Option<&Assignee>,
579    ) -> Result<(), EngineError> {
580        let next_stage = next_stage(policy, step.approval_stage);
581        let deadline = config
582            .effective_deadline_secs()
583            .map(|secs| Utc::now() + TimeDelta::seconds(secs as i64));
584
585        self.engine
586            .store()
587            .update_step(
588                step.id,
589                StepUpdate {
590                    approval_deadline_at: deadline,
591                    approval_stage: Some(next_stage),
592                    approval_assignee: assignee.cloned(),
593                    ..StepUpdate::default()
594                },
595            )
596            .await?;
597
598        Ok(())
599    }
600
601    /// Build the escalation event describing one firing.
602    fn escalated_event(
603        &self,
604        step: &Step,
605        stage: u32,
606        policy: &str,
607        action: &str,
608        reason: &str,
609        assignee: Option<Assignee>,
610    ) -> ApprovalEscalatedEvent {
611        ApprovalEscalatedEvent {
612            run_id: step.run_id,
613            step_id: step.id,
614            step_name: step.name.clone(),
615            stage,
616            policy: policy.to_string(),
617            action: action.to_string(),
618            reason: reason.to_string(),
619            assignee,
620            at: Utc::now(),
621        }
622    }
623
624    /// Publish the audit event for one firing.
625    fn publish(
626        &self,
627        step: &Step,
628        stage: u32,
629        policy: &str,
630        action: &EscalationAction,
631        reason: &str,
632        assignee: Option<Assignee>,
633    ) {
634        let event = self.escalated_event(step, stage, policy, action.label(), reason, assignee);
635
636        info!(
637            run_id = %step.run_id,
638            step_id = %step.id,
639            stage,
640            policy,
641            action = action.label(),
642            reason,
643            "approval gate escalated"
644        );
645
646        self.engine
647            .event_publisher()
648            .publish(Event::ApprovalEscalated(event));
649    }
650
651    /// POST the escalation event to every configured target.
652    ///
653    /// Delivery failures are logged by
654    /// [`deliver_with_retry`](crate::notify::deliver_with_retry) and never
655    /// propagate: a dead webhook must not block the timer reset.
656    async fn deliver_notifications(
657        &self,
658        targets: &[NotificationTarget],
659        event: &ApprovalEscalatedEvent,
660    ) {
661        for target in targets {
662            match target {
663                NotificationTarget::Webhook { url } => {
664                    deliver_with_retry(
665                        &self.retry,
666                        || self.client.post(url).json(event),
667                        is_success_2xx,
668                        "approval_escalation",
669                        url,
670                    )
671                    .await;
672                }
673                NotificationTarget::Slack {
674                    webhook_url,
675                    channel,
676                } => {
677                    let body = json!({
678                        "channel": channel,
679                        "text": format!(
680                            "⏰ Approval gate `{}` on run {} hit its SLA: {}",
681                            event.step_name, event.run_id, event.reason
682                        ),
683                    });
684                    deliver_with_retry(
685                        &self.retry,
686                        || self.client.post(webhook_url).json(&body),
687                        is_success_2xx,
688                        "approval_escalation",
689                        webhook_url,
690                    )
691                    .await;
692                }
693            }
694        }
695    }
696}
697
698/// Whether `run` still waits on its gate: suspended on it, or paused by an
699/// operator while suspended on it.
700fn awaits_gate(run: &Run) -> bool {
701    run.status.state == RunStatus::AwaitingApproval
702        || (run.status.state == RunStatus::Paused
703            && run.resume_status == Some(RunStatus::AwaitingApproval))
704}
705
706/// The policy to apply at `stage`, rejecting a misconfigured nested chain.
707///
708/// Returns `Ok(None)` when the chain is exhausted: the gate stays open with no
709/// timer instead of being silently resolved.
710fn resolve_stage(
711    policy: &EscalationPolicy,
712    stage: u32,
713) -> Result<Option<&EscalationPolicy>, EngineError> {
714    match policy.stage(stage as usize) {
715        Some(EscalationPolicy::Chain(_)) => Err(EngineError::StepConfig(
716            "nested escalation chains are not supported".to_string(),
717        )),
718        other => Ok(other),
719    }
720}
721
722/// The stage to re-arm at after a policy that left the gate open.
723///
724/// A bare repeating policy stays at the same stage and fires again; a policy
725/// inside a chain advances so the next link runs at the next expiry.
726fn next_stage(policy: &EscalationPolicy, stage: u32) -> u32 {
727    match policy {
728        EscalationPolicy::Chain(_) => stage.saturating_add(1),
729        _ => stage,
730    }
731}
732
733/// Wire label of the policy that fired, for the audit entry.
734fn policy_label(policy: &EscalationPolicy) -> &'static str {
735    match policy {
736        EscalationPolicy::AutoApprove => "auto_approve",
737        EscalationPolicy::AutoReject => "auto_reject",
738        EscalationPolicy::Notify(_) => "notify",
739        EscalationPolicy::Escalate(_) => "escalate",
740        EscalationPolicy::Chain(_) => "chain",
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    #[test]
749    fn actions_compare_by_value() {
750        assert_eq!(EscalationAction::Notified(2), EscalationAction::Notified(2));
751        assert_ne!(EscalationAction::Notified(2), EscalationAction::Notified(3));
752        assert_eq!(
753            EscalationAction::Reassigned(Assignee::group("sre")),
754            EscalationAction::Reassigned(Assignee::group("sre"))
755        );
756        assert_ne!(EscalationAction::Approved, EscalationAction::Rejected);
757    }
758
759    #[test]
760    fn action_labels_are_distinct() {
761        let labels = [
762            EscalationAction::Approved.label(),
763            EscalationAction::Rejected.label(),
764            EscalationAction::Notified(1).label(),
765            EscalationAction::Reassigned(Assignee::group("sre")).label(),
766            EscalationAction::Exhausted.label(),
767            EscalationAction::Stale.label(),
768        ];
769
770        for (i, a) in labels.iter().enumerate() {
771            for (j, b) in labels.iter().enumerate() {
772                if i != j {
773                    assert_ne!(a, b, "labels {i} and {j} collide");
774                }
775            }
776        }
777    }
778
779    #[test]
780    fn a_bare_repeating_policy_stays_at_the_same_stage() {
781        let notify = EscalationPolicy::Notify(Vec::new());
782        assert_eq!(next_stage(&notify, 0), 0);
783        assert_eq!(next_stage(&notify, 7), 7);
784
785        let escalate = EscalationPolicy::Escalate(Assignee::group("sre"));
786        assert_eq!(next_stage(&escalate, 3), 3);
787    }
788
789    #[test]
790    fn a_chained_policy_advances_one_stage_per_expiry() {
791        let chain = EscalationPolicy::Chain(vec![
792            EscalationPolicy::Notify(Vec::new()),
793            EscalationPolicy::AutoReject,
794        ]);
795        assert_eq!(next_stage(&chain, 0), 1);
796        assert_eq!(next_stage(&chain, 1), 2);
797        assert_eq!(next_stage(&chain, u32::MAX), u32::MAX);
798    }
799
800    #[test]
801    fn resolve_stage_rejects_a_nested_chain() {
802        let policy = EscalationPolicy::Chain(vec![
803            EscalationPolicy::Chain(vec![EscalationPolicy::AutoReject]),
804            EscalationPolicy::AutoApprove,
805        ]);
806
807        let err = resolve_stage(&policy, 0).expect_err("nested chains are rejected");
808        assert!(
809            err.to_string().contains("nested escalation chains"),
810            "got {err}"
811        );
812
813        // The rest of the chain is still usable.
814        assert_eq!(
815            resolve_stage(&policy, 1).expect("valid stage"),
816            Some(&EscalationPolicy::AutoApprove)
817        );
818    }
819
820    #[test]
821    fn resolve_stage_reports_an_exhausted_chain_as_none() {
822        let policy = EscalationPolicy::Chain(vec![EscalationPolicy::AutoReject]);
823        assert!(resolve_stage(&policy, 1).expect("valid call").is_none());
824
825        let bare = EscalationPolicy::AutoApprove;
826        assert!(resolve_stage(&bare, 1).expect("valid call").is_none());
827        assert_eq!(
828            resolve_stage(&bare, 0).expect("valid call"),
829            Some(&EscalationPolicy::AutoApprove)
830        );
831    }
832
833    #[test]
834    fn policy_labels_match_the_wire_format() {
835        assert_eq!(policy_label(&EscalationPolicy::AutoApprove), "auto_approve");
836        assert_eq!(policy_label(&EscalationPolicy::AutoReject), "auto_reject");
837        assert_eq!(
838            policy_label(&EscalationPolicy::Notify(Vec::new())),
839            "notify"
840        );
841        assert_eq!(
842            policy_label(&EscalationPolicy::Escalate(Assignee::group("sre"))),
843            "escalate"
844        );
845        assert_eq!(policy_label(&EscalationPolicy::Chain(Vec::new())), "chain");
846    }
847}