1use adk_guardrail::Severity;
2
3use crate::domain::{
4 CommerceMode, InterventionKind, ProtocolDescriptor, TransactionRecord, TransactionState,
5};
6
7use super::{PaymentPolicyDecision, PaymentPolicyFinding, PaymentPolicyGuardrail};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum InterventionActionPolicy {
12 Allow,
14 RequireUserConfirmation,
16 Deny,
18}
19
20pub struct InterventionPolicyGuardrail {
22 human_present_policy: InterventionActionPolicy,
23 human_not_present_policy: InterventionActionPolicy,
24 blocked_kinds: Vec<InterventionKind>,
25}
26
27impl InterventionPolicyGuardrail {
28 #[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 #[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}