Skip to main content

adk_payments/guardrail/
protocol_version.rs

1use adk_guardrail::Severity;
2
3use crate::domain::{ProtocolDescriptor, TransactionRecord};
4
5use super::{PaymentPolicyDecision, PaymentPolicyFinding, PaymentPolicyGuardrail};
6
7/// Restricts payment execution to explicit protocol name and version combinations.
8pub struct ProtocolVersionGuardrail {
9    allowed_protocols: Vec<ProtocolDescriptor>,
10}
11
12impl ProtocolVersionGuardrail {
13    /// Creates a protocol-version guardrail.
14    #[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}