Skip to main content

adk_payments/guardrail/
intervention_policy.rs

1use adk_guardrail::Severity;
2
3use crate::domain::{
4    CommerceMode, InterventionKind, ProtocolDescriptor, TransactionRecord, TransactionState,
5};
6
7use super::{PaymentPolicyDecision, PaymentPolicyFinding, PaymentPolicyGuardrail};
8
9/// Policy applied when a payment flow may continue autonomously or requires a user return.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum InterventionActionPolicy {
12    /// The payment may continue without further user confirmation.
13    Allow,
14    /// The payment may continue only after the user explicitly confirms.
15    RequireUserConfirmation,
16    /// The payment must not continue under the current policy.
17    Deny,
18}
19
20/// Governs when payment interventions may continue autonomously.
21pub struct InterventionPolicyGuardrail {
22    human_present_policy: InterventionActionPolicy,
23    human_not_present_policy: InterventionActionPolicy,
24    blocked_kinds: Vec<InterventionKind>,
25}
26
27impl InterventionPolicyGuardrail {
28    /// Creates a new intervention-policy guardrail.
29    #[must_use]
30    pub fn new(
31        human_present_policy: InterventionActionPolicy,
32        human_not_present_policy: InterventionActionPolicy,
33    ) -> Self {
34        Self { human_present_policy, human_not_present_policy, blocked_kinds: Vec::new() }
35    }
36
37    /// Blocks one specific intervention kind outright.
38    #[must_use]
39    pub fn with_blocked_kind(mut self, kind: InterventionKind) -> Self {
40        self.blocked_kinds.push(kind);
41        self
42    }
43
44    fn mode_policy(&self, mode: CommerceMode) -> InterventionActionPolicy {
45        match mode {
46            CommerceMode::HumanPresent => self.human_present_policy,
47            CommerceMode::HumanNotPresent => self.human_not_present_policy,
48        }
49    }
50}
51
52impl PaymentPolicyGuardrail for InterventionPolicyGuardrail {
53    fn name(&self) -> &str {
54        "intervention_policy"
55    }
56
57    fn evaluate(
58        &self,
59        record: &TransactionRecord,
60        _protocol: &ProtocolDescriptor,
61    ) -> PaymentPolicyDecision {
62        let policy = self.mode_policy(record.mode);
63
64        if let TransactionState::InterventionRequired(intervention) = &record.state {
65            if self.blocked_kinds.contains(&intervention.kind) {
66                return PaymentPolicyDecision::deny(vec![PaymentPolicyFinding::new(
67                    self.name(),
68                    format!(
69                        "intervention `{}` is blocked by policy",
70                        intervention_kind_name(&intervention.kind)
71                    ),
72                    Severity::High,
73                )]);
74            }
75
76            return match policy {
77                InterventionActionPolicy::Allow => PaymentPolicyDecision::allow(),
78                InterventionActionPolicy::RequireUserConfirmation => {
79                    PaymentPolicyDecision::escalate(vec![PaymentPolicyFinding::new(
80                        self.name(),
81                        format!(
82                            "intervention `{}` requires explicit user confirmation before continuation",
83                            intervention_kind_name(&intervention.kind)
84                        ),
85                        Severity::Medium,
86                    )])
87                }
88                InterventionActionPolicy::Deny => {
89                    PaymentPolicyDecision::deny(vec![PaymentPolicyFinding::new(
90                        self.name(),
91                        format!(
92                            "intervention `{}` cannot continue under the current policy",
93                            intervention_kind_name(&intervention.kind)
94                        ),
95                        Severity::High,
96                    )])
97                }
98            };
99        }
100
101        if matches!(record.mode, CommerceMode::HumanNotPresent) {
102            match policy {
103                InterventionActionPolicy::Allow => {}
104                InterventionActionPolicy::RequireUserConfirmation => {
105                    return PaymentPolicyDecision::escalate(vec![PaymentPolicyFinding::new(
106                        self.name(),
107                        "human-not-present payment execution requires explicit user confirmation"
108                            .to_string(),
109                        Severity::Medium,
110                    )]);
111                }
112                InterventionActionPolicy::Deny => {
113                    return PaymentPolicyDecision::deny(vec![PaymentPolicyFinding::new(
114                        self.name(),
115                        "human-not-present payment execution is blocked by policy".to_string(),
116                        Severity::High,
117                    )]);
118                }
119            }
120        }
121
122        PaymentPolicyDecision::allow()
123    }
124}
125
126fn intervention_kind_name(kind: &InterventionKind) -> &str {
127    match kind {
128        InterventionKind::ThreeDsChallenge => "three_ds_challenge",
129        InterventionKind::BiometricConfirmation => "biometric_confirmation",
130        InterventionKind::AddressVerification => "address_verification",
131        InterventionKind::BuyerReconfirmation => "buyer_reconfirmation",
132        InterventionKind::MerchantReview => "merchant_review",
133        InterventionKind::Other(value) => value.as_str(),
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use chrono::{TimeZone, Utc};
140
141    use super::*;
142    use crate::domain::{
143        Cart, CartLine, CommerceActor, CommerceActorRole, InterventionState, InterventionStatus,
144        MerchantRef, Money, ProtocolExtensions, TransactionId,
145    };
146
147    fn sample_record() -> TransactionRecord {
148        let mut record = TransactionRecord::new(
149            TransactionId::from("tx-intervention"),
150            CommerceActor {
151                actor_id: "shopper-agent".to_string(),
152                role: CommerceActorRole::AgentSurface,
153                display_name: Some("shopper".to_string()),
154                tenant_id: Some("tenant-1".to_string()),
155                extensions: ProtocolExtensions::default(),
156            },
157            MerchantRef {
158                merchant_id: "merchant-1".to_string(),
159                legal_name: "Merchant Example LLC".to_string(),
160                display_name: Some("Merchant Example".to_string()),
161                statement_descriptor: None,
162                country_code: Some("US".to_string()),
163                website: Some("https://merchant.example".to_string()),
164                extensions: ProtocolExtensions::default(),
165            },
166            CommerceMode::HumanNotPresent,
167            Cart {
168                cart_id: Some("cart-1".to_string()),
169                lines: vec![CartLine {
170                    line_id: "line-1".to_string(),
171                    merchant_sku: Some("sku-1".to_string()),
172                    title: "Widget".to_string(),
173                    quantity: 1,
174                    unit_price: Money::new("USD", 1_500, 2),
175                    total_price: Money::new("USD", 1_500, 2),
176                    product_class: Some("widgets".to_string()),
177                    extensions: ProtocolExtensions::default(),
178                }],
179                subtotal: Some(Money::new("USD", 1_500, 2)),
180                adjustments: Vec::new(),
181                total: Money::new("USD", 1_500, 2),
182                affiliate_attribution: None,
183                extensions: ProtocolExtensions::default(),
184            },
185            Utc.with_ymd_and_hms(2026, 3, 22, 15, 30, 0).unwrap(),
186        );
187        record
188            .transition_to(
189                TransactionState::Negotiating,
190                Utc.with_ymd_and_hms(2026, 3, 22, 15, 31, 0).unwrap(),
191            )
192            .unwrap();
193        record
194            .transition_to(
195                TransactionState::InterventionRequired(Box::new(InterventionState {
196                    intervention_id: "int-1".to_string(),
197                    kind: InterventionKind::BuyerReconfirmation,
198                    status: InterventionStatus::Pending,
199                    instructions: Some("return to user".to_string()),
200                    continuation_token: None,
201                    requested_by: None,
202                    expires_at: None,
203                    extensions: ProtocolExtensions::default(),
204                })),
205                Utc.with_ymd_and_hms(2026, 3, 22, 15, 32, 0).unwrap(),
206            )
207            .unwrap();
208        record
209    }
210
211    #[test]
212    fn intervention_policy_escalates_when_user_confirmation_is_required() {
213        let guardrail = InterventionPolicyGuardrail::new(
214            InterventionActionPolicy::Allow,
215            InterventionActionPolicy::RequireUserConfirmation,
216        );
217        let decision = guardrail.evaluate(&sample_record(), &ProtocolDescriptor::ap2("v0.1-alpha"));
218
219        assert!(decision.is_escalate());
220        assert_eq!(decision.findings()[0].guardrail, "intervention_policy");
221    }
222}