Skip to main content

agentic_workflow/governance/
approval.rs

1use std::collections::HashMap;
2
3use chrono::Utc;
4use uuid::Uuid;
5
6use crate::types::{
7    ApprovalDecision, ApprovalGate, ApprovalReceipt, PendingApproval,
8    WorkflowError, WorkflowResult,
9};
10
11/// Approval gate engine — rich approval workflow with escalation.
12pub struct ApprovalEngine {
13    gates: HashMap<String, ApprovalGate>,
14    pending: HashMap<String, PendingApproval>,
15    receipts: Vec<ApprovalReceipt>,
16}
17
18impl ApprovalEngine {
19    pub fn new() -> Self {
20        Self {
21            gates: HashMap::new(),
22            pending: HashMap::new(),
23            receipts: Vec::new(),
24        }
25    }
26
27    /// Define an approval gate.
28    pub fn define_gate(&mut self, gate: ApprovalGate) -> WorkflowResult<()> {
29        self.gates.insert(gate.id.clone(), gate);
30        Ok(())
31    }
32
33    /// Request approval — creates a pending approval.
34    pub fn request_approval(
35        &mut self,
36        gate_id: &str,
37        execution_id: &str,
38        step_id: &str,
39        context: serde_json::Value,
40    ) -> WorkflowResult<String> {
41        let gate = self
42            .gates
43            .get(gate_id)
44            .ok_or_else(|| WorkflowError::ApprovalRequired(gate_id.to_string()))?;
45
46        let now = Utc::now();
47        let current_approver = gate
48            .approver_chain
49            .first()
50            .map(|a| a.identity.clone())
51            .unwrap_or_else(|| "unknown".to_string());
52
53        let expires_at = gate
54            .timeout
55            .as_ref()
56            .map(|t| now + chrono::Duration::milliseconds(t.timeout_ms as i64));
57
58        let pending_id = Uuid::new_v4().to_string();
59        let pending = PendingApproval {
60            gate_id: gate_id.to_string(),
61            execution_id: execution_id.to_string(),
62            step_id: step_id.to_string(),
63            requested_at: now,
64            expires_at,
65            current_approver,
66            context,
67        };
68
69        self.pending.insert(pending_id.clone(), pending);
70        Ok(pending_id)
71    }
72
73    /// Decide on a pending approval.
74    pub fn decide(
75        &mut self,
76        pending_id: &str,
77        decision: ApprovalDecision,
78        decided_by: &str,
79        reason: Option<String>,
80    ) -> WorkflowResult<ApprovalReceipt> {
81        let pending = self
82            .pending
83            .remove(pending_id)
84            .ok_or_else(|| WorkflowError::Internal(format!("No pending approval: {}", pending_id)))?;
85
86        let receipt = ApprovalReceipt {
87            gate_id: pending.gate_id,
88            execution_id: pending.execution_id,
89            decision,
90            decided_by: decided_by.to_string(),
91            decided_at: Utc::now(),
92            reason,
93            checksum: blake3::hash(pending_id.as_bytes()).to_hex().to_string(),
94        };
95
96        self.receipts.push(receipt.clone());
97        Ok(receipt)
98    }
99
100    /// List pending approvals.
101    pub fn list_pending(&self) -> Vec<(&str, &PendingApproval)> {
102        self.pending.iter().map(|(k, v)| (k.as_str(), v)).collect()
103    }
104
105    /// Get approval audit trail.
106    pub fn get_receipts(&self, gate_id: Option<&str>) -> Vec<&ApprovalReceipt> {
107        match gate_id {
108            Some(gid) => self.receipts.iter().filter(|r| r.gate_id == gid).collect(),
109            None => self.receipts.iter().collect(),
110        }
111    }
112
113    /// Escalate a pending approval to the next approver in the chain.
114    pub fn escalate(&mut self, pending_id: &str) -> WorkflowResult<()> {
115        let pending = self
116            .pending
117            .get_mut(pending_id)
118            .ok_or_else(|| WorkflowError::Internal(format!("No pending approval: {}", pending_id)))?;
119
120        let gate = self
121            .gates
122            .get(&pending.gate_id)
123            .ok_or_else(|| WorkflowError::Internal("Gate not found".to_string()))?;
124
125        // Find next approver
126        let current_idx = gate
127            .approver_chain
128            .iter()
129            .position(|a| a.identity == pending.current_approver);
130
131        if let Some(idx) = current_idx {
132            if idx + 1 < gate.approver_chain.len() {
133                pending.current_approver = gate.approver_chain[idx + 1].identity.clone();
134                return Ok(());
135            }
136        }
137
138        Err(WorkflowError::Internal(
139            "No more approvers in chain".to_string(),
140        ))
141    }
142
143    /// Get a gate definition.
144    pub fn get_gate(&self, gate_id: &str) -> Option<&ApprovalGate> {
145        self.gates.get(gate_id)
146    }
147}
148
149impl Default for ApprovalEngine {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::types::approval::Approver;
159
160    #[test]
161    fn test_approval_flow() {
162        let mut engine = ApprovalEngine::new();
163
164        let gate = ApprovalGate {
165            id: "deploy-gate".into(),
166            step_id: "deploy-prod".into(),
167            workflow_id: "ci-cd".into(),
168            approver_chain: vec![
169                Approver { identity: "alice".into(), role: Some("lead".into()), priority: 1 },
170                Approver { identity: "bob".into(), role: Some("manager".into()), priority: 2 },
171            ],
172            condition: None,
173            timeout: None,
174            delegation: None,
175        };
176
177        engine.define_gate(gate).unwrap();
178
179        let pid = engine
180            .request_approval("deploy-gate", "exec-1", "deploy-prod", serde_json::json!({}))
181            .unwrap();
182
183        assert_eq!(engine.list_pending().len(), 1);
184
185        let receipt = engine
186            .decide(&pid, ApprovalDecision::Approved, "alice", Some("LGTM".into()))
187            .unwrap();
188
189        assert!(matches!(receipt.decision, ApprovalDecision::Approved));
190        assert_eq!(engine.list_pending().len(), 0);
191    }
192}