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