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}