Skip to main content

oximedia_workflow/
approval_gate.rs

1#![allow(dead_code)]
2//! Approval gate system for workflow steps.
3//!
4//! Provides configurable approval gates that can pause workflow execution
5//! until human or automated approval is received. Supports multi-approver
6//! policies, escalation rules, and time-based auto-approval.
7
8use std::collections::HashMap;
9use std::time::{Duration, Instant};
10
11/// Unique identifier for an approval gate instance.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub struct GateId(u64);
14
15impl GateId {
16    /// Create a new gate identifier from a raw value.
17    #[must_use]
18    pub fn new(id: u64) -> Self {
19        Self(id)
20    }
21
22    /// Return the raw numeric identifier.
23    #[must_use]
24    pub fn value(self) -> u64 {
25        self.0
26    }
27}
28
29/// Policy that governs how many approvals are required.
30#[derive(Debug, Clone, PartialEq)]
31pub enum ApprovalPolicy {
32    /// Any single approver is sufficient.
33    Any,
34    /// All listed approvers must approve.
35    All,
36    /// At least `n` out of the total approvers must approve.
37    Quorum(usize),
38    /// A specific named role must approve.
39    Role(String),
40}
41
42/// The current state of an approval gate.
43#[derive(Debug, Clone, PartialEq)]
44pub enum GateState {
45    /// Gate is waiting for approval.
46    Pending,
47    /// Gate has been approved.
48    Approved,
49    /// Gate has been rejected.
50    Rejected,
51    /// Gate was auto-approved after timeout.
52    AutoApproved,
53    /// Gate has timed out without response.
54    TimedOut,
55    /// Gate was escalated to a higher authority.
56    Escalated,
57}
58
59/// A single approval decision from an approver.
60#[derive(Debug, Clone)]
61pub struct ApprovalDecision {
62    /// Who made the decision.
63    pub approver: String,
64    /// Whether it was approved or rejected.
65    pub approved: bool,
66    /// Optional comment or reason.
67    pub comment: Option<String>,
68    /// When the decision was made.
69    pub decided_at: Instant,
70}
71
72/// Escalation configuration for when approval is not received in time.
73#[derive(Debug, Clone)]
74pub struct EscalationRule {
75    /// How long to wait before escalating.
76    pub after: Duration,
77    /// Who to escalate to.
78    pub escalate_to: String,
79    /// Optional message for the escalation.
80    pub message: Option<String>,
81}
82
83impl EscalationRule {
84    /// Create a new escalation rule.
85    pub fn new(after: Duration, escalate_to: impl Into<String>) -> Self {
86        Self {
87            after,
88            escalate_to: escalate_to.into(),
89            message: None,
90        }
91    }
92
93    /// Set the escalation message.
94    pub fn with_message(mut self, msg: impl Into<String>) -> Self {
95        self.message = Some(msg.into());
96        self
97    }
98}
99
100/// Configuration for an approval gate.
101#[derive(Debug, Clone)]
102pub struct ApprovalGateConfig {
103    /// Human-readable name for this gate.
104    pub name: String,
105    /// Description of what is being approved.
106    pub description: Option<String>,
107    /// Who can approve this gate.
108    pub approvers: Vec<String>,
109    /// Policy governing how many approvals are needed.
110    pub policy: ApprovalPolicy,
111    /// If set, the gate auto-approves after this duration.
112    pub auto_approve_after: Option<Duration>,
113    /// If set, the gate times out after this duration.
114    pub timeout: Option<Duration>,
115    /// Escalation rules (applied in order).
116    pub escalation_rules: Vec<EscalationRule>,
117    /// Metadata attached to this gate.
118    pub metadata: HashMap<String, String>,
119}
120
121impl ApprovalGateConfig {
122    /// Create a new approval gate configuration.
123    pub fn new(name: impl Into<String>, approvers: Vec<String>, policy: ApprovalPolicy) -> Self {
124        Self {
125            name: name.into(),
126            description: None,
127            approvers,
128            policy,
129            auto_approve_after: None,
130            timeout: None,
131            escalation_rules: Vec::new(),
132            metadata: HashMap::new(),
133        }
134    }
135
136    /// Set the gate description.
137    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
138        self.description = Some(desc.into());
139        self
140    }
141
142    /// Set auto-approve timeout.
143    #[must_use]
144    pub fn with_auto_approve(mut self, after: Duration) -> Self {
145        self.auto_approve_after = Some(after);
146        self
147    }
148
149    /// Set the hard timeout.
150    #[must_use]
151    pub fn with_timeout(mut self, timeout: Duration) -> Self {
152        self.timeout = Some(timeout);
153        self
154    }
155
156    /// Add an escalation rule.
157    #[must_use]
158    pub fn add_escalation(mut self, rule: EscalationRule) -> Self {
159        self.escalation_rules.push(rule);
160        self
161    }
162
163    /// Add metadata to the gate.
164    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
165        self.metadata.insert(key.into(), value.into());
166        self
167    }
168}
169
170/// A live approval gate instance tracking approvals and state.
171#[derive(Debug)]
172pub struct ApprovalGate {
173    /// Unique identifier for this gate.
174    pub id: GateId,
175    /// Configuration for this gate.
176    pub config: ApprovalGateConfig,
177    /// Current state of the gate.
178    pub state: GateState,
179    /// Collected approval decisions.
180    pub decisions: Vec<ApprovalDecision>,
181    /// When the gate was created / opened.
182    pub opened_at: Instant,
183    /// When the gate was closed (approved/rejected/timed-out).
184    pub closed_at: Option<Instant>,
185}
186
187impl ApprovalGate {
188    /// Create a new approval gate from configuration.
189    #[must_use]
190    pub fn new(id: GateId, config: ApprovalGateConfig) -> Self {
191        Self {
192            id,
193            config,
194            state: GateState::Pending,
195            decisions: Vec::new(),
196            opened_at: Instant::now(),
197            closed_at: None,
198        }
199    }
200
201    /// Submit an approval decision.
202    pub fn submit_decision(&mut self, decision: ApprovalDecision) {
203        if self.state != GateState::Pending {
204            return;
205        }
206        self.decisions.push(decision);
207        self.evaluate();
208    }
209
210    /// Check whether the gate should auto-approve or time out.
211    pub fn check_timeouts(&mut self) {
212        if self.state != GateState::Pending {
213            return;
214        }
215        let elapsed = self.opened_at.elapsed();
216
217        if let Some(auto_dur) = self.config.auto_approve_after {
218            if elapsed >= auto_dur {
219                self.state = GateState::AutoApproved;
220                self.closed_at = Some(Instant::now());
221                return;
222            }
223        }
224
225        if let Some(timeout) = self.config.timeout {
226            if elapsed >= timeout {
227                self.state = GateState::TimedOut;
228                self.closed_at = Some(Instant::now());
229                return;
230            }
231        }
232
233        // Check escalation rules
234        for rule in &self.config.escalation_rules {
235            if elapsed >= rule.after && self.state == GateState::Pending {
236                self.state = GateState::Escalated;
237                return;
238            }
239        }
240    }
241
242    /// Return whether the gate is still pending.
243    #[must_use]
244    pub fn is_pending(&self) -> bool {
245        self.state == GateState::Pending
246    }
247
248    /// Return whether the gate has been approved (including auto-approved).
249    #[must_use]
250    pub fn is_approved(&self) -> bool {
251        matches!(self.state, GateState::Approved | GateState::AutoApproved)
252    }
253
254    /// Return whether the gate has been rejected.
255    #[must_use]
256    pub fn is_rejected(&self) -> bool {
257        self.state == GateState::Rejected
258    }
259
260    /// Count how many positive approvals have been received.
261    #[must_use]
262    pub fn approval_count(&self) -> usize {
263        self.decisions.iter().filter(|d| d.approved).count()
264    }
265
266    /// Count how many rejections have been received.
267    #[must_use]
268    pub fn rejection_count(&self) -> usize {
269        self.decisions.iter().filter(|d| !d.approved).count()
270    }
271
272    /// Get the elapsed time since the gate was opened.
273    #[must_use]
274    pub fn elapsed(&self) -> Duration {
275        self.opened_at.elapsed()
276    }
277
278    /// Evaluate decisions against the policy and update state.
279    fn evaluate(&mut self) {
280        let approvals = self.approval_count();
281        let rejections = self.rejection_count();
282        let total = self.config.approvers.len();
283
284        match &self.config.policy {
285            ApprovalPolicy::Any => {
286                if approvals >= 1 {
287                    self.state = GateState::Approved;
288                    self.closed_at = Some(Instant::now());
289                } else if rejections == total {
290                    self.state = GateState::Rejected;
291                    self.closed_at = Some(Instant::now());
292                }
293            }
294            ApprovalPolicy::All => {
295                if approvals == total {
296                    self.state = GateState::Approved;
297                    self.closed_at = Some(Instant::now());
298                } else if rejections >= 1 {
299                    self.state = GateState::Rejected;
300                    self.closed_at = Some(Instant::now());
301                }
302            }
303            ApprovalPolicy::Quorum(n) => {
304                if approvals >= *n {
305                    self.state = GateState::Approved;
306                    self.closed_at = Some(Instant::now());
307                } else if rejections > total.saturating_sub(*n) {
308                    self.state = GateState::Rejected;
309                    self.closed_at = Some(Instant::now());
310                }
311            }
312            ApprovalPolicy::Role(role) => {
313                // Check if any approver with the matching role has approved
314                for decision in &self.decisions {
315                    if decision.approver == *role {
316                        if decision.approved {
317                            self.state = GateState::Approved;
318                        } else {
319                            self.state = GateState::Rejected;
320                        }
321                        self.closed_at = Some(Instant::now());
322                        return;
323                    }
324                }
325            }
326        }
327    }
328}
329
330/// Registry that manages multiple approval gates.
331#[derive(Debug)]
332pub struct ApprovalGateRegistry {
333    /// All registered gates keyed by ID.
334    gates: HashMap<GateId, ApprovalGate>,
335    /// Counter for generating gate IDs.
336    next_id: u64,
337}
338
339impl Default for ApprovalGateRegistry {
340    fn default() -> Self {
341        Self::new()
342    }
343}
344
345impl ApprovalGateRegistry {
346    /// Create a new empty registry.
347    #[must_use]
348    pub fn new() -> Self {
349        Self {
350            gates: HashMap::new(),
351            next_id: 1,
352        }
353    }
354
355    /// Open a new approval gate and return its ID.
356    pub fn open_gate(&mut self, config: ApprovalGateConfig) -> GateId {
357        let id = GateId::new(self.next_id);
358        self.next_id += 1;
359        let gate = ApprovalGate::new(id, config);
360        self.gates.insert(id, gate);
361        id
362    }
363
364    /// Get a reference to a gate by ID.
365    #[must_use]
366    pub fn get_gate(&self, id: GateId) -> Option<&ApprovalGate> {
367        self.gates.get(&id)
368    }
369
370    /// Get a mutable reference to a gate by ID.
371    pub fn get_gate_mut(&mut self, id: GateId) -> Option<&mut ApprovalGate> {
372        self.gates.get_mut(&id)
373    }
374
375    /// Submit a decision to a specific gate.
376    pub fn submit_decision(&mut self, gate_id: GateId, decision: ApprovalDecision) -> bool {
377        if let Some(gate) = self.gates.get_mut(&gate_id) {
378            gate.submit_decision(decision);
379            true
380        } else {
381            false
382        }
383    }
384
385    /// Check timeouts on all pending gates.
386    pub fn check_all_timeouts(&mut self) {
387        for gate in self.gates.values_mut() {
388            gate.check_timeouts();
389        }
390    }
391
392    /// List all pending gate IDs.
393    #[must_use]
394    pub fn pending_gates(&self) -> Vec<GateId> {
395        self.gates
396            .iter()
397            .filter(|(_, g)| g.is_pending())
398            .map(|(id, _)| *id)
399            .collect()
400    }
401
402    /// Return the total number of gates in the registry.
403    #[must_use]
404    pub fn gate_count(&self) -> usize {
405        self.gates.len()
406    }
407
408    /// Remove a closed gate from the registry.
409    pub fn remove_gate(&mut self, id: GateId) -> Option<ApprovalGate> {
410        self.gates.remove(&id)
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    fn make_config(approvers: Vec<&str>, policy: ApprovalPolicy) -> ApprovalGateConfig {
419        ApprovalGateConfig::new(
420            "test-gate",
421            approvers.into_iter().map(String::from).collect(),
422            policy,
423        )
424    }
425
426    fn approve(approver: &str) -> ApprovalDecision {
427        ApprovalDecision {
428            approver: approver.to_string(),
429            approved: true,
430            comment: None,
431            decided_at: Instant::now(),
432        }
433    }
434
435    fn reject(approver: &str) -> ApprovalDecision {
436        ApprovalDecision {
437            approver: approver.to_string(),
438            approved: false,
439            comment: Some("Not ready".to_string()),
440            decided_at: Instant::now(),
441        }
442    }
443
444    #[test]
445    fn test_gate_id() {
446        let id = GateId::new(42);
447        assert_eq!(id.value(), 42);
448    }
449
450    #[test]
451    fn test_new_gate_is_pending() {
452        let config = make_config(vec!["alice"], ApprovalPolicy::Any);
453        let gate = ApprovalGate::new(GateId::new(1), config);
454        assert!(gate.is_pending());
455        assert!(!gate.is_approved());
456        assert!(!gate.is_rejected());
457    }
458
459    #[test]
460    fn test_any_policy_single_approval() {
461        let config = make_config(vec!["alice", "bob"], ApprovalPolicy::Any);
462        let mut gate = ApprovalGate::new(GateId::new(1), config);
463        gate.submit_decision(approve("alice"));
464        assert!(gate.is_approved());
465        assert_eq!(gate.approval_count(), 1);
466    }
467
468    #[test]
469    fn test_all_policy_requires_all() {
470        let config = make_config(vec!["alice", "bob"], ApprovalPolicy::All);
471        let mut gate = ApprovalGate::new(GateId::new(1), config);
472        gate.submit_decision(approve("alice"));
473        assert!(gate.is_pending());
474        gate.submit_decision(approve("bob"));
475        assert!(gate.is_approved());
476    }
477
478    #[test]
479    fn test_all_policy_rejects_on_single_rejection() {
480        let config = make_config(vec!["alice", "bob"], ApprovalPolicy::All);
481        let mut gate = ApprovalGate::new(GateId::new(1), config);
482        gate.submit_decision(reject("alice"));
483        assert!(gate.is_rejected());
484    }
485
486    #[test]
487    fn test_quorum_policy() {
488        let config = make_config(vec!["a", "b", "c"], ApprovalPolicy::Quorum(2));
489        let mut gate = ApprovalGate::new(GateId::new(1), config);
490        gate.submit_decision(approve("a"));
491        assert!(gate.is_pending());
492        gate.submit_decision(approve("b"));
493        assert!(gate.is_approved());
494    }
495
496    #[test]
497    fn test_quorum_policy_rejection() {
498        let config = make_config(vec!["a", "b", "c"], ApprovalPolicy::Quorum(2));
499        let mut gate = ApprovalGate::new(GateId::new(1), config);
500        gate.submit_decision(reject("a"));
501        gate.submit_decision(reject("b"));
502        assert!(gate.is_rejected());
503    }
504
505    #[test]
506    fn test_role_policy_approved() {
507        let config = make_config(vec!["admin"], ApprovalPolicy::Role("admin".to_string()));
508        let mut gate = ApprovalGate::new(GateId::new(1), config);
509        gate.submit_decision(approve("admin"));
510        assert!(gate.is_approved());
511    }
512
513    #[test]
514    fn test_role_policy_rejected() {
515        let config = make_config(vec!["admin"], ApprovalPolicy::Role("admin".to_string()));
516        let mut gate = ApprovalGate::new(GateId::new(1), config);
517        gate.submit_decision(reject("admin"));
518        assert!(gate.is_rejected());
519    }
520
521    #[test]
522    fn test_auto_approve_timeout() {
523        let config = make_config(vec!["alice"], ApprovalPolicy::Any)
524            .with_auto_approve(Duration::from_millis(0));
525        let mut gate = ApprovalGate::new(GateId::new(1), config);
526        gate.check_timeouts();
527        assert_eq!(gate.state, GateState::AutoApproved);
528        assert!(gate.is_approved());
529    }
530
531    #[test]
532    fn test_hard_timeout() {
533        let config =
534            make_config(vec!["alice"], ApprovalPolicy::Any).with_timeout(Duration::from_millis(0));
535        let mut gate = ApprovalGate::new(GateId::new(1), config);
536        gate.check_timeouts();
537        assert_eq!(gate.state, GateState::TimedOut);
538    }
539
540    #[test]
541    fn test_registry_open_and_get() {
542        let mut registry = ApprovalGateRegistry::new();
543        let config = make_config(vec!["alice"], ApprovalPolicy::Any);
544        let id = registry.open_gate(config);
545        assert!(registry.get_gate(id).is_some());
546        assert_eq!(registry.gate_count(), 1);
547    }
548
549    #[test]
550    fn test_registry_submit_and_pending() {
551        let mut registry = ApprovalGateRegistry::new();
552        let config1 = make_config(vec!["alice"], ApprovalPolicy::Any);
553        let config2 = make_config(vec!["bob"], ApprovalPolicy::Any);
554        let id1 = registry.open_gate(config1);
555        let id2 = registry.open_gate(config2);
556        assert_eq!(registry.pending_gates().len(), 2);
557
558        registry.submit_decision(id1, approve("alice"));
559        assert_eq!(registry.pending_gates().len(), 1);
560        assert_eq!(registry.pending_gates()[0], id2);
561    }
562
563    #[test]
564    fn test_registry_remove_gate() {
565        let mut registry = ApprovalGateRegistry::new();
566        let config = make_config(vec!["alice"], ApprovalPolicy::Any);
567        let id = registry.open_gate(config);
568        assert_eq!(registry.gate_count(), 1);
569        let removed = registry.remove_gate(id);
570        assert!(removed.is_some());
571        assert_eq!(registry.gate_count(), 0);
572    }
573
574    #[test]
575    fn test_config_builder_methods() {
576        let config = make_config(vec!["alice"], ApprovalPolicy::Any)
577            .with_description("Review final output")
578            .with_timeout(Duration::from_secs(3600))
579            .with_metadata("project", "alpha");
580        assert_eq!(config.description.as_deref(), Some("Review final output"));
581        assert!(config.timeout.is_some());
582        assert_eq!(
583            config.metadata.get("project").map(|s| s.as_str()),
584            Some("alpha")
585        );
586    }
587
588    #[test]
589    fn test_escalation_rule() {
590        let rule = EscalationRule::new(Duration::from_secs(60), "manager")
591            .with_message("Urgent: please review");
592        assert_eq!(rule.escalate_to, "manager");
593        assert_eq!(rule.message.as_deref(), Some("Urgent: please review"));
594    }
595
596    #[test]
597    fn test_decision_after_close_is_ignored() {
598        let config = make_config(vec!["alice", "bob"], ApprovalPolicy::Any);
599        let mut gate = ApprovalGate::new(GateId::new(1), config);
600        gate.submit_decision(approve("alice"));
601        assert!(gate.is_approved());
602        // submit another decision -- should be ignored
603        gate.submit_decision(reject("bob"));
604        assert!(gate.is_approved()); // still approved
605        assert_eq!(gate.decisions.len(), 1);
606    }
607
608    #[test]
609    fn test_default_registry() {
610        let registry = ApprovalGateRegistry::default();
611        assert_eq!(registry.gate_count(), 0);
612    }
613}