Skip to main content

axonflow_sdk_rust/
decisions.rs

1// Decision explainability methods for the AxonFlow Rust SDK.
2//
3// Implements the ADR-043 contract:
4//   GET /api/v1/decisions/:id/explain
5//
6// Returns a [`DecisionExplanation`] including the matched policies,
7// risk level, override availability, and historical hit count.
8//
9// Cross-SDK parity:
10//   Go:     axonflow-sdk-go/decisions.go (ExplainDecision)
11//   Python: axonflow-sdk-python/axonflow/client.py (explain_decision)
12//   TS:     axonflow-sdk-typescript/src/client.ts (explainDecision)
13//   Java:   axonflow-sdk-java/src/main/java/com/getaxonflow/sdk/AxonFlow.java (explainDecision)
14
15use crate::client::AxonFlowClient;
16use crate::error::AxonFlowError;
17use crate::types::decisions::{
18    DecisionExplanation, DecisionSummary, ListDecisionsOptions, RateLimitEnvelope,
19};
20use crate::PATH_SEGMENT;
21use percent_encoding::utf8_percent_encode;
22use serde::Deserialize;
23use tracing;
24
25impl AxonFlowClient {
26    /// Fetches the full explanation for a previously-made policy decision.
27    ///
28    /// The caller must either own the decision (X-User-Email match) or
29    /// belong to the same tenant as the decision (X-Tenant-ID match).
30    /// Returns an error wrapping HTTP 404 when the decision is past the
31    /// tier's audit retention window.
32    ///
33    /// # Example
34    ///
35    /// ```no_run
36    /// # use axonflow_sdk_rust::{AxonFlowClient, AxonFlowConfig};
37    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
38    /// let client = AxonFlowClient::new(AxonFlowConfig::new("http://localhost:8080"))?;
39    /// let exp = client.explain_decision("dec_wf123_step4").await?;
40    /// if exp.override_available {
41    ///     // Surface a "request override" UI affordance
42    /// }
43    /// # Ok(()) }
44    /// ```
45    #[tracing::instrument(skip(self))]
46    pub async fn explain_decision(
47        &self,
48        decision_id: &str,
49    ) -> Result<DecisionExplanation, AxonFlowError> {
50        if decision_id.is_empty() {
51            return Err(AxonFlowError::ConfigError(
52                "decision_id is required".to_string(),
53            ));
54        }
55
56        // Path-escape — platform-generated decision IDs are usually
57        // filesystem-safe, but ADR-043 does not guarantee it. Decision
58        // IDs containing '/' or '?' would otherwise corrupt the URL.
59        let encoded = utf8_percent_encode(decision_id, PATH_SEGMENT).to_string();
60        let url = format!("{}/api/v1/decisions/{}/explain", self.endpoint(), encoded);
61
62        let resp = self.checked_get(&url).await?;
63        let body = resp.text().await?;
64        let parsed: DecisionExplanation = serde_json::from_str(&body)?;
65        Ok(parsed)
66    }
67
68    /// Lists recent policy decisions for the caller's tenant.
69    ///
70    /// Returns the slim 5-field [`DecisionSummary`] page; the platform
71    /// applies a tier-gated cap (5/24h on Free + Community, 100/30d on
72    /// Pro + Evaluation, 1000/full on Enterprise). Requesting a `limit`
73    /// above the tier cap yields a 429 with the V1 upgrade envelope —
74    /// surfaced here as [`AxonFlowError::RateLimited`] so callers can
75    /// branch on `envelope.upgrade.{tier,compare_url,buy_url}` without
76    /// re-parsing the body.
77    ///
78    /// Filters compose: passing `decision = Some("blocked")` AND
79    /// `policy_id = Some("pol-sqli")` returns only blocked decisions
80    /// matching that policy. `since` is RFC3339 (chrono `DateTime<Utc>`).
81    ///
82    /// # Example
83    ///
84    /// ```no_run
85    /// # use axonflow_sdk_rust::{AxonFlowClient, AxonFlowConfig, ListDecisionsOptions};
86    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
87    /// let client = AxonFlowClient::new(AxonFlowConfig::new("http://localhost:8080"))?;
88    /// let opts = ListDecisionsOptions {
89    ///     decision: Some("blocked".into()),
90    ///     limit: Some(10),
91    ///     ..Default::default()
92    /// };
93    /// let decisions = client.list_decisions(opts).await?;
94    /// for d in decisions {
95    ///     println!("{} {} {}", d.decision_id, d.decision, d.timestamp);
96    /// }
97    /// # Ok(()) }
98    /// ```
99    pub async fn list_decisions(
100        &self,
101        opts: ListDecisionsOptions,
102    ) -> Result<Vec<DecisionSummary>, AxonFlowError> {
103        let mut url = format!("{}/api/v1/decisions", self.endpoint());
104        let qs = build_decisions_query(&opts);
105        if !qs.is_empty() {
106            url.push('?');
107            url.push_str(&qs);
108        }
109
110        // raw_get bypasses check_status so we can branch on 429 BEFORE
111        // it turns into a generic ApiError. Other failures fall through
112        // to the same shape check_status would have produced.
113        let resp = self.raw_get(&url).await?;
114        if resp.status().as_u16() == 429 {
115            let body = resp.text().await?;
116            return match serde_json::from_str::<RateLimitEnvelope>(&body) {
117                Ok(envelope) => Err(AxonFlowError::RateLimited {
118                    envelope: Box::new(envelope),
119                }),
120                Err(_) => Err(AxonFlowError::ApiError {
121                    status: 429,
122                    message: body,
123                }),
124            };
125        }
126        if !resp.status().is_success() {
127            let status = resp.status().as_u16();
128            let message = resp.text().await?;
129            return Err(AxonFlowError::ApiError { status, message });
130        }
131        let body = resp.text().await?;
132        #[derive(Deserialize)]
133        struct ListResponse {
134            #[serde(default)]
135            decisions: Vec<DecisionSummary>,
136        }
137        let parsed: ListResponse = serde_json::from_str(&body)?;
138        Ok(parsed.decisions)
139    }
140}
141
142/// Builds the URL-encoded query string from [`ListDecisionsOptions`].
143/// Empty / `None` fields are omitted so the platform applies its tier
144/// defaults. Field order is stable so test mocks can match the URL.
145fn build_decisions_query(opts: &ListDecisionsOptions) -> String {
146    let mut pairs: Vec<(&str, String)> = Vec::with_capacity(5);
147    if let Some(since) = &opts.since {
148        // Use the "Z" UTC marker rather than "+00:00" — `+` in a query
149        // string decodes to space under application/x-www-form-urlencoded,
150        // so emitting `+00:00` would wire-corrupt the timestamp on the
151        // platform side.
152        pairs.push((
153            "since",
154            since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
155        ));
156    }
157    if let Some(decision) = &opts.decision {
158        pairs.push(("decision", decision.clone()));
159    }
160    if let Some(policy_id) = &opts.policy_id {
161        pairs.push(("policy_id", policy_id.clone()));
162    }
163    if let Some(tool_signature) = &opts.tool_signature {
164        pairs.push(("tool_signature", tool_signature.clone()));
165    }
166    if let Some(limit) = opts.limit {
167        pairs.push(("limit", limit.to_string()));
168    }
169    pairs
170        .into_iter()
171        .map(|(k, v)| {
172            let v = utf8_percent_encode(&v, PATH_SEGMENT).to_string();
173            format!("{k}={v}")
174        })
175        .collect::<Vec<_>>()
176        .join("&")
177}
178
179#[cfg(test)]
180mod tests {
181    use crate::types::decisions::DecisionExplanation;
182    use crate::{AxonFlowClient, AxonFlowConfig};
183    use chrono::{TimeZone, Utc};
184    use serde_json::json;
185    use std::time::Duration;
186    use wiremock::matchers::{method, path, query_param};
187    use wiremock::{Mock, MockServer, ResponseTemplate};
188
189    fn make_client(endpoint: String) -> AxonFlowClient {
190        let config = AxonFlowConfig {
191            endpoint,
192            timeout: Duration::from_secs(2),
193            ..Default::default()
194        };
195        AxonFlowClient::new(config).expect("client init")
196    }
197
198    #[tokio::test]
199    async fn empty_decision_id_returns_config_error() {
200        // No HTTP server needed — guard fires before any wire call.
201        let client = make_client("http://127.0.0.1:1".into());
202        let err = client.explain_decision("").await.unwrap_err();
203        assert!(
204            err.to_string().contains("decision_id is required"),
205            "unexpected error: {err}"
206        );
207    }
208
209    #[tokio::test]
210    async fn happy_path_parses_full_payload() {
211        let server = MockServer::start().await;
212        let want = json!({
213            "decision_id": "dec_wf1_step2",
214            "timestamp": "2026-04-17T12:00:00Z",
215            "decision": "blocked",
216            "reason": "SQL injection detected",
217            "risk_level": "high",
218            "policy_matches": [{
219                "policy_id": "pol-sqli",
220                "policy_name": "SQL Injection Detector",
221                "action": "deny",
222                "risk_level": "high",
223                "allow_override": true
224            }],
225            "override_available": true,
226            "historical_hit_count_session": 3
227        });
228
229        Mock::given(method("GET"))
230            .and(path("/api/v1/decisions/dec_wf1_step2/explain"))
231            .respond_with(
232                ResponseTemplate::new(200)
233                    .insert_header("content-type", "application/json")
234                    .set_body_json(want),
235            )
236            .expect(1)
237            .mount(&server)
238            .await;
239
240        let client = make_client(server.uri());
241        let got = client.explain_decision("dec_wf1_step2").await.unwrap();
242
243        assert_eq!(got.decision_id, "dec_wf1_step2");
244        assert_eq!(got.decision, "blocked");
245        assert_eq!(got.reason, "SQL injection detected");
246        assert_eq!(got.risk_level.as_deref(), Some("high"));
247        assert_eq!(got.policy_matches.len(), 1);
248        assert_eq!(got.policy_matches[0].policy_id, "pol-sqli");
249        assert!(got.policy_matches[0].allow_override);
250        assert!(got.override_available);
251        assert_eq!(got.historical_hit_count_session, 3);
252        assert_eq!(
253            got.timestamp,
254            Utc.with_ymd_and_hms(2026, 4, 17, 12, 0, 0).unwrap()
255        );
256    }
257
258    #[tokio::test]
259    async fn decision_id_is_url_encoded() {
260        // Decision IDs containing '/' must be percent-encoded so they don't
261        // corrupt the path. Ensures parity with axonflow-sdk-go's PathEscape
262        // contract test (decisions_test.go::TestExplainDecision_URLEncodesDecisionID).
263        let server = MockServer::start().await;
264        Mock::given(method("GET"))
265            .and(path("/api/v1/decisions/a%2Fb/explain"))
266            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
267                "decision_id": "a/b",
268                "timestamp": "2026-04-17T12:00:00Z",
269                "decision": "allowed",
270                "reason": "",
271                "policy_matches": []
272            })))
273            .expect(1)
274            .mount(&server)
275            .await;
276
277        let client = make_client(server.uri());
278        let _ = client.explain_decision("a/b").await.unwrap();
279    }
280
281    #[tokio::test]
282    async fn http_404_surfaces_as_api_error() {
283        let server = MockServer::start().await;
284        Mock::given(method("GET"))
285            .and(path("/api/v1/decisions/dec-missing/explain"))
286            .respond_with(
287                ResponseTemplate::new(404)
288                    .set_body_json(json!({"error": "Decision not found or past retention window"})),
289            )
290            .mount(&server)
291            .await;
292
293        let client = make_client(server.uri());
294        let err = client.explain_decision("dec-missing").await.unwrap_err();
295        match err {
296            crate::error::AxonFlowError::ApiError { status, .. } => assert_eq!(status, 404),
297            other => panic!("expected ApiError(404), got: {other}"),
298        }
299    }
300
301    #[tokio::test]
302    async fn http_401_surfaces_as_api_error() {
303        // explainDecisionHandler returns 401 when X-Tenant-ID is missing
304        // (platform/orchestrator/explain_handler.go:80). Caller-side rendering
305        // should distinguish "not authorized" from "not found" — covered by
306        // the ApiError status.
307        let server = MockServer::start().await;
308        Mock::given(method("GET"))
309            .and(path("/api/v1/decisions/dec-x/explain"))
310            .respond_with(
311                ResponseTemplate::new(401)
312                    .set_body_json(json!({"error": "X-Tenant-ID header is required"})),
313            )
314            .mount(&server)
315            .await;
316
317        let client = make_client(server.uri());
318        let err = client.explain_decision("dec-x").await.unwrap_err();
319        match err {
320            crate::error::AxonFlowError::ApiError { status, .. } => assert_eq!(status, 401),
321            other => panic!("expected ApiError(401), got: {other}"),
322        }
323    }
324
325    #[tokio::test]
326    async fn malformed_json_response_is_serde_error() {
327        let server = MockServer::start().await;
328        Mock::given(method("GET"))
329            .and(path("/api/v1/decisions/dec-x/explain"))
330            .respond_with(
331                ResponseTemplate::new(200)
332                    .insert_header("content-type", "application/json")
333                    .set_body_string("{not valid json"),
334            )
335            .mount(&server)
336            .await;
337
338        let client = make_client(server.uri());
339        let err = client.explain_decision("dec-x").await.unwrap_err();
340        match err {
341            crate::error::AxonFlowError::SerdeError(_) => {}
342            other => panic!("expected SerdeError, got: {other}"),
343        }
344    }
345
346    #[tokio::test]
347    async fn additive_unknown_fields_are_ignored() {
348        // Forward-compat: ADR-043 §"Versioning" allows additive fields on
349        // future platform versions. The Rust SDK must NOT fail when the
350        // platform returns a field the SDK doesn't know about yet — this is
351        // the failure mode that breaks customers when the platform is ahead
352        // of the SDK. (Default serde_json behavior is to ignore unknown
353        // fields; this test pins that contract so it cannot regress via a
354        // future #[serde(deny_unknown_fields)] addition.)
355        let server = MockServer::start().await;
356        Mock::given(method("GET"))
357            .and(path("/api/v1/decisions/dec-x/explain"))
358            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
359                "decision_id": "dec-x",
360                "timestamp": "2026-04-17T12:00:00Z",
361                "decision": "allowed",
362                "reason": "",
363                "policy_matches": [],
364                "policy_version_at_decision": "v3",      // future-additive (V1.1)
365                "latest_policy_version": "v5",            // future-additive (V1.1)
366                "yet_another_future_field": "shrug"      // arbitrary forward-compat
367            })))
368            .mount(&server)
369            .await;
370
371        let client = make_client(server.uri());
372        let got: DecisionExplanation = client.explain_decision("dec-x").await.unwrap();
373        assert_eq!(got.decision_id, "dec-x");
374    }
375
376    // ------------------------------------------------------------------
377    // list_decisions — 6 contract tests covering happy path, every
378    // filter, the 429 upgrade envelope, 401, and forward-compat.
379    // ------------------------------------------------------------------
380
381    use crate::decisions::build_decisions_query;
382    use crate::error::AxonFlowError;
383    use crate::types::decisions::{DecisionSummary, ListDecisionsOptions};
384
385    #[tokio::test]
386    async fn list_decisions_happy_path_parses_three_rows() {
387        let server = MockServer::start().await;
388        let want = json!({
389            "decisions": [
390                {
391                    "decision_id": "dec-1",
392                    "timestamp": "2026-05-07T12:00:00Z",
393                    "decision": "blocked",
394                    "policy_id": "pol-sqli",
395                    "tool_signature": "postgres.query"
396                },
397                {
398                    "decision_id": "dec-2",
399                    "timestamp": "2026-05-07T11:00:00Z",
400                    "decision": "allowed",
401                    "policy_id": "pol-default",
402                    "tool_signature": "github.status"
403                },
404                {
405                    "decision_id": "dec-3",
406                    "timestamp": "2026-05-07T10:00:00Z",
407                    "decision": "needs_approval",
408                    "policy_id": "pol-amount",
409                    "tool_signature": "stripe.charge"
410                }
411            ]
412        });
413
414        Mock::given(method("GET"))
415            .and(path("/api/v1/decisions"))
416            .respond_with(
417                ResponseTemplate::new(200)
418                    .insert_header("content-type", "application/json")
419                    .set_body_json(want),
420            )
421            .mount(&server)
422            .await;
423
424        let client = make_client(server.uri());
425        let got = client
426            .list_decisions(ListDecisionsOptions::default())
427            .await
428            .unwrap();
429
430        assert_eq!(got.len(), 3);
431        assert_eq!(got[0].decision_id, "dec-1");
432        assert_eq!(got[0].decision, "blocked");
433        assert_eq!(got[0].policy_id.as_deref(), Some("pol-sqli"));
434        assert_eq!(got[0].tool_signature.as_deref(), Some("postgres.query"));
435        assert_eq!(got[2].decision, "needs_approval");
436    }
437
438    #[tokio::test]
439    async fn list_decisions_serializes_every_filter_into_url() {
440        let server = MockServer::start().await;
441        // Mock matches the EXACT query string we expect — if the SDK
442        // forgets to register a field in the URL builder, the mock
443        // returns 404 and the test fails via the unmatched-request
444        // assertion (.expect(1) below).
445        Mock::given(method("GET"))
446            .and(path("/api/v1/decisions"))
447            .and(query_param("since", "2026-05-07T00:00:00Z"))
448            .and(query_param("decision", "blocked"))
449            .and(query_param("policy_id", "pol-sqli"))
450            .and(query_param("tool_signature", "postgres.query"))
451            .and(query_param("limit", "25"))
452            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"decisions": []})))
453            .expect(1)
454            .mount(&server)
455            .await;
456
457        let client = make_client(server.uri());
458        let opts = ListDecisionsOptions {
459            since: Some(Utc.with_ymd_and_hms(2026, 5, 7, 0, 0, 0).unwrap()),
460            decision: Some("blocked".into()),
461            policy_id: Some("pol-sqli".into()),
462            tool_signature: Some("postgres.query".into()),
463            limit: Some(25),
464        };
465        let _ = client.list_decisions(opts).await.unwrap();
466    }
467
468    #[tokio::test]
469    async fn list_decisions_429_surfaces_typed_rate_limit_envelope() {
470        let server = MockServer::start().await;
471        let envelope = json!({
472            "error": "Free tier shows the last 5 decisions in 24h. Pro raises this to 100 decisions in the last 30 days.",
473            "limit_type": "decision_list_size",
474            "tier": "Community",
475            "limit": 5,
476            "remaining": 0,
477            "upgrade": {
478                "tier": "Pro",
479                "wording": "Free tier shows the last 5 decisions in 24h. Pro raises this to 100 decisions in the last 30 days.",
480                "compare_url": "https://getaxonflow.com/pricing/",
481                "buy_url": "https://buy.stripe.com/bJe28qbztcdVchjdkw8k800"
482            }
483        });
484
485        Mock::given(method("GET"))
486            .and(path("/api/v1/decisions"))
487            .respond_with(
488                ResponseTemplate::new(429)
489                    .insert_header("content-type", "application/json")
490                    .insert_header("X-Axonflow-Tier-Limit", "decision_list_size")
491                    .set_body_json(envelope),
492            )
493            .mount(&server)
494            .await;
495
496        let client = make_client(server.uri());
497        let err = client
498            .list_decisions(ListDecisionsOptions {
499                limit: Some(10),
500                ..Default::default()
501            })
502            .await
503            .expect_err("must reject with RateLimited");
504
505        match err {
506            AxonFlowError::RateLimited { envelope } => {
507                assert_eq!(envelope.tier, "Community");
508                assert_eq!(envelope.limit_type, "decision_list_size");
509                assert_eq!(envelope.limit, 5);
510                assert_eq!(envelope.upgrade.tier, "Pro");
511                assert_eq!(
512                    envelope.upgrade.compare_url,
513                    "https://getaxonflow.com/pricing/"
514                );
515                assert_eq!(
516                    envelope.upgrade.buy_url,
517                    "https://buy.stripe.com/bJe28qbztcdVchjdkw8k800"
518                );
519            }
520            other => panic!("expected RateLimited, got {other:?}"),
521        }
522    }
523
524    #[tokio::test]
525    async fn list_decisions_429_with_malformed_body_falls_back_to_apierror() {
526        // If the platform changes the 429 shape and the SDK can't parse
527        // the envelope, we must still surface the 429 — not panic or
528        // silently succeed. Falls through to ApiError{status=429}.
529        let server = MockServer::start().await;
530        Mock::given(method("GET"))
531            .and(path("/api/v1/decisions"))
532            .respond_with(
533                ResponseTemplate::new(429)
534                    .insert_header("content-type", "application/json")
535                    .set_body_string("not a json envelope"),
536            )
537            .mount(&server)
538            .await;
539
540        let client = make_client(server.uri());
541        let err = client
542            .list_decisions(ListDecisionsOptions::default())
543            .await
544            .expect_err("must reject");
545        match err {
546            AxonFlowError::ApiError { status, .. } => assert_eq!(status, 429),
547            other => panic!("expected ApiError{{status=429}}, got {other:?}"),
548        }
549    }
550
551    #[tokio::test]
552    async fn list_decisions_401_surfaces_as_apierror() {
553        let server = MockServer::start().await;
554        Mock::given(method("GET"))
555            .and(path("/api/v1/decisions"))
556            .respond_with(
557                ResponseTemplate::new(401)
558                    .insert_header("content-type", "application/json")
559                    .set_body_json(json!({"error": "X-Tenant-ID header is required"})),
560            )
561            .mount(&server)
562            .await;
563
564        let client = make_client(server.uri());
565        let err = client
566            .list_decisions(ListDecisionsOptions::default())
567            .await
568            .expect_err("must reject");
569        match err {
570            AxonFlowError::ApiError { status, message } => {
571                assert_eq!(status, 401);
572                assert!(message.contains("X-Tenant-ID"), "msg = {message}");
573            }
574            other => panic!("expected ApiError{{status=401}}, got {other:?}"),
575        }
576    }
577
578    #[tokio::test]
579    async fn list_decisions_forward_compat_unknown_fields_ignored() {
580        let server = MockServer::start().await;
581        let want = json!({
582            "decisions": [{
583                "decision_id": "dec-fwd",
584                "timestamp": "2026-05-07T12:00:00Z",
585                "decision": "blocked",
586                "policy_id": "pol-x",
587                "tool_signature": "tool-x",
588                "policy_version": 7,                  // future-additive (#1983 α3)
589                "latest_policy_version": 9,           // future-additive
590                "arbitrary_unknown": "ignored"        // arbitrary forward-compat
591            }],
592            "next_cursor": "future_cursor_pagination" // outer envelope additive
593        });
594
595        Mock::given(method("GET"))
596            .and(path("/api/v1/decisions"))
597            .respond_with(ResponseTemplate::new(200).set_body_json(want))
598            .mount(&server)
599            .await;
600
601        let client = make_client(server.uri());
602        let got = client
603            .list_decisions(ListDecisionsOptions::default())
604            .await
605            .unwrap();
606        assert_eq!(got.len(), 1);
607        assert_eq!(got[0].decision_id, "dec-fwd");
608    }
609
610    #[test]
611    fn build_decisions_query_omits_none_fields() {
612        let qs = build_decisions_query(&ListDecisionsOptions::default());
613        assert_eq!(qs, "");
614
615        let qs = build_decisions_query(&ListDecisionsOptions {
616            decision: Some("blocked".into()),
617            limit: Some(7),
618            ..Default::default()
619        });
620        assert_eq!(qs, "decision=blocked&limit=7");
621    }
622
623    #[test]
624    fn decision_summary_optional_fields_round_trip() {
625        // Platform may write rows without policy_id / tool_signature
626        // (dynamic-only blocks); SDK must accept them as Option::None
627        // and round-trip without emitting empty strings.
628        let raw = json!({
629            "decision_id": "dec-min",
630            "timestamp": "2026-05-07T12:00:00Z",
631            "decision": "blocked"
632        });
633        let parsed: DecisionSummary = serde_json::from_value(raw).unwrap();
634        assert_eq!(parsed.decision_id, "dec-min");
635        assert_eq!(parsed.policy_id, None);
636        assert_eq!(parsed.tool_signature, None);
637        // Re-serialize: omitempty must drop the optional fields.
638        let s = serde_json::to_string(&parsed).unwrap();
639        assert!(!s.contains("policy_id"), "policy_id must be omitted: {s}");
640        assert!(
641            !s.contains("tool_signature"),
642            "tool_signature must be omitted: {s}"
643        );
644    }
645}