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` — `"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}