Skip to main content

adk_payments/guardrail/
policy.rs

1use std::sync::Arc;
2
3use adk_guardrail::Severity;
4
5use crate::domain::{ProtocolDescriptor, TransactionRecord};
6
7/// One concrete policy finding emitted by a payment guardrail.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct PaymentPolicyFinding {
10    /// The guardrail that produced the finding.
11    pub guardrail: String,
12    /// Human-readable explanation of the policy outcome.
13    pub reason: String,
14    /// Severity of the finding.
15    pub severity: Severity,
16}
17
18impl PaymentPolicyFinding {
19    /// Creates a new policy finding.
20    #[must_use]
21    pub fn new(
22        guardrail: impl Into<String>,
23        reason: impl Into<String>,
24        severity: Severity,
25    ) -> Self {
26        Self { guardrail: guardrail.into(), reason: reason.into(), severity }
27    }
28}
29
30/// Outcome of evaluating a payment policy or policy set.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum PaymentPolicyDecision {
33    /// The payment passed every configured guardrail.
34    Allow,
35    /// The payment may continue only after explicit human confirmation.
36    Escalate { findings: Vec<PaymentPolicyFinding> },
37    /// The payment must not continue under the current policy.
38    Deny { findings: Vec<PaymentPolicyFinding> },
39}
40
41impl PaymentPolicyDecision {
42    /// Returns an allow decision.
43    #[must_use]
44    pub const fn allow() -> Self {
45        Self::Allow
46    }
47
48    /// Returns an escalation decision with one or more findings.
49    #[must_use]
50    pub fn escalate(findings: Vec<PaymentPolicyFinding>) -> Self {
51        Self::Escalate { findings }
52    }
53
54    /// Returns a denial decision with one or more findings.
55    #[must_use]
56    pub fn deny(findings: Vec<PaymentPolicyFinding>) -> Self {
57        Self::Deny { findings }
58    }
59
60    /// Returns `true` when the payment may proceed without intervention.
61    #[must_use]
62    pub const fn is_allow(&self) -> bool {
63        matches!(self, Self::Allow)
64    }
65
66    /// Returns `true` when the payment requires explicit human confirmation.
67    #[must_use]
68    pub const fn is_escalate(&self) -> bool {
69        matches!(self, Self::Escalate { .. })
70    }
71
72    /// Returns `true` when the payment is denied.
73    #[must_use]
74    pub const fn is_deny(&self) -> bool {
75        matches!(self, Self::Deny { .. })
76    }
77
78    /// Returns the policy findings attached to the decision.
79    #[must_use]
80    pub fn findings(&self) -> &[PaymentPolicyFinding] {
81        match self {
82            Self::Allow => &[],
83            Self::Escalate { findings } | Self::Deny { findings } => findings.as_slice(),
84        }
85    }
86
87    /// Returns the highest-severity finding attached to the decision.
88    #[must_use]
89    pub fn highest_severity(&self) -> Option<Severity> {
90        self.findings()
91            .iter()
92            .map(|finding| finding.severity)
93            .max_by_key(|severity| severity_rank(*severity))
94    }
95}
96
97/// Trait implemented by payment-specific policy guardrails.
98pub trait PaymentPolicyGuardrail: Send + Sync {
99    /// Stable name of the guardrail.
100    fn name(&self) -> &str;
101
102    /// Evaluates one canonical transaction under a specific protocol surface.
103    fn evaluate(
104        &self,
105        record: &TransactionRecord,
106        protocol: &ProtocolDescriptor,
107    ) -> PaymentPolicyDecision;
108}
109
110/// Ordered collection of payment policy guardrails.
111pub struct PaymentPolicySet {
112    guardrails: Vec<Arc<dyn PaymentPolicyGuardrail>>,
113}
114
115impl PaymentPolicySet {
116    /// Creates an empty payment policy set.
117    #[must_use]
118    pub fn new() -> Self {
119        Self { guardrails: Vec::new() }
120    }
121
122    /// Adds one concrete guardrail to the set.
123    #[must_use]
124    pub fn with(mut self, guardrail: impl PaymentPolicyGuardrail + 'static) -> Self {
125        self.guardrails.push(Arc::new(guardrail));
126        self
127    }
128
129    /// Adds one shared guardrail instance to the set.
130    #[must_use]
131    pub fn with_arc(mut self, guardrail: Arc<dyn PaymentPolicyGuardrail>) -> Self {
132        self.guardrails.push(guardrail);
133        self
134    }
135
136    /// Returns all configured payment policy guardrails.
137    #[must_use]
138    pub fn guardrails(&self) -> &[Arc<dyn PaymentPolicyGuardrail>] {
139        &self.guardrails
140    }
141
142    /// Evaluates all configured guardrails and returns the strongest outcome.
143    #[must_use]
144    pub fn evaluate(
145        &self,
146        record: &TransactionRecord,
147        protocol: &ProtocolDescriptor,
148    ) -> PaymentPolicyDecision {
149        let mut denied = Vec::new();
150        let mut escalated = Vec::new();
151
152        for guardrail in &self.guardrails {
153            match guardrail.evaluate(record, protocol) {
154                PaymentPolicyDecision::Allow => {}
155                PaymentPolicyDecision::Escalate { findings } => escalated.extend(findings),
156                PaymentPolicyDecision::Deny { findings } => denied.extend(findings),
157            }
158        }
159
160        sort_findings(&mut denied);
161        sort_findings(&mut escalated);
162
163        if !denied.is_empty() {
164            PaymentPolicyDecision::deny(denied)
165        } else if !escalated.is_empty() {
166            PaymentPolicyDecision::escalate(escalated)
167        } else {
168            PaymentPolicyDecision::allow()
169        }
170    }
171}
172
173impl Default for PaymentPolicySet {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179fn sort_findings(findings: &mut [PaymentPolicyFinding]) {
180    findings.sort_by(|left, right| {
181        left.guardrail
182            .cmp(&right.guardrail)
183            .then(severity_rank(right.severity).cmp(&severity_rank(left.severity)))
184            .then(left.reason.cmp(&right.reason))
185    });
186}
187
188const fn severity_rank(severity: Severity) -> u8 {
189    match severity {
190        Severity::Low => 0,
191        Severity::Medium => 1,
192        Severity::High => 2,
193        Severity::Critical => 3,
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use adk_guardrail::Severity;
200    use chrono::{TimeZone, Utc};
201
202    use super::*;
203    use crate::domain::{
204        Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef, Money,
205        ProtocolExtensions, TransactionId,
206    };
207
208    struct StaticDecisionGuardrail {
209        name: &'static str,
210        decision: PaymentPolicyDecision,
211    }
212
213    impl PaymentPolicyGuardrail for StaticDecisionGuardrail {
214        fn name(&self) -> &str {
215            self.name
216        }
217
218        fn evaluate(
219            &self,
220            _record: &TransactionRecord,
221            _protocol: &ProtocolDescriptor,
222        ) -> PaymentPolicyDecision {
223            self.decision.clone()
224        }
225    }
226
227    fn sample_record() -> TransactionRecord {
228        TransactionRecord::new(
229            TransactionId::from("tx-policy"),
230            CommerceActor {
231                actor_id: "shopper-agent".to_string(),
232                role: CommerceActorRole::AgentSurface,
233                display_name: Some("shopper".to_string()),
234                tenant_id: Some("tenant-1".to_string()),
235                extensions: ProtocolExtensions::default(),
236            },
237            MerchantRef {
238                merchant_id: "merchant-1".to_string(),
239                legal_name: "Merchant Example LLC".to_string(),
240                display_name: Some("Merchant Example".to_string()),
241                statement_descriptor: None,
242                country_code: Some("US".to_string()),
243                website: Some("https://merchant.example".to_string()),
244                extensions: ProtocolExtensions::default(),
245            },
246            CommerceMode::HumanPresent,
247            Cart {
248                cart_id: Some("cart-1".to_string()),
249                lines: vec![CartLine {
250                    line_id: "line-1".to_string(),
251                    merchant_sku: Some("sku-1".to_string()),
252                    title: "Widget".to_string(),
253                    quantity: 1,
254                    unit_price: Money::new("USD", 1_500, 2),
255                    total_price: Money::new("USD", 1_500, 2),
256                    product_class: Some("widgets".to_string()),
257                    extensions: ProtocolExtensions::default(),
258                }],
259                subtotal: Some(Money::new("USD", 1_500, 2)),
260                adjustments: Vec::new(),
261                total: Money::new("USD", 1_500, 2),
262                affiliate_attribution: None,
263                extensions: ProtocolExtensions::default(),
264            },
265            Utc.with_ymd_and_hms(2026, 3, 22, 15, 0, 0).unwrap(),
266        )
267    }
268
269    #[test]
270    fn policy_set_prefers_denials_over_escalations() {
271        let set = PaymentPolicySet::new()
272            .with(StaticDecisionGuardrail {
273                name: "amount_threshold",
274                decision: PaymentPolicyDecision::escalate(vec![PaymentPolicyFinding::new(
275                    "amount_threshold",
276                    "needs approval",
277                    Severity::Medium,
278                )]),
279            })
280            .with(StaticDecisionGuardrail {
281                name: "merchant_allowlist",
282                decision: PaymentPolicyDecision::deny(vec![PaymentPolicyFinding::new(
283                    "merchant_allowlist",
284                    "merchant is blocked",
285                    Severity::High,
286                )]),
287            });
288
289        let decision = set.evaluate(&sample_record(), &ProtocolDescriptor::acp("2026-01-30"));
290
291        assert!(decision.is_deny());
292        assert_eq!(decision.findings().len(), 1);
293        assert_eq!(decision.findings()[0].guardrail, "merchant_allowlist");
294        assert_eq!(decision.highest_severity(), Some(Severity::High));
295    }
296}