adk_payments/guardrail/
protocol_version.rs1use adk_guardrail::Severity;
2
3use crate::domain::{ProtocolDescriptor, TransactionRecord};
4
5use super::{PaymentPolicyDecision, PaymentPolicyFinding, PaymentPolicyGuardrail};
6
7pub struct ProtocolVersionGuardrail {
9 allowed_protocols: Vec<ProtocolDescriptor>,
10}
11
12impl ProtocolVersionGuardrail {
13 #[must_use]
15 pub fn new<I>(allowed_protocols: I) -> Self
16 where
17 I: IntoIterator<Item = ProtocolDescriptor>,
18 {
19 Self { allowed_protocols: allowed_protocols.into_iter().collect() }
20 }
21
22 fn allows(&self, protocol: &ProtocolDescriptor) -> bool {
23 self.allowed_protocols.iter().any(|allowed| {
24 allowed.name == protocol.name
25 && match &allowed.version {
26 Some(version) => protocol.version.as_ref() == Some(version),
27 None => true,
28 }
29 })
30 }
31}
32
33impl PaymentPolicyGuardrail for ProtocolVersionGuardrail {
34 fn name(&self) -> &str {
35 "protocol_version"
36 }
37
38 fn evaluate(
39 &self,
40 _record: &TransactionRecord,
41 protocol: &ProtocolDescriptor,
42 ) -> PaymentPolicyDecision {
43 if self.allows(protocol) {
44 PaymentPolicyDecision::allow()
45 } else {
46 let version = protocol.version.as_deref().unwrap_or("unspecified");
47 PaymentPolicyDecision::deny(vec![PaymentPolicyFinding::new(
48 self.name(),
49 format!(
50 "protocol `{}` version `{version}` is not allowed by the configured baseline policy",
51 protocol.name
52 ),
53 Severity::High,
54 )])
55 }
56 }
57}
58
59#[cfg(test)]
60mod tests {
61 use chrono::{TimeZone, Utc};
62
63 use super::*;
64 use crate::domain::{
65 Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef, Money,
66 ProtocolExtensions, TransactionId,
67 };
68
69 fn sample_record() -> TransactionRecord {
70 TransactionRecord::new(
71 TransactionId::from("tx-protocol"),
72 CommerceActor {
73 actor_id: "shopper-agent".to_string(),
74 role: CommerceActorRole::AgentSurface,
75 display_name: Some("shopper".to_string()),
76 tenant_id: Some("tenant-1".to_string()),
77 extensions: ProtocolExtensions::default(),
78 },
79 MerchantRef {
80 merchant_id: "merchant-1".to_string(),
81 legal_name: "Merchant Example LLC".to_string(),
82 display_name: Some("Merchant Example".to_string()),
83 statement_descriptor: None,
84 country_code: Some("US".to_string()),
85 website: Some("https://merchant.example".to_string()),
86 extensions: ProtocolExtensions::default(),
87 },
88 CommerceMode::HumanPresent,
89 Cart {
90 cart_id: Some("cart-1".to_string()),
91 lines: vec![CartLine {
92 line_id: "line-1".to_string(),
93 merchant_sku: Some("sku-1".to_string()),
94 title: "Widget".to_string(),
95 quantity: 1,
96 unit_price: Money::new("USD", 1_500, 2),
97 total_price: Money::new("USD", 1_500, 2),
98 product_class: Some("widgets".to_string()),
99 extensions: ProtocolExtensions::default(),
100 }],
101 subtotal: Some(Money::new("USD", 1_500, 2)),
102 adjustments: Vec::new(),
103 total: Money::new("USD", 1_500, 2),
104 affiliate_attribution: None,
105 extensions: ProtocolExtensions::default(),
106 },
107 Utc.with_ymd_and_hms(2026, 3, 22, 15, 40, 0).unwrap(),
108 )
109 }
110
111 #[test]
112 fn protocol_version_guardrail_denies_unknown_version() {
113 let guardrail = ProtocolVersionGuardrail::new([ProtocolDescriptor::acp("2026-01-30")]);
114 let decision = guardrail.evaluate(&sample_record(), &ProtocolDescriptor::acp("2025-12-01"));
115
116 assert!(decision.is_deny());
117 assert_eq!(decision.findings()[0].guardrail, "protocol_version");
118 }
119}