Skip to main content

axonflow_sdk_rust/types/
decisions.rs

1// Decision explainability types — implements ADR-043.
2//
3// The DecisionExplanation shape is frozen per ADR-043. Additive fields
4// may be added with `Option<>` + `serde(skip_serializing_if = "Option::is_none")`;
5// renames or removals require a major version bump.
6//
7// Cross-SDK parity:
8//   Go:     axonflow-sdk-go/decisions.go
9//   Python: axonflow-sdk-python/axonflow/decisions.py
10//   TS:     axonflow-sdk-typescript/src/types/decisions.ts
11//   Java:   axonflow-sdk-java/src/main/java/com/getaxonflow/sdk/types/DecisionExplanation.java
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17/// A policy reference inside a decision explanation.
18#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
19pub struct ExplainPolicy {
20    pub policy_id: String,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub policy_name: Option<String>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub action: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub risk_level: Option<String>,
27    #[serde(default)]
28    pub allow_override: bool,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub policy_description: Option<String>,
31}
32
33/// Rule-level detail inside a decision explanation.
34#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
35pub struct ExplainRule {
36    pub policy_id: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub rule_id: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub rule_text: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub matched_on: Option<String>,
43}
44
45/// Canonical payload returned by `AxonFlowClient::explain_decision`.
46///
47/// Shape frozen per ADR-043. Field semantics:
48///
49/// * `decision_id` — the global decision identifier.
50/// * `timestamp` — when the decision was made.
51/// * `policy_matches` — every policy that contributed to the decision,
52///   with risk level and overridability.
53/// * `matched_rules` — rule-level detail (optional, populated when the
54///   upstream engine supports it).
55/// * `decision` — canonical audit verdict `"allowed"` | `"blocked"` |
56///   `"redacted"` | `"needs_approval"` | `"error"` (platform 9.0.0+; pre-9.0.0
57///   used `"allow"` | `"deny"` | `"require_approval"`, see the v8 → v9 migration
58///   guide <https://docs.getaxonflow.com/docs/deployment/v8-to-v9-migration/>).
59/// * `reason` — human-readable reason string.
60/// * `risk_level` — aggregate risk label (`"low"` | `"medium"` | `"high"` | `"critical"`).
61/// * `override_available` — true iff at least one non-critical policy with
62///   `allow_override = true` matched.
63/// * `override_existing_id` — populated when an active override already
64///   covers this caller + policy + tool scope.
65/// * `historical_hit_count_session` — how many times the same
66///   `(policy_id, user_email)` tuple matched in a rolling 24h window.
67/// * `policy_source_link` — optional URL to the policy source.
68/// * `tool_signature` — the tool the decision was scoped to (may be empty
69///   when the decision had no tool context).
70/// * `context` — the FULL sanitized request context the PEP attached to the
71///   decision (canonical `lower_snake_case` keys, string values), read from the
72///   audit row's `policy_details->'context'`. Unlike [`DecisionSummary`] (which
73///   the platform truncates to 5 keys), explain returns every persisted key up
74///   to the 10-key cap (e.g. `x_ai_agent`, `x_session_id`, `x_leader_identity`,
75///   `x-bukuwarung-*`). `None` for pre-v0.6.0 audit rows. (platform #2509)
76/// * `context_truncated` — true when the agent dropped surplus context keys at
77///   write time.
78#[must_use]
79#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
80pub struct DecisionExplanation {
81    pub decision_id: String,
82    pub timestamp: DateTime<Utc>,
83    #[serde(default)]
84    pub policy_matches: Vec<ExplainPolicy>,
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub matched_rules: Vec<ExplainRule>,
87    pub decision: String,
88    pub reason: String,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub risk_level: Option<String>,
91    #[serde(default)]
92    pub override_available: bool,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub override_existing_id: Option<String>,
95    #[serde(default)]
96    pub historical_hit_count_session: i64,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub policy_source_link: Option<String>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub tool_signature: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub context: Option<HashMap<String, String>>,
103    #[serde(default, skip_serializing_if = "is_false")]
104    pub context_truncated: bool,
105}
106
107/// serde `skip_serializing_if` helper: drop `context_truncated` when it is the
108/// `false` default, matching the platform's `omitempty` wire shape.
109fn is_false(b: &bool) -> bool {
110    !*b
111}
112
113/// Slim summary returned by `AxonFlowClient::list_decisions`.
114///
115/// Matches the platform `GET /api/v1/decisions` contract: 5 fields.
116///   `policy_id` and `tool_signature` are optional because pre-α1 audit rows
117///   and dynamic-only blocks may not populate them. ADR-043 §"Versioning"
118///   rules apply: additive `Option<>` fields are non-breaking.
119///
120/// Cross-SDK parity:
121///   Go:     axonflow-sdk-go/decisions.go (DecisionSummary)
122///   Python: axonflow-sdk-python/axonflow/decisions.py (DecisionSummary)
123///   TS:     axonflow-sdk-typescript/src/types/decisions.ts (DecisionSummary)
124///   Java:   axonflow-sdk-java/src/main/java/com/getaxonflow/sdk/types/DecisionSummary.java
125#[must_use]
126#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
127pub struct DecisionSummary {
128    pub decision_id: String,
129    pub timestamp: DateTime<Utc>,
130    pub decision: String,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub policy_id: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub tool_signature: Option<String>,
135    /// The sanitized request context the PEP attached to the decision (canonical
136    /// `lower_snake_case` keys, string values), surfaced from the audit row's
137    /// `policy_details->'context'`. The list summary is truncated by the
138    /// platform to the 5 most-correlated keys; the full map is available via
139    /// `AxonFlowClient::explain_decision`. `None` for pre-v0.6.0 audit rows or
140    /// decisions with no context. (platform #2509 / epic #2508)
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub context: Option<HashMap<String, String>>,
143}
144
145/// Optional filters for `AxonFlowClient::list_decisions`.
146///
147/// Every field is optional — leaving all `None` returns the tier-default
148/// page from the caller's tenant. `since` is RFC3339; `decision`, when set, is
149/// one of the canonical audit verdicts
150/// `"allowed"|"blocked"|"redacted"|"needs_approval"|"error"` (platform 9.0.0+);
151/// the pre-9.0.0 values `"allow"|"deny"|"require_approval"` are rejected with
152/// HTTP 400 by 9.0.0 (see the v8 → v9 migration guide
153/// <https://docs.getaxonflow.com/docs/deployment/v8-to-v9-migration/>). `limit`
154/// is server-capped per tier; over-cap requests get a 429 with the V1 upgrade envelope.
155#[derive(Debug, Clone, Default, PartialEq)]
156pub struct ListDecisionsOptions {
157    pub since: Option<DateTime<Utc>>,
158    pub decision: Option<String>,
159    pub policy_id: Option<String>,
160    pub tool_signature: Option<String>,
161    pub limit: Option<u32>,
162}
163
164/// Pricing-tier upgrade context returned in a 429 envelope when the caller's
165/// tier limits the operation. Mirrors the platform-side
166/// `feedback_429_no_upgrade_hint_is_conversion_gap.md` contract.
167#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
168pub struct UpgradeInfo {
169    pub tier: String,
170    pub wording: String,
171    pub compare_url: String,
172    pub buy_url: String,
173}
174
175/// Parsed body of a 429 response carrying a tier-cap envelope.
176/// Surfaced via `AxonFlowError::RateLimited`.
177#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
178pub struct RateLimitEnvelope {
179    pub error: String,
180    pub limit_type: String,
181    pub tier: String,
182    pub limit: u32,
183    pub remaining: u32,
184    pub upgrade: UpgradeInfo,
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    // v0.6.0 (platform #2509): request context surfaced on decision reads.
192
193    #[test]
194    fn summary_context_round_trips() {
195        let json = r#"{
196            "decision_id": "dec-ctx",
197            "timestamp": "2026-05-30T12:00:00Z",
198            "decision": "blocked",
199            "context": {
200                "x_ai_agent": "refund-bot",
201                "x_session_id": "sess-42",
202                "x_leader_identity": "ops-lead"
203            }
204        }"#;
205        let summary: DecisionSummary = serde_json::from_str(json).unwrap();
206        let ctx = summary.context.as_ref().expect("context present");
207        assert_eq!(ctx.len(), 3);
208        assert_eq!(
209            ctx.get("x_ai_agent").map(String::as_str),
210            Some("refund-bot")
211        );
212
213        // re-serialize -> re-parse without loss
214        let back: DecisionSummary =
215            serde_json::from_str(&serde_json::to_string(&summary).unwrap()).unwrap();
216        assert_eq!(
217            back.context
218                .unwrap()
219                .get("x_leader_identity")
220                .map(String::as_str),
221            Some("ops-lead")
222        );
223    }
224
225    #[test]
226    fn summary_context_absent_is_none_and_omitted() {
227        let json = r#"{"decision_id":"dec-noctx","timestamp":"2026-05-30T12:00:00Z","decision":"allowed"}"#;
228        let summary: DecisionSummary = serde_json::from_str(json).unwrap();
229        assert!(summary.context.is_none());
230        // omitted on the wire (skip_serializing_if), preserving pre-v0.6.0 byte-shape
231        assert!(!serde_json::to_string(&summary).unwrap().contains("context"));
232    }
233
234    #[test]
235    fn explanation_full_context_and_truncated_flag() {
236        let json = r#"{
237            "decision_id": "dec-x",
238            "timestamp": "2026-05-30T12:00:00Z",
239            "decision": "blocked",
240            "reason": "pii",
241            "policy_matches": [],
242            "context": {"x_ai_agent": "a", "x_session_id": "s"},
243            "context_truncated": true
244        }"#;
245        let exp: DecisionExplanation = serde_json::from_str(json).unwrap();
246        assert_eq!(exp.context.as_ref().unwrap().len(), 2);
247        assert!(exp.context_truncated);
248        assert!(serde_json::to_string(&exp)
249            .unwrap()
250            .contains("\"context_truncated\":true"));
251    }
252
253    #[test]
254    fn explanation_context_truncated_false_omitted() {
255        let exp = DecisionExplanation {
256            decision_id: "d".to_string(),
257            decision: "allowed".to_string(),
258            ..Default::default()
259        };
260        let json = serde_json::to_string(&exp).unwrap();
261        assert!(!json.contains("context_truncated"));
262        assert!(!json.contains("\"context\""));
263    }
264}