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