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::Assignee;
6use serde::{Deserialize, Serialize};
7
8use super::{Approvers, EscalationPolicy};
9
10/// Configuration for a human approval step.
11///
12/// When the workflow reaches an approval step, the run transitions to
13/// `AwaitingApproval` and waits for a human to approve or reject via
14/// the API.
15///
16/// A gate can carry an SLA: [`with_deadline`](Self::with_deadline) arms a timer
17/// persisted alongside the step, and [`on_timeout`](Self::on_timeout) says what
18/// happens when it fires. The timer lives in the database, so it survives an API
19/// or worker restart.
20///
21/// A gate can also require several approvers:
22/// [`requiring`](Self::requiring) takes the [`Approvers`] the handler computed
23/// in Rust, from its typed input and earlier step outputs. They decide how many
24/// distinct approvals the gate needs and which groups may vote. A config
25/// without approvers is resolved by one approval from anyone allowed to answer
26/// the gate.
27///
28/// # Examples
29///
30/// ```
31/// use ironflow_engine::config::{ApprovalConfig, Approvers};
32///
33/// let config = ApprovalConfig::new("Deploy to production?");
34/// assert_eq!(config.message(), "Deploy to production?");
35/// assert!(config.timeout_seconds().is_none());
36///
37/// let payment = ApprovalConfig::new("Release the payment?")
38///     .requiring(Approvers::at_least(2).from_groups(["finance"]).because("amount > 10k"));
39/// assert_eq!(payment.approvers().map(Approvers::required), Some(2));
40/// ```
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ApprovalConfig {
43    message: String,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    timeout_seconds: Option<u64>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    deadline_secs: Option<u64>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    on_timeout: Option<EscalationPolicy>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    assignee: Option<Assignee>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    approvers: Option<Approvers>,
54}
55
56impl ApprovalConfig {
57    /// Create a new approval config with the given message.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use ironflow_engine::config::ApprovalConfig;
63    ///
64    /// let config = ApprovalConfig::new("Approve this deployment?");
65    /// assert_eq!(config.message(), "Approve this deployment?");
66    /// ```
67    pub fn new(message: &str) -> Self {
68        Self {
69            message: message.to_string(),
70            timeout_seconds: None,
71            deadline_secs: None,
72            on_timeout: None,
73            assignee: None,
74            approvers: None,
75        }
76    }
77
78    /// Set an auto-reject timeout in seconds.
79    ///
80    /// If no approval or rejection is received within this duration,
81    /// the run is automatically rejected (marked as Failed).
82    ///
83    /// This is the legacy spelling of [`with_deadline_secs`](Self::with_deadline_secs)
84    /// with an implicit [`EscalationPolicy::AutoReject`]. It is now actually
85    /// enforced by the escalator; a config that sets both keeps the explicit
86    /// deadline.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use ironflow_engine::config::{ApprovalConfig, EscalationPolicy};
92    ///
93    /// let config = ApprovalConfig::new("Approve?")
94    ///     .with_timeout_seconds(3600);
95    /// assert_eq!(config.timeout_seconds(), Some(3600));
96    /// assert_eq!(config.effective_deadline_secs(), Some(3600));
97    /// assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
98    /// ```
99    pub fn with_timeout_seconds(mut self, seconds: u64) -> Self {
100        self.timeout_seconds = Some(seconds);
101        self
102    }
103
104    /// Set the SLA deadline of this gate.
105    ///
106    /// Sub-second precision is dropped: the deadline is stored in whole seconds.
107    ///
108    /// # Panics
109    ///
110    /// Panics if `deadline` rounds down to zero seconds.
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use std::time::Duration;
116    /// use ironflow_engine::config::ApprovalConfig;
117    ///
118    /// let config = ApprovalConfig::new("Approve?")
119    ///     .with_deadline(Duration::from_secs(1800));
120    /// assert_eq!(config.deadline(), Some(Duration::from_secs(1800)));
121    /// ```
122    pub fn with_deadline(self, deadline: Duration) -> Self {
123        self.with_deadline_secs(deadline.as_secs())
124    }
125
126    /// Set the SLA deadline of this gate, in seconds.
127    ///
128    /// # Panics
129    ///
130    /// Panics if `secs` is zero: a gate that expires the instant it opens can
131    /// never be approved by a human.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// use ironflow_engine::config::ApprovalConfig;
137    ///
138    /// let config = ApprovalConfig::new("Approve?").with_deadline_secs(1800);
139    /// assert_eq!(config.deadline_secs(), Some(1800));
140    /// ```
141    pub fn with_deadline_secs(mut self, secs: u64) -> Self {
142        assert!(secs > 0, "approval deadline must be greater than zero");
143        self.deadline_secs = Some(secs);
144        self
145    }
146
147    /// Set the policy applied when the deadline fires.
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// use ironflow_engine::config::{ApprovalConfig, EscalationPolicy};
153    ///
154    /// let config = ApprovalConfig::new("Approve?")
155    ///     .with_deadline_secs(3600)
156    ///     .on_timeout(EscalationPolicy::AutoApprove);
157    /// assert_eq!(config.effective_policy(), EscalationPolicy::AutoApprove);
158    /// ```
159    pub fn on_timeout(mut self, policy: EscalationPolicy) -> Self {
160        self.on_timeout = Some(policy);
161        self
162    }
163
164    /// Assign the gate to a user or group.
165    ///
166    /// # Panics
167    ///
168    /// Panics if the assignee name is empty or only whitespace.
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use ironflow_engine::config::ApprovalConfig;
174    /// use ironflow_store::entities::Assignee;
175    ///
176    /// let config = ApprovalConfig::new("Approve?").assigned_to(Assignee::group("release-managers"));
177    /// assert_eq!(config.assignee(), Some(&Assignee::group("release-managers")));
178    /// ```
179    pub fn assigned_to(mut self, assignee: Assignee) -> Self {
180        assert!(
181            !assignee.name().trim().is_empty(),
182            "approval assignee must not be empty"
183        );
184        self.assignee = Some(assignee);
185        self
186    }
187
188    /// Require the given approvers. A later call replaces an earlier one.
189    ///
190    /// The engine records them on the gate when it opens, as an
191    /// [`ApprovalRequirement`](crate::config::ApprovalRequirement); that record
192    /// stays the source of truth on replay and resume.
193    ///
194    /// # Examples
195    ///
196    /// ```
197    /// use ironflow_engine::config::{ApprovalConfig, Approvers};
198    ///
199    /// let config = ApprovalConfig::new("Approve?")
200    ///     .requiring(Approvers::at_least(3).from_groups(["finance", "board"]));
201    /// assert_eq!(config.approvers().map(Approvers::required), Some(3));
202    /// ```
203    pub fn requiring(mut self, approvers: Approvers) -> Self {
204        self.approvers = Some(approvers);
205        self
206    }
207
208    /// The approvers this gate requires, if any were set.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use ironflow_engine::config::ApprovalConfig;
214    ///
215    /// assert!(ApprovalConfig::new("Approve?").approvers().is_none());
216    /// ```
217    pub fn approvers(&self) -> Option<&Approvers> {
218        self.approvers.as_ref()
219    }
220
221    /// The approval message displayed to reviewers.
222    pub fn message(&self) -> &str {
223        &self.message
224    }
225
226    /// Optional auto-reject timeout in seconds.
227    pub fn timeout_seconds(&self) -> Option<u64> {
228        self.timeout_seconds
229    }
230
231    /// The configured SLA deadline, if any.
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use std::time::Duration;
237    /// use ironflow_engine::config::ApprovalConfig;
238    ///
239    /// let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
240    /// assert_eq!(config.deadline(), Some(Duration::from_secs(60)));
241    /// ```
242    pub fn deadline(&self) -> Option<Duration> {
243        self.deadline_secs.map(Duration::from_secs)
244    }
245
246    /// The configured SLA deadline in seconds, if any.
247    pub fn deadline_secs(&self) -> Option<u64> {
248        self.deadline_secs
249    }
250
251    /// The configured escalation policy, if any.
252    pub fn on_timeout_policy(&self) -> Option<&EscalationPolicy> {
253        self.on_timeout.as_ref()
254    }
255
256    /// The user or group the gate is assigned to, if any.
257    pub fn assignee(&self) -> Option<&Assignee> {
258        self.assignee.as_ref()
259    }
260
261    /// The deadline actually enforced, in seconds.
262    ///
263    /// [`with_deadline_secs`](Self::with_deadline_secs) wins; the legacy
264    /// [`with_timeout_seconds`](Self::with_timeout_seconds) is honoured as a
265    /// fallback so configs written before escalation existed finally behave the
266    /// way their documentation always promised.
267    ///
268    /// # Examples
269    ///
270    /// ```
271    /// use ironflow_engine::config::ApprovalConfig;
272    ///
273    /// let legacy = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
274    /// assert_eq!(legacy.effective_deadline_secs(), Some(7200));
275    ///
276    /// let both = legacy.with_deadline_secs(60);
277    /// assert_eq!(both.effective_deadline_secs(), Some(60));
278    ///
279    /// assert_eq!(ApprovalConfig::new("Approve?").effective_deadline_secs(), None);
280    /// ```
281    pub fn effective_deadline_secs(&self) -> Option<u64> {
282        self.deadline_secs.or(self.timeout_seconds)
283    }
284
285    /// The policy applied when the deadline fires. Defaults to
286    /// [`EscalationPolicy::AutoReject`], matching the documented meaning of
287    /// `timeout_seconds`.
288    ///
289    /// # Examples
290    ///
291    /// ```
292    /// use ironflow_engine::config::{ApprovalConfig, EscalationPolicy};
293    ///
294    /// let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
295    /// assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
296    /// ```
297    pub fn effective_policy(&self) -> EscalationPolicy {
298        self.on_timeout
299            .clone()
300            .unwrap_or(EscalationPolicy::AutoReject)
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use serde_json::{from_str, from_value, json, to_string};
307
308    use super::*;
309    use crate::config::NotificationTarget;
310
311    #[test]
312    fn new_sets_message() {
313        let config = ApprovalConfig::new("Deploy?");
314        assert_eq!(config.message(), "Deploy?");
315        assert!(config.timeout_seconds().is_none());
316        assert!(config.deadline_secs().is_none());
317        assert!(config.on_timeout_policy().is_none());
318        assert!(config.assignee().is_none());
319    }
320
321    #[test]
322    fn with_timeout() {
323        let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
324        assert_eq!(config.timeout_seconds(), Some(7200));
325    }
326
327    #[test]
328    fn with_deadline_stores_whole_seconds() {
329        let config = ApprovalConfig::new("Approve?").with_deadline(Duration::from_millis(90_500));
330        assert_eq!(config.deadline_secs(), Some(90));
331        assert_eq!(config.deadline(), Some(Duration::from_secs(90)));
332    }
333
334    #[test]
335    fn on_timeout_stores_the_policy() {
336        let config = ApprovalConfig::new("Approve?").on_timeout(EscalationPolicy::AutoApprove);
337        assert_eq!(
338            config.on_timeout_policy(),
339            Some(&EscalationPolicy::AutoApprove)
340        );
341    }
342
343    #[test]
344    fn assigned_to_stores_the_assignee() {
345        let config =
346            ApprovalConfig::new("Approve?").assigned_to(Assignee::group("release-managers"));
347        assert_eq!(
348            config.assignee(),
349            Some(&Assignee::group("release-managers"))
350        );
351    }
352
353    #[test]
354    fn effective_deadline_prefers_the_explicit_deadline() {
355        let config = ApprovalConfig::new("Approve?")
356            .with_timeout_seconds(7200)
357            .with_deadline_secs(60);
358        assert_eq!(config.effective_deadline_secs(), Some(60));
359    }
360
361    #[test]
362    fn effective_deadline_falls_back_to_the_legacy_timeout() {
363        let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
364        assert_eq!(config.effective_deadline_secs(), Some(7200));
365    }
366
367    #[test]
368    fn effective_deadline_is_none_without_any_timer() {
369        assert_eq!(
370            ApprovalConfig::new("Approve?").effective_deadline_secs(),
371            None
372        );
373    }
374
375    #[test]
376    fn effective_policy_defaults_to_auto_reject() {
377        let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
378        assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
379    }
380
381    #[test]
382    fn effective_policy_returns_the_configured_policy() {
383        let policy = EscalationPolicy::Chain(vec![
384            EscalationPolicy::Notify(vec![NotificationTarget::Webhook {
385                url: "https://example.com/sla".to_string(),
386            }]),
387            EscalationPolicy::AutoReject,
388        ]);
389        let config = ApprovalConfig::new("Approve?")
390            .with_deadline_secs(60)
391            .on_timeout(policy.clone());
392        assert_eq!(config.effective_policy(), policy);
393    }
394
395    #[test]
396    #[should_panic(expected = "approval deadline must be greater than zero")]
397    fn with_deadline_secs_rejects_zero() {
398        let _ = ApprovalConfig::new("Approve?").with_deadline_secs(0);
399    }
400
401    #[test]
402    #[should_panic(expected = "approval assignee must not be empty")]
403    fn assigned_to_rejects_blank() {
404        let _ = ApprovalConfig::new("Approve?").assigned_to(Assignee::group("  "));
405    }
406
407    #[test]
408    fn serde_roundtrip() {
409        let config = ApprovalConfig::new("Deploy to prod?")
410            .with_timeout_seconds(3600)
411            .with_deadline_secs(1800)
412            .on_timeout(EscalationPolicy::Escalate(Assignee::group("sre-oncall")))
413            .assigned_to(Assignee::group("release-managers"));
414
415        let json = serde_json::to_string(&config).expect("serialize");
416        let back: ApprovalConfig = serde_json::from_str(&json).expect("deserialize");
417
418        assert_eq!(back.message(), config.message());
419        assert_eq!(back.timeout_seconds(), config.timeout_seconds());
420        assert_eq!(back.deadline_secs(), config.deadline_secs());
421        assert_eq!(back.on_timeout_policy(), config.on_timeout_policy());
422        assert_eq!(back.assignee(), config.assignee());
423    }
424
425    #[test]
426    fn serde_minimal() {
427        let config = ApprovalConfig::new("Approve?");
428        let json = serde_json::to_string(&config).expect("serialize");
429        assert!(!json.contains("timeout_seconds"));
430        assert!(!json.contains("deadline_secs"));
431        assert!(!json.contains("on_timeout"));
432        assert!(!json.contains("assignee"));
433        assert!(!json.contains("approvers"));
434    }
435
436    #[test]
437    fn requiring_stores_the_approvers() {
438        let approvers = Approvers::at_least(2)
439            .from_groups(["finance"])
440            .because("amount > 10k");
441        let config = ApprovalConfig::new("Release the payment?").requiring(approvers.clone());
442        assert_eq!(config.approvers(), Some(&approvers));
443    }
444
445    #[test]
446    fn requiring_twice_keeps_the_last_approvers() {
447        let config = ApprovalConfig::new("Approve?")
448            .requiring(Approvers::at_least(3))
449            .requiring(Approvers::any());
450        assert_eq!(config.approvers(), Some(&Approvers::any()));
451    }
452
453    #[test]
454    fn serde_roundtrip_with_approvers() {
455        let config = ApprovalConfig::new("Release the payment?").requiring(
456            Approvers::at_least(3)
457                .from_groups(["finance", "board"])
458                .because("amount > 100k"),
459        );
460        let json = to_string(&config).expect("serialize");
461        let back: ApprovalConfig = from_str(&json).expect("deserialize");
462
463        assert_eq!(back.approvers(), config.approvers());
464        assert_eq!(to_string(&back).expect("serialize"), json);
465    }
466
467    #[test]
468    fn serde_accepts_a_config_written_before_approvers_existed() {
469        let raw = r#"{"message":"Approve?","assignee":"user:alice"}"#;
470        let config: ApprovalConfig = from_str(raw).expect("deserialize");
471
472        assert!(config.approvers().is_none());
473        assert_eq!(config.assignee(), Some(&Assignee::user("alice")));
474    }
475
476    #[test]
477    fn serde_ignores_the_rules_of_a_config_written_by_approval_rules() {
478        // Step inputs recorded before approval rules were removed still load.
479        let config: ApprovalConfig = from_value(json!({
480            "message": "Approve?",
481            "rules": [{"condition": "payload.amount > 10000", "required_approvers": 2}],
482        }))
483        .expect("deserialize");
484
485        assert_eq!(config.message(), "Approve?");
486        assert!(config.approvers().is_none());
487    }
488
489    #[test]
490    fn serde_rejects_zero_approvers() {
491        let result = from_value::<ApprovalConfig>(json!({
492            "message": "Approve?",
493            "approvers": {"required_approvers": 0},
494        }));
495        assert!(result.is_err());
496    }
497
498    #[test]
499    fn serde_accepts_a_config_written_before_escalation_existed() {
500        let config: ApprovalConfig =
501            serde_json::from_str(r#"{"message":"Approve?","timeout_seconds":60}"#)
502                .expect("deserialize");
503
504        assert_eq!(config.effective_deadline_secs(), Some(60));
505        assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
506    }
507}