Skip to main content

ironflow_engine/config/
approval.rs

1//! [`ApprovalConfig`] -- configuration for human approval gates.
2
3use std::time::Duration;
4
5use ironflow_store::entities::{ApprovalRequirement, ApprovalRuleEvaluation, Assignee};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use super::{ApprovalRule, EscalationPolicy};
10
11/// Configuration for a human approval step.
12///
13/// When the workflow reaches an approval step, the run transitions to
14/// `AwaitingApproval` and waits for a human to approve or reject via
15/// the API.
16///
17/// A gate can carry an SLA: [`with_deadline`](Self::with_deadline) arms a timer
18/// persisted alongside the step, and [`on_timeout`](Self::on_timeout) says what
19/// happens when it fires. The timer lives in the database, so it survives an API
20/// or worker restart.
21///
22/// A gate can also carry a dynamic approval matrix:
23/// [`with_rule`](Self::with_rule) appends an [`ApprovalRule`] whose condition is
24/// evaluated against the run context when the gate opens. The first matching
25/// rule decides how many distinct approvals the gate needs and which groups may
26/// vote; when no rule matches, one approval from anyone allowed to answer the
27/// gate resolves it. A config without rules behaves exactly as before.
28///
29/// # Examples
30///
31/// ```
32/// use ironflow_engine::config::{ApprovalConfig, ApprovalRule};
33///
34/// let config = ApprovalConfig::new("Deploy to production?");
35/// assert_eq!(config.message(), "Deploy to production?");
36/// assert!(config.timeout_seconds().is_none());
37///
38/// let rule = ApprovalRule::new("payload.amount > 10000", 2).with_approver_groups(["finance"]);
39/// let payment = ApprovalConfig::new("Release the payment?").with_rule(rule);
40/// assert_eq!(payment.rules().len(), 1);
41/// ```
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ApprovalConfig {
44    message: String,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    timeout_seconds: Option<u64>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    deadline_secs: Option<u64>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    on_timeout: Option<EscalationPolicy>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    assignee: Option<Assignee>,
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    rules: Vec<ApprovalRule>,
55}
56
57impl ApprovalConfig {
58    /// Create a new approval config with the given message.
59    ///
60    /// # Examples
61    ///
62    /// ```
63    /// use ironflow_engine::config::ApprovalConfig;
64    ///
65    /// let config = ApprovalConfig::new("Approve this deployment?");
66    /// assert_eq!(config.message(), "Approve this deployment?");
67    /// ```
68    pub fn new(message: &str) -> Self {
69        Self {
70            message: message.to_string(),
71            timeout_seconds: None,
72            deadline_secs: None,
73            on_timeout: None,
74            assignee: None,
75            rules: Vec::new(),
76        }
77    }
78
79    /// Set an auto-reject timeout in seconds.
80    ///
81    /// If no approval or rejection is received within this duration,
82    /// the run is automatically rejected (marked as Failed).
83    ///
84    /// This is the legacy spelling of [`with_deadline_secs`](Self::with_deadline_secs)
85    /// with an implicit [`EscalationPolicy::AutoReject`]. It is now actually
86    /// enforced by the escalator; a config that sets both keeps the explicit
87    /// deadline.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use ironflow_engine::config::{ApprovalConfig, EscalationPolicy};
93    ///
94    /// let config = ApprovalConfig::new("Approve?")
95    ///     .with_timeout_seconds(3600);
96    /// assert_eq!(config.timeout_seconds(), Some(3600));
97    /// assert_eq!(config.effective_deadline_secs(), Some(3600));
98    /// assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
99    /// ```
100    pub fn with_timeout_seconds(mut self, seconds: u64) -> Self {
101        self.timeout_seconds = Some(seconds);
102        self
103    }
104
105    /// Set the SLA deadline of this gate.
106    ///
107    /// Sub-second precision is dropped: the deadline is stored in whole seconds.
108    ///
109    /// # Panics
110    ///
111    /// Panics if `deadline` rounds down to zero seconds.
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// use std::time::Duration;
117    /// use ironflow_engine::config::ApprovalConfig;
118    ///
119    /// let config = ApprovalConfig::new("Approve?")
120    ///     .with_deadline(Duration::from_secs(1800));
121    /// assert_eq!(config.deadline(), Some(Duration::from_secs(1800)));
122    /// ```
123    pub fn with_deadline(self, deadline: Duration) -> Self {
124        self.with_deadline_secs(deadline.as_secs())
125    }
126
127    /// Set the SLA deadline of this gate, in seconds.
128    ///
129    /// # Panics
130    ///
131    /// Panics if `secs` is zero: a gate that expires the instant it opens can
132    /// never be approved by a human.
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use ironflow_engine::config::ApprovalConfig;
138    ///
139    /// let config = ApprovalConfig::new("Approve?").with_deadline_secs(1800);
140    /// assert_eq!(config.deadline_secs(), Some(1800));
141    /// ```
142    pub fn with_deadline_secs(mut self, secs: u64) -> Self {
143        assert!(secs > 0, "approval deadline must be greater than zero");
144        self.deadline_secs = Some(secs);
145        self
146    }
147
148    /// Set the policy applied when the deadline fires.
149    ///
150    /// # Examples
151    ///
152    /// ```
153    /// use ironflow_engine::config::{ApprovalConfig, EscalationPolicy};
154    ///
155    /// let config = ApprovalConfig::new("Approve?")
156    ///     .with_deadline_secs(3600)
157    ///     .on_timeout(EscalationPolicy::AutoApprove);
158    /// assert_eq!(config.effective_policy(), EscalationPolicy::AutoApprove);
159    /// ```
160    pub fn on_timeout(mut self, policy: EscalationPolicy) -> Self {
161        self.on_timeout = Some(policy);
162        self
163    }
164
165    /// Assign the gate to a user or group.
166    ///
167    /// # Panics
168    ///
169    /// Panics if the assignee name is empty or only whitespace.
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// use ironflow_engine::config::ApprovalConfig;
175    /// use ironflow_store::entities::Assignee;
176    ///
177    /// let config = ApprovalConfig::new("Approve?").assigned_to(Assignee::group("release-managers"));
178    /// assert_eq!(config.assignee(), Some(&Assignee::group("release-managers")));
179    /// ```
180    pub fn assigned_to(mut self, assignee: Assignee) -> Self {
181        assert!(
182            !assignee.name().trim().is_empty(),
183            "approval assignee must not be empty"
184        );
185        self.assignee = Some(assignee);
186        self
187    }
188
189    /// Append an approval rule. Rules are evaluated in the order they were
190    /// added and the first match wins.
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// use ironflow_engine::config::{ApprovalConfig, ApprovalRule};
196    ///
197    /// let config = ApprovalConfig::new("Approve?")
198    ///     .with_rule(ApprovalRule::new("payload.amount > 100000", 3))
199    ///     .with_rule(ApprovalRule::new("payload.amount > 10000", 2));
200    /// assert_eq!(config.rules()[0].required_approvers(), 3);
201    /// ```
202    pub fn with_rule(mut self, rule: ApprovalRule) -> Self {
203        self.rules.push(rule);
204        self
205    }
206
207    /// The approval rules, in evaluation order.
208    ///
209    /// # Examples
210    ///
211    /// ```
212    /// use ironflow_engine::config::ApprovalConfig;
213    ///
214    /// assert!(ApprovalConfig::new("Approve?").rules().is_empty());
215    /// ```
216    pub fn rules(&self) -> &[ApprovalRule] {
217        &self.rules
218    }
219
220    /// Evaluate the rules in order against `ctx`.
221    ///
222    /// Every evaluated rule is recorded in
223    /// [`ApprovalRequirement::evaluated`], up to and including the first match.
224    /// The first matching rule decides the requirement; when none matches, the
225    /// [default requirement](ApprovalRequirement::default) applies (one
226    /// approval, any approver).
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use ironflow_engine::config::{ApprovalConfig, ApprovalRule};
232    /// use serde_json::json;
233    ///
234    /// let rule = ApprovalRule::new("payload.amount > 10000", 2).with_approver_groups(["finance"]);
235    /// let config = ApprovalConfig::new("Approve?").with_rule(rule);
236    ///
237    /// let big = config.evaluate_rules(&json!({"payload": {"amount": 15000}}));
238    /// assert_eq!(big.rule_index, Some(0));
239    /// assert_eq!(big.required_approvers, 2);
240    ///
241    /// let small = config.evaluate_rules(&json!({"payload": {"amount": 10}}));
242    /// assert_eq!(small.rule_index, None);
243    /// assert_eq!(small.required_approvers, 1);
244    /// assert!(!small.evaluated[0].matched);
245    /// ```
246    pub fn evaluate_rules(&self, ctx: &Value) -> ApprovalRequirement {
247        let mut evaluated = Vec::new();
248        for (index, rule) in self.rules.iter().enumerate() {
249            let index = u32::try_from(index).unwrap_or(u32::MAX);
250            let matched = rule.matches(ctx);
251            evaluated.push(ApprovalRuleEvaluation {
252                index,
253                condition: rule.condition().source().to_string(),
254                matched,
255            });
256            if matched {
257                let required = u32::try_from(rule.required_approvers()).unwrap_or(u32::MAX);
258                return ApprovalRequirement {
259                    rule_index: Some(index),
260                    condition: Some(rule.condition().source().to_string()),
261                    required_approvers: required,
262                    approver_groups: rule.approver_groups().to_vec(),
263                    evaluated,
264                };
265            }
266        }
267        ApprovalRequirement {
268            evaluated,
269            ..ApprovalRequirement::default()
270        }
271    }
272
273    /// The approval message displayed to reviewers.
274    pub fn message(&self) -> &str {
275        &self.message
276    }
277
278    /// Optional auto-reject timeout in seconds.
279    pub fn timeout_seconds(&self) -> Option<u64> {
280        self.timeout_seconds
281    }
282
283    /// The configured SLA deadline, if any.
284    ///
285    /// # Examples
286    ///
287    /// ```
288    /// use std::time::Duration;
289    /// use ironflow_engine::config::ApprovalConfig;
290    ///
291    /// let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
292    /// assert_eq!(config.deadline(), Some(Duration::from_secs(60)));
293    /// ```
294    pub fn deadline(&self) -> Option<Duration> {
295        self.deadline_secs.map(Duration::from_secs)
296    }
297
298    /// The configured SLA deadline in seconds, if any.
299    pub fn deadline_secs(&self) -> Option<u64> {
300        self.deadline_secs
301    }
302
303    /// The configured escalation policy, if any.
304    pub fn on_timeout_policy(&self) -> Option<&EscalationPolicy> {
305        self.on_timeout.as_ref()
306    }
307
308    /// The user or group the gate is assigned to, if any.
309    pub fn assignee(&self) -> Option<&Assignee> {
310        self.assignee.as_ref()
311    }
312
313    /// The deadline actually enforced, in seconds.
314    ///
315    /// [`with_deadline_secs`](Self::with_deadline_secs) wins; the legacy
316    /// [`with_timeout_seconds`](Self::with_timeout_seconds) is honoured as a
317    /// fallback so configs written before escalation existed finally behave the
318    /// way their documentation always promised.
319    ///
320    /// # Examples
321    ///
322    /// ```
323    /// use ironflow_engine::config::ApprovalConfig;
324    ///
325    /// let legacy = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
326    /// assert_eq!(legacy.effective_deadline_secs(), Some(7200));
327    ///
328    /// let both = legacy.with_deadline_secs(60);
329    /// assert_eq!(both.effective_deadline_secs(), Some(60));
330    ///
331    /// assert_eq!(ApprovalConfig::new("Approve?").effective_deadline_secs(), None);
332    /// ```
333    pub fn effective_deadline_secs(&self) -> Option<u64> {
334        self.deadline_secs.or(self.timeout_seconds)
335    }
336
337    /// The policy applied when the deadline fires. Defaults to
338    /// [`EscalationPolicy::AutoReject`], matching the documented meaning of
339    /// `timeout_seconds`.
340    ///
341    /// # Examples
342    ///
343    /// ```
344    /// use ironflow_engine::config::{ApprovalConfig, EscalationPolicy};
345    ///
346    /// let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
347    /// assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
348    /// ```
349    pub fn effective_policy(&self) -> EscalationPolicy {
350        self.on_timeout
351            .clone()
352            .unwrap_or(EscalationPolicy::AutoReject)
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use serde_json::{from_str, from_value, json, to_string};
359
360    use super::*;
361    use crate::config::NotificationTarget;
362
363    #[test]
364    fn new_sets_message() {
365        let config = ApprovalConfig::new("Deploy?");
366        assert_eq!(config.message(), "Deploy?");
367        assert!(config.timeout_seconds().is_none());
368        assert!(config.deadline_secs().is_none());
369        assert!(config.on_timeout_policy().is_none());
370        assert!(config.assignee().is_none());
371    }
372
373    #[test]
374    fn with_timeout() {
375        let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
376        assert_eq!(config.timeout_seconds(), Some(7200));
377    }
378
379    #[test]
380    fn with_deadline_stores_whole_seconds() {
381        let config = ApprovalConfig::new("Approve?").with_deadline(Duration::from_millis(90_500));
382        assert_eq!(config.deadline_secs(), Some(90));
383        assert_eq!(config.deadline(), Some(Duration::from_secs(90)));
384    }
385
386    #[test]
387    fn on_timeout_stores_the_policy() {
388        let config = ApprovalConfig::new("Approve?").on_timeout(EscalationPolicy::AutoApprove);
389        assert_eq!(
390            config.on_timeout_policy(),
391            Some(&EscalationPolicy::AutoApprove)
392        );
393    }
394
395    #[test]
396    fn assigned_to_stores_the_assignee() {
397        let config =
398            ApprovalConfig::new("Approve?").assigned_to(Assignee::group("release-managers"));
399        assert_eq!(
400            config.assignee(),
401            Some(&Assignee::group("release-managers"))
402        );
403    }
404
405    #[test]
406    fn effective_deadline_prefers_the_explicit_deadline() {
407        let config = ApprovalConfig::new("Approve?")
408            .with_timeout_seconds(7200)
409            .with_deadline_secs(60);
410        assert_eq!(config.effective_deadline_secs(), Some(60));
411    }
412
413    #[test]
414    fn effective_deadline_falls_back_to_the_legacy_timeout() {
415        let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
416        assert_eq!(config.effective_deadline_secs(), Some(7200));
417    }
418
419    #[test]
420    fn effective_deadline_is_none_without_any_timer() {
421        assert_eq!(
422            ApprovalConfig::new("Approve?").effective_deadline_secs(),
423            None
424        );
425    }
426
427    #[test]
428    fn effective_policy_defaults_to_auto_reject() {
429        let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
430        assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
431    }
432
433    #[test]
434    fn effective_policy_returns_the_configured_policy() {
435        let policy = EscalationPolicy::Chain(vec![
436            EscalationPolicy::Notify(vec![NotificationTarget::Webhook {
437                url: "https://example.com/sla".to_string(),
438            }]),
439            EscalationPolicy::AutoReject,
440        ]);
441        let config = ApprovalConfig::new("Approve?")
442            .with_deadline_secs(60)
443            .on_timeout(policy.clone());
444        assert_eq!(config.effective_policy(), policy);
445    }
446
447    #[test]
448    #[should_panic(expected = "approval deadline must be greater than zero")]
449    fn with_deadline_secs_rejects_zero() {
450        let _ = ApprovalConfig::new("Approve?").with_deadline_secs(0);
451    }
452
453    #[test]
454    #[should_panic(expected = "approval assignee must not be empty")]
455    fn assigned_to_rejects_blank() {
456        let _ = ApprovalConfig::new("Approve?").assigned_to(Assignee::group("  "));
457    }
458
459    #[test]
460    fn serde_roundtrip() {
461        let config = ApprovalConfig::new("Deploy to prod?")
462            .with_timeout_seconds(3600)
463            .with_deadline_secs(1800)
464            .on_timeout(EscalationPolicy::Escalate(Assignee::group("sre-oncall")))
465            .assigned_to(Assignee::group("release-managers"));
466
467        let json = serde_json::to_string(&config).expect("serialize");
468        let back: ApprovalConfig = serde_json::from_str(&json).expect("deserialize");
469
470        assert_eq!(back.message(), config.message());
471        assert_eq!(back.timeout_seconds(), config.timeout_seconds());
472        assert_eq!(back.deadline_secs(), config.deadline_secs());
473        assert_eq!(back.on_timeout_policy(), config.on_timeout_policy());
474        assert_eq!(back.assignee(), config.assignee());
475    }
476
477    #[test]
478    fn serde_minimal() {
479        let config = ApprovalConfig::new("Approve?");
480        let json = serde_json::to_string(&config).expect("serialize");
481        assert!(!json.contains("timeout_seconds"));
482        assert!(!json.contains("deadline_secs"));
483        assert!(!json.contains("on_timeout"));
484        assert!(!json.contains("assignee"));
485        assert!(!json.contains("rules"));
486    }
487
488    fn matrix() -> ApprovalConfig {
489        ApprovalConfig::new("Release the payment?")
490            .with_rule(
491                ApprovalRule::new("payload.amount > 100000", 3)
492                    .with_approver_groups(["finance", "board"]),
493            )
494            .with_rule(
495                ApprovalRule::new("payload.amount > 10000", 2).with_approver_groups(["finance"]),
496            )
497    }
498
499    #[test]
500    fn with_rule_appends_in_order() {
501        let config = matrix();
502        assert_eq!(config.rules().len(), 2);
503        assert_eq!(config.rules()[0].required_approvers(), 3);
504        assert_eq!(config.rules()[1].required_approvers(), 2);
505    }
506
507    #[test]
508    fn evaluate_rules_first_match_wins() {
509        let ctx = json!({"payload": {"amount": 500000}});
510        let requirement = matrix().evaluate_rules(&ctx);
511
512        assert_eq!(requirement.rule_index, Some(0));
513        assert_eq!(
514            requirement.condition.as_deref(),
515            Some("payload.amount > 100000")
516        );
517        assert_eq!(requirement.required_approvers, 3);
518        assert_eq!(requirement.approver_groups, vec!["finance", "board"]);
519        assert_eq!(
520            requirement.evaluated,
521            vec![ApprovalRuleEvaluation {
522                index: 0,
523                condition: "payload.amount > 100000".to_string(),
524                matched: true,
525            }]
526        );
527    }
528
529    #[test]
530    fn evaluate_rules_records_misses_before_the_match() {
531        let ctx = json!({"payload": {"amount": 15000}});
532        let requirement = matrix().evaluate_rules(&ctx);
533
534        assert_eq!(requirement.rule_index, Some(1));
535        assert_eq!(requirement.required_approvers, 2);
536        assert_eq!(requirement.approver_groups, vec!["finance"]);
537        let matched: Vec<bool> = requirement.evaluated.iter().map(|e| e.matched).collect();
538        assert_eq!(matched, vec![false, true]);
539        assert_eq!(requirement.evaluated[1].index, 1);
540    }
541
542    #[test]
543    fn evaluate_rules_falls_through_to_the_default() {
544        let ctx = json!({"payload": {"amount": 10}});
545        let requirement = matrix().evaluate_rules(&ctx);
546
547        assert_eq!(requirement.rule_index, None);
548        assert_eq!(requirement.condition, None);
549        assert_eq!(requirement.required_approvers, 1);
550        assert!(requirement.approver_groups.is_empty());
551        assert_eq!(requirement.evaluated.len(), 2);
552        assert!(requirement.evaluated.iter().all(|e| !e.matched));
553    }
554
555    #[test]
556    fn evaluate_rules_without_rules_is_the_default() {
557        let requirement = ApprovalConfig::new("Approve?").evaluate_rules(&json!({}));
558        assert_eq!(requirement, ApprovalRequirement::default());
559    }
560
561    #[test]
562    fn serde_roundtrip_with_rules() {
563        let config = matrix();
564        let json = to_string(&config).expect("serialize");
565        let back: ApprovalConfig = from_str(&json).expect("deserialize");
566
567        assert_eq!(back.rules(), config.rules());
568        assert_eq!(to_string(&back).expect("serialize"), json);
569    }
570
571    #[test]
572    fn serde_accepts_a_config_written_before_rules_existed() {
573        let raw = r#"{"message":"Approve?","assignee":"user:alice"}"#;
574        let config: ApprovalConfig = from_str(raw).expect("deserialize");
575
576        assert!(config.rules().is_empty());
577        assert_eq!(config.assignee(), Some(&Assignee::user("alice")));
578    }
579
580    #[test]
581    fn serde_rejects_a_rule_with_zero_approvers() {
582        let result = from_value::<ApprovalConfig>(json!({
583            "message": "Approve?",
584            "rules": [{"condition": "payload.urgent", "required_approvers": 0}],
585        }));
586        assert!(result.is_err());
587    }
588
589    #[test]
590    fn serde_rejects_a_rule_with_an_invalid_condition() {
591        let result = from_value::<ApprovalConfig>(json!({
592            "message": "Approve?",
593            "rules": [{"condition": "payload.amount >", "required_approvers": 1}],
594        }));
595        assert!(result.is_err());
596    }
597
598    #[test]
599    fn serde_accepts_a_config_written_before_escalation_existed() {
600        let config: ApprovalConfig =
601            serde_json::from_str(r#"{"message":"Approve?","timeout_seconds":60}"#)
602                .expect("deserialize");
603
604        assert_eq!(config.effective_deadline_secs(), Some(60));
605        assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
606    }
607}