Skip to main content

axonflow_sdk_rust/
pep.rs

1//! Decision Mode PEP (Policy Enforcement Point) contract: **decide → fulfill →
2//! forward** (ADR-056, epic #2563).
3//!
4//!   - decide:  ask the PDP (`POST /api/v1/decide`) for a verdict on a request.
5//!   - fulfill: for every obligation the verdict carries, call the ENGINE
6//!     endpoint named in the obligation's `fulfillment` block to obtain
7//!     engine-redacted content.
8//!   - forward: forward the (possibly redacted) content, or block, per verdict.
9//!
10//! The structural guarantee #2563 demands: a PEP built on this SDK contains NO
11//! redaction logic of its own. There is no regex, no pattern table, no masking
12//! branch. The ONLY way it discharges a `redact_pii` obligation is by POSTing
13//! the source content to the engine endpoint the obligation names
14//! ([`AxonFlowClient::fulfill_request`] / [`AxonFlowClient::decide_and_fulfill`])
15//! and forwarding what the engine returns. If an obligation arrives without a
16//! fulfillable engine endpoint — or the engine reports the redactor did not run —
17//! the helper returns [`AxonFlowError::ObligationNotFulfillable`] and the caller
18//! MUST fail closed (block), never forward unredacted.
19//!
20//! This mirrors `platform/shared/pep` (the Go reference PEP) so the SDK PEP
21//! cannot reimplement redaction the way a hand-rolled regex would.
22
23use crate::client::AxonFlowClient;
24use crate::error::AxonFlowError;
25use crate::types::pep::{
26    DecideRequest, DecideResponse, MCPCheckInputRequest, MCPCheckInputResponse, Obligation,
27};
28
29// --- Obligation contract constants (mirror platform/agent/decision_handler.go) ---
30
31/// The obligation a PEP discharges by replacing request content with
32/// engine-redacted content before forwarding.
33pub const OBLIGATION_REDACT_PII: &str = "redact_pii";
34
35/// Fulfillment phase: pre-call. `/decide` runs pre-call so it only emits
36/// request-phase obligations.
37pub const PHASE_REQUEST: &str = "request";
38/// Fulfillment phase: post-call. Part of the contract for PEP helpers that fan
39/// out to the response-redaction endpoint after the backend call.
40pub const PHASE_RESPONSE: &str = "response";
41
42/// The only redaction content-type wired today. The contract is content-type
43/// agnostic — a PEP holding content of a type not advertised by an obligation's
44/// `content_types` must fail closed rather than forward it unredacted.
45pub const CONTENT_TYPE_TEXT: &str = "text/plain";
46
47// --- Verdict values returned by the PDP ---
48
49/// Allow verdict — forward the (possibly redacted) content.
50pub const VERDICT_ALLOW: &str = "allow";
51/// Deny verdict — block.
52pub const VERDICT_DENY: &str = "deny";
53/// Needs-approval verdict — route to HITL; do not forward.
54pub const VERDICT_NEEDS_APPROVAL: &str = "needs_approval";
55
56// --- Engine endpoints a PEP will POST content to for fulfillment ---
57
58/// The PDP verdict endpoint.
59pub const DECIDE_PATH: &str = "/api/v1/decide";
60/// The request-phase redaction engine endpoint. An obligation whose fulfillment
61/// endpoint is not this (or an absolute URL whose path is this) is rejected — a
62/// PEP must not be steered into calling an arbitrary URL by a malformed verdict.
63pub const REQUEST_REDACTION_PATH: &str = "/api/v1/mcp/check-input";
64/// The response-phase redaction engine endpoint.
65pub const RESPONSE_REDACTION_PATH: &str = "/api/v1/mcp/check-output";
66
67/// The synthetic connector tag recorded by the fulfillment endpoint in gateway
68/// / PDP mode, where there is no managed connector. It lets the audit trail
69/// attribute the redaction to the PEP layer (#2563, connector-agnostic gateway).
70pub const GATEWAY_CONNECTOR_TAG: &str = "gateway";
71
72/// Report whether any obligation requires request-phase PII redaction.
73///
74/// Exposed so a PEP can branch ("does this verdict carry work for me?") before
75/// calling [`AxonFlowClient::fulfill_request`].
76pub fn has_request_redaction(obligations: &[Obligation]) -> bool {
77    obligations.iter().any(|o| {
78        o.r#type == OBLIGATION_REDACT_PII
79            && o.fulfillment
80                .as_ref()
81                .is_some_and(|f| f.phase == PHASE_REQUEST)
82    })
83}
84
85/// Report whether `endpoint` is the expected engine path.
86///
87/// Tolerates an absolute URL whose path component matches (some PDPs return a
88/// fully-qualified obligation endpoint); a blank endpoint never matches.
89pub(crate) fn endpoint_path_matches(endpoint: &str, expected: &str) -> bool {
90    let e = endpoint.trim();
91    if e == expected {
92        return true;
93    }
94    if let Some(idx) = e.find("://") {
95        let rest = &e[idx + 3..];
96        if let Some(slash) = rest.find('/') {
97            let mut path = &rest[slash..];
98            if let Some(q) = path.find('?') {
99                path = &path[..q];
100            }
101            return path == expected;
102        }
103    }
104    false
105}
106
107impl AxonFlowClient {
108    /// Ask the PDP for a verdict on a request (`POST /api/v1/decide`).
109    ///
110    /// This is the PDP step of a PEP. `/decide` is a pure decision point: it
111    /// NEVER mutates content. When an allow verdict carries a `redact_pii`
112    /// obligation, discharge it with [`fulfill_request`](Self::fulfill_request)
113    /// (or use the one-call [`decide_and_fulfill`](Self::decide_and_fulfill)) —
114    /// never by redacting locally.
115    ///
116    /// Decision Mode auth is HTTP Basic (org:license), which this client already
117    /// sends on every request. Demo / wrong credentials are refused with HTTP
118    /// 401 → [`AxonFlowError::ApiError`] with `status: 401`. A deny verdict is
119    /// returned in the body with HTTP 200, not as an error.
120    ///
121    /// # Errors
122    ///
123    /// - [`AxonFlowError::ApiError`] with `status: 401` for bad / demo creds.
124    /// - [`AxonFlowError::ApiError`] for other non-2xx responses.
125    /// - [`AxonFlowError::HttpError`] for transport failures.
126    ///
127    /// # Example
128    ///
129    /// ```no_run
130    /// # use axonflow_sdk_rust::{AxonFlowClient, AxonFlowConfig};
131    /// # use axonflow_sdk_rust::DecideRequest;
132    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
133    /// let client = AxonFlowClient::new(
134    ///     AxonFlowConfig::new("http://localhost:8080").with_auth("org", "license"))?;
135    /// let decision = client.decide(DecideRequest::new("tool", "send to a@b.com")).await?;
136    /// println!("{}", decision.verdict);
137    /// # Ok(()) }
138    /// ```
139    pub async fn decide(&self, request: DecideRequest) -> Result<DecideResponse, AxonFlowError> {
140        let url = format!("{}{}", self.endpoint(), DECIDE_PATH);
141        // checked_post_json maps any non-2xx (incl. 401) into ApiError, so a
142        // demo-cred 401 surfaces as ApiError { status: 401, .. }. A deny verdict
143        // is HTTP 200 with verdict="deny" in the body, returned as Ok.
144        let resp = self.checked_post_json(&url, &request).await?;
145        let body = resp.text().await?;
146        let parsed: DecideResponse = serde_json::from_str(&body)?;
147        Ok(parsed)
148    }
149
150    /// Discharge every request-phase `redact_pii` obligation on `decision`.
151    ///
152    /// For each request-phase `redact_pii` obligation, POSTs `statement` to the
153    /// engine endpoint the obligation names (`check-input`) and returns the
154    /// engine-redacted statement to forward.
155    ///
156    /// There is NO code path in which this method redacts locally — fulfillment
157    /// is always the engine round-trip (ADR-056 / #2563).
158    ///
159    /// Returns `(content, did_redact)`. `content` is the engine-redacted
160    /// statement (or the original when no obligation mutates the request).
161    /// `did_redact` reflects whether the ENGINE actually changed the content,
162    /// not merely that an obligation was present.
163    ///
164    /// # Errors
165    ///
166    /// [`AxonFlowError::ObligationNotFulfillable`] when a `redact_pii` obligation
167    /// cannot be discharged through the engine: it named no request-phase
168    /// fulfillment, advertised a content-type the PEP is not holding, named an
169    /// endpoint this client will not call, the engine call failed / returned
170    /// non-200, or the engine reported the redactor did not run
171    /// (`redaction_evaluated=false`). The caller MUST fail closed (block) —
172    /// never forward the original `statement`.
173    pub async fn fulfill_request(
174        &self,
175        decision: &DecideResponse,
176        statement: &str,
177    ) -> Result<(String, bool), AxonFlowError> {
178        let mut redacted = statement.to_string();
179        let mut did_redact = false;
180        for ob in &decision.obligations {
181            if ob.r#type != OBLIGATION_REDACT_PII {
182                // redact_pii is the only content-mutating obligation today;
183                // other types are pass-through by contract.
184                continue;
185            }
186            let fulfillment = match &ob.fulfillment {
187                Some(f) if f.phase == PHASE_REQUEST => f,
188                _ => {
189                    // A redact_pii obligation with no request-phase fulfillment
190                    // cannot be discharged here — fail closed.
191                    return Err(AxonFlowError::ObligationNotFulfillable(
192                        "redact_pii obligation missing request-phase fulfillment".to_string(),
193                    ));
194                }
195            };
196            // Content-type-agnostic check: this client submits text. If the
197            // endpoint advertises content types and text is not one of them,
198            // fail closed — never assume the endpoint can handle our content.
199            if let Some(cts) = &fulfillment.content_types {
200                if !cts.is_empty() && !cts.iter().any(|c| c == CONTENT_TYPE_TEXT) {
201                    return Err(AxonFlowError::ObligationNotFulfillable(format!(
202                        "fulfillment endpoint does not advertise a {CONTENT_TYPE_TEXT} detector"
203                    )));
204                }
205            }
206            if !endpoint_path_matches(&fulfillment.endpoint, REQUEST_REDACTION_PATH) {
207                return Err(AxonFlowError::ObligationNotFulfillable(format!(
208                    "fulfillment endpoint {:?} is not the request-redaction endpoint",
209                    fulfillment.endpoint
210                )));
211            }
212            redacted = self.fulfill_via_check_input(&redacted).await?;
213            if redacted != statement {
214                did_redact = true;
215            }
216        }
217        Ok((redacted, did_redact))
218    }
219
220    /// POST `statement` to the request-redaction engine endpoint and return the
221    /// engine-masked statement.
222    ///
223    /// Fails closed ([`AxonFlowError::ObligationNotFulfillable`]) when the engine
224    /// call errors, the engine returns non-200, or `redaction_evaluated` is
225    /// false — never returns unredacted content under an unfulfillable condition.
226    async fn fulfill_via_check_input(&self, statement: &str) -> Result<String, AxonFlowError> {
227        let req = MCPCheckInputRequest {
228            connector_type: GATEWAY_CONNECTOR_TAG.to_string(),
229            statement: statement.to_string(),
230            operation: Some("execute".to_string()),
231            tenant_id: None,
232            content_type: Some(CONTENT_TYPE_TEXT.to_string()),
233        };
234        let url = format!("{}{}", self.endpoint(), REQUEST_REDACTION_PATH);
235        let result: MCPCheckInputResponse = match self.checked_post_json(&url, &req).await {
236            Ok(resp) => {
237                let body = resp.text().await?;
238                serde_json::from_str(&body).map_err(|e| {
239                    AxonFlowError::ObligationNotFulfillable(format!(
240                        "decode request-redaction engine response: {e}"
241                    ))
242                })?
243            }
244            Err(e) => {
245                return Err(AxonFlowError::ObligationNotFulfillable(format!(
246                    "request-redaction engine call failed: {e}"
247                )));
248            }
249        };
250        // FAIL CLOSED if the redactor did not actually run (#2563 B1). Without
251        // this the PEP cannot distinguish "engine looked, found nothing" (safe to
252        // forward) from "engine wasn't looking" (would leak PII).
253        if !result.redaction_evaluated {
254            return Err(AxonFlowError::ObligationNotFulfillable(
255                "engine reported the redactor did not run (redaction disabled)".to_string(),
256            ));
257        }
258        match (result.redacted, result.redacted_statement) {
259            (true, Some(masked)) if !masked.is_empty() => Ok(masked),
260            // FAIL CLOSED on a self-contradictory engine response: redacted=true
261            // with no (or empty) redacted_statement means the engine claims it
262            // masked something but gave us nothing to forward — never fall back
263            // to the unredacted original.
264            (true, _) => Err(AxonFlowError::ObligationNotFulfillable(
265                "engine reported redacted=true but returned no redacted_statement".to_string(),
266            )),
267            // Redactor ran and found nothing to mask — forward unchanged.
268            (false, _) => Ok(statement.to_string()),
269        }
270    }
271
272    /// One-call PEP path: decide, then fulfill any request-phase obligation.
273    ///
274    /// Returns `(verdict, content, decision)`. Branch on `verdict`: forward
275    /// `content` on `"allow"`; block on `"deny"` / `"needs_approval"`.
276    ///
277    /// On the not-fulfillable path this returns
278    /// [`AxonFlowError::ObligationNotFulfillable`] — a caller that handles the
279    /// error cannot accidentally forward the unredacted query, so fail-closed is
280    /// guaranteed by construction (#2563 L2). The original query is returned as
281    /// `content` only on the non-allow path (where the caller blocks anyway).
282    ///
283    /// # Errors
284    ///
285    /// Propagates [`Self::decide`] errors, and
286    /// [`AxonFlowError::ObligationNotFulfillable`] from [`Self::fulfill_request`].
287    pub async fn decide_and_fulfill(
288        &self,
289        request: DecideRequest,
290    ) -> Result<(String, String, DecideResponse), AxonFlowError> {
291        let query = request.query.clone();
292        let decision = self.decide(request).await?;
293        if decision.verdict != VERDICT_ALLOW {
294            return Ok((decision.verdict.clone(), query, decision));
295        }
296        let (redacted, _) = self.fulfill_request(&decision, &query).await?;
297        Ok((decision.verdict.clone(), redacted, decision))
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::types::pep::ObligationFulfillment;
305    use crate::{AxonFlowConfig, AxonFlowError};
306    use serde_json::json;
307    use std::time::Duration;
308    use wiremock::matchers::{body_partial_json, method, path};
309    use wiremock::{Mock, MockServer, ResponseTemplate};
310
311    fn make_client(endpoint: String) -> AxonFlowClient {
312        let config = AxonFlowConfig {
313            endpoint,
314            client_id: Some("org-1".into()),
315            client_secret: Some("license-1".into()),
316            timeout: Duration::from_secs(2),
317            ..Default::default()
318        };
319        AxonFlowClient::new(config).expect("client init")
320    }
321
322    fn redact_obligation() -> Obligation {
323        Obligation {
324            r#type: OBLIGATION_REDACT_PII.into(),
325            detail: None,
326            fulfillment: Some(ObligationFulfillment {
327                endpoint: REQUEST_REDACTION_PATH.into(),
328                method: "POST".into(),
329                phase: PHASE_REQUEST.into(),
330                content_types: Some(vec![CONTENT_TYPE_TEXT.into()]),
331            }),
332        }
333    }
334
335    fn allow_with(obligations: Vec<Obligation>) -> DecideResponse {
336        DecideResponse {
337            verdict: VERDICT_ALLOW.into(),
338            obligations,
339            ..Default::default()
340        }
341    }
342
343    // ---- endpoint_path_matches ----
344
345    #[test]
346    fn endpoint_path_matches_exact_and_absolute() {
347        assert!(endpoint_path_matches(
348            REQUEST_REDACTION_PATH,
349            REQUEST_REDACTION_PATH
350        ));
351        assert!(endpoint_path_matches(
352            "  /api/v1/mcp/check-input  ",
353            REQUEST_REDACTION_PATH
354        ));
355        assert!(endpoint_path_matches(
356            "https://pdp.internal:8443/api/v1/mcp/check-input",
357            REQUEST_REDACTION_PATH
358        ));
359        assert!(endpoint_path_matches(
360            "https://pdp.internal/api/v1/mcp/check-input?x=1",
361            REQUEST_REDACTION_PATH
362        ));
363    }
364
365    #[test]
366    fn endpoint_path_matches_rejects_foreign() {
367        assert!(!endpoint_path_matches("", REQUEST_REDACTION_PATH));
368        assert!(!endpoint_path_matches(
369            "/api/v1/mcp/check-output",
370            REQUEST_REDACTION_PATH
371        ));
372        assert!(!endpoint_path_matches(
373            "https://evil.example.com/steal",
374            REQUEST_REDACTION_PATH
375        ));
376        // Absolute URL with no path component never matches.
377        assert!(!endpoint_path_matches(
378            "https://pdp.internal",
379            REQUEST_REDACTION_PATH
380        ));
381    }
382
383    // ---- has_request_redaction ----
384
385    #[test]
386    fn has_request_redaction_detects_request_phase() {
387        assert!(has_request_redaction(&[redact_obligation()]));
388    }
389
390    #[test]
391    fn has_request_redaction_ignores_response_phase_and_no_fulfillment() {
392        let resp_phase = Obligation {
393            r#type: OBLIGATION_REDACT_PII.into(),
394            detail: None,
395            fulfillment: Some(ObligationFulfillment {
396                endpoint: RESPONSE_REDACTION_PATH.into(),
397                method: "POST".into(),
398                phase: PHASE_RESPONSE.into(),
399                content_types: None,
400            }),
401        };
402        let no_fulfillment = Obligation {
403            r#type: OBLIGATION_REDACT_PII.into(),
404            detail: None,
405            fulfillment: None,
406        };
407        let other_type = Obligation {
408            r#type: "log_only".into(),
409            detail: None,
410            fulfillment: Some(ObligationFulfillment {
411                endpoint: REQUEST_REDACTION_PATH.into(),
412                method: "POST".into(),
413                phase: PHASE_REQUEST.into(),
414                content_types: None,
415            }),
416        };
417        assert!(!has_request_redaction(&[
418            resp_phase,
419            no_fulfillment,
420            other_type
421        ]));
422        assert!(!has_request_redaction(&[]));
423    }
424
425    // ---- decide: parse ----
426
427    #[tokio::test]
428    async fn decide_parses_allow_with_obligation() {
429        let server = MockServer::start().await;
430        Mock::given(method("POST"))
431            .and(path("/api/v1/decide"))
432            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
433                "verdict": "allow",
434                "decision_id": "dec-1",
435                "trace_id": "04110a0b50577bbbdda23a00dcbaf6da",
436                "obligations": [{
437                    "type": "redact_pii",
438                    "fulfillment": {
439                        "endpoint": "/api/v1/mcp/check-input",
440                        "method": "POST",
441                        "phase": "request",
442                        "content_types": ["text/plain"],
443                    },
444                }],
445                "evaluated_policies": ["sys_pii_email"],
446                "stage": "tool",
447                "expires_at": "2026-06-09T05:05:06.8Z",
448            })))
449            .mount(&server)
450            .await;
451
452        let client = make_client(server.uri());
453        let d = client
454            .decide(DecideRequest::new("tool", "send to a@b.com"))
455            .await
456            .unwrap();
457        assert_eq!(d.verdict, "allow");
458        assert_eq!(d.decision_id.as_deref(), Some("dec-1"));
459        assert_eq!(d.obligations.len(), 1);
460        assert_eq!(d.obligations[0].r#type, "redact_pii");
461        assert!(has_request_redaction(&d.obligations));
462        assert_eq!(d.evaluated_policies, vec!["sys_pii_email"]);
463    }
464
465    #[tokio::test]
466    async fn decide_returns_deny_in_body_not_error() {
467        let server = MockServer::start().await;
468        Mock::given(method("POST"))
469            .and(path("/api/v1/decide"))
470            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
471                "verdict": "deny",
472                "error": "stage is required and must be one of: llm, tool, agent",
473            })))
474            .mount(&server)
475            .await;
476
477        let client = make_client(server.uri());
478        let d = client
479            .decide(DecideRequest::new("", "x"))
480            .await
481            .expect("deny is a 200 body, not an error");
482        assert_eq!(d.verdict, "deny");
483        assert!(d.error.is_some());
484        assert!(d.obligations.is_empty());
485    }
486
487    #[tokio::test]
488    async fn decide_maps_401_to_api_error() {
489        let server = MockServer::start().await;
490        Mock::given(method("POST"))
491            .and(path("/api/v1/decide"))
492            .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
493            .mount(&server)
494            .await;
495
496        let client = make_client(server.uri());
497        let err = client
498            .decide(DecideRequest::new("tool", "x"))
499            .await
500            .unwrap_err();
501        match err {
502            AxonFlowError::ApiError { status, .. } => assert_eq!(status, 401),
503            other => panic!("expected ApiError 401, got {other:?}"),
504        }
505    }
506
507    #[tokio::test]
508    async fn decide_sends_basic_auth_and_body() {
509        let server = MockServer::start().await;
510        Mock::given(method("POST"))
511            .and(path("/api/v1/decide"))
512            .and(body_partial_json(json!({"stage": "tool", "query": "hi"})))
513            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"verdict": "allow"})))
514            .expect(1)
515            .mount(&server)
516            .await;
517
518        let client = make_client(server.uri());
519        let d = client
520            .decide(DecideRequest::new("tool", "hi"))
521            .await
522            .unwrap();
523        assert_eq!(d.verdict, "allow");
524    }
525
526    // ---- fulfill_request: happy path + passthrough ----
527
528    #[tokio::test]
529    async fn fulfill_request_returns_engine_masked_content() {
530        let server = MockServer::start().await;
531        Mock::given(method("POST"))
532            .and(path("/api/v1/mcp/check-input"))
533            .and(body_partial_json(
534                json!({"connector_type": "gateway", "content_type": "text/plain"}),
535            ))
536            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
537                "allowed": true,
538                "redacted": true,
539                "redacted_statement": "Email jo****om and card 4****1",
540                "redaction_evaluated": true,
541            })))
542            .mount(&server)
543            .await;
544
545        let client = make_client(server.uri());
546        let (content, did_redact) = client
547            .fulfill_request(
548                &allow_with(vec![redact_obligation()]),
549                "Email john and card 4111",
550            )
551            .await
552            .unwrap();
553        assert!(did_redact);
554        assert_eq!(content, "Email jo****om and card 4****1");
555    }
556
557    #[tokio::test]
558    async fn fulfill_request_no_obligation_is_passthrough() {
559        // No HTTP mock — must not call the engine at all.
560        let client = make_client("http://127.0.0.1:1".into());
561        let (content, did_redact) = client
562            .fulfill_request(&allow_with(vec![]), "untouched")
563            .await
564            .unwrap();
565        assert!(!did_redact);
566        assert_eq!(content, "untouched");
567    }
568
569    #[tokio::test]
570    async fn fulfill_request_engine_found_nothing_is_passthrough() {
571        let server = MockServer::start().await;
572        Mock::given(method("POST"))
573            .and(path("/api/v1/mcp/check-input"))
574            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
575                "allowed": true,
576                "redacted": false,
577                "redaction_evaluated": true,
578            })))
579            .mount(&server)
580            .await;
581
582        let client = make_client(server.uri());
583        let (content, did_redact) = client
584            .fulfill_request(&allow_with(vec![redact_obligation()]), "no pii here")
585            .await
586            .unwrap();
587        assert!(!did_redact);
588        assert_eq!(content, "no pii here");
589    }
590
591    // ---- fulfill_request: every fail-closed branch ----
592
593    #[tokio::test]
594    async fn fulfill_fails_closed_on_missing_request_phase_fulfillment() {
595        let ob = Obligation {
596            r#type: OBLIGATION_REDACT_PII.into(),
597            detail: None,
598            fulfillment: None,
599        };
600        let client = make_client("http://127.0.0.1:1".into());
601        let err = client
602            .fulfill_request(&allow_with(vec![ob]), "secret a@b.com")
603            .await
604            .unwrap_err();
605        assert!(matches!(err, AxonFlowError::ObligationNotFulfillable(_)));
606    }
607
608    #[tokio::test]
609    async fn fulfill_fails_closed_on_response_phase_obligation() {
610        let ob = Obligation {
611            r#type: OBLIGATION_REDACT_PII.into(),
612            detail: None,
613            fulfillment: Some(ObligationFulfillment {
614                endpoint: REQUEST_REDACTION_PATH.into(),
615                method: "POST".into(),
616                phase: PHASE_RESPONSE.into(),
617                content_types: None,
618            }),
619        };
620        let client = make_client("http://127.0.0.1:1".into());
621        let err = client
622            .fulfill_request(&allow_with(vec![ob]), "secret a@b.com")
623            .await
624            .unwrap_err();
625        assert!(matches!(err, AxonFlowError::ObligationNotFulfillable(_)));
626    }
627
628    #[tokio::test]
629    async fn fulfill_fails_closed_on_unadvertised_content_type() {
630        let ob = Obligation {
631            r#type: OBLIGATION_REDACT_PII.into(),
632            detail: None,
633            fulfillment: Some(ObligationFulfillment {
634                endpoint: REQUEST_REDACTION_PATH.into(),
635                method: "POST".into(),
636                phase: PHASE_REQUEST.into(),
637                content_types: Some(vec!["image/png".into()]),
638            }),
639        };
640        let client = make_client("http://127.0.0.1:1".into());
641        let err = client
642            .fulfill_request(&allow_with(vec![ob]), "secret a@b.com")
643            .await
644            .unwrap_err();
645        assert!(matches!(err, AxonFlowError::ObligationNotFulfillable(_)));
646    }
647
648    #[tokio::test]
649    async fn fulfill_fails_closed_on_foreign_endpoint() {
650        let ob = Obligation {
651            r#type: OBLIGATION_REDACT_PII.into(),
652            detail: None,
653            fulfillment: Some(ObligationFulfillment {
654                endpoint: "https://evil.example.com/steal".into(),
655                method: "POST".into(),
656                phase: PHASE_REQUEST.into(),
657                content_types: Some(vec![CONTENT_TYPE_TEXT.into()]),
658            }),
659        };
660        let client = make_client("http://127.0.0.1:1".into());
661        let err = client
662            .fulfill_request(&allow_with(vec![ob]), "secret a@b.com")
663            .await
664            .unwrap_err();
665        assert!(matches!(err, AxonFlowError::ObligationNotFulfillable(_)));
666    }
667
668    #[tokio::test]
669    async fn fulfill_fails_closed_on_engine_error() {
670        let server = MockServer::start().await;
671        Mock::given(method("POST"))
672            .and(path("/api/v1/mcp/check-input"))
673            .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
674            .mount(&server)
675            .await;
676
677        let client = make_client(server.uri());
678        let err = client
679            .fulfill_request(&allow_with(vec![redact_obligation()]), "secret a@b.com")
680            .await
681            .unwrap_err();
682        assert!(matches!(err, AxonFlowError::ObligationNotFulfillable(_)));
683    }
684
685    #[tokio::test]
686    async fn fulfill_fails_closed_when_redaction_evaluated_false() {
687        let server = MockServer::start().await;
688        Mock::given(method("POST"))
689            .and(path("/api/v1/mcp/check-input"))
690            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
691                "allowed": true,
692                "redacted": false,
693                "redaction_evaluated": false,
694            })))
695            .mount(&server)
696            .await;
697
698        let client = make_client(server.uri());
699        let err = client
700            .fulfill_request(&allow_with(vec![redact_obligation()]), "secret a@b.com")
701            .await
702            .unwrap_err();
703        assert!(matches!(err, AxonFlowError::ObligationNotFulfillable(_)));
704    }
705
706    #[tokio::test]
707    async fn fulfill_fails_closed_when_redacted_true_without_statement() {
708        // Self-contradictory engine response: redacted=true but no
709        // redacted_statement -> must fail closed, never forward the original.
710        let server = MockServer::start().await;
711        Mock::given(method("POST"))
712            .and(path("/api/v1/mcp/check-input"))
713            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
714                "allowed": true,
715                "redacted": true,
716                "redaction_evaluated": true,
717            })))
718            .mount(&server)
719            .await;
720
721        let client = make_client(server.uri());
722        let err = client
723            .fulfill_request(&allow_with(vec![redact_obligation()]), "secret a@b.com")
724            .await
725            .unwrap_err();
726        assert!(matches!(err, AxonFlowError::ObligationNotFulfillable(_)));
727    }
728
729    #[tokio::test]
730    async fn fulfill_fails_closed_when_redaction_evaluated_absent() {
731        let server = MockServer::start().await;
732        // Field absent entirely -> serde default false -> fail closed.
733        Mock::given(method("POST"))
734            .and(path("/api/v1/mcp/check-input"))
735            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
736                "allowed": true,
737                "redacted": true,
738                "redacted_statement": "Email jo****om",
739            })))
740            .mount(&server)
741            .await;
742
743        let client = make_client(server.uri());
744        let err = client
745            .fulfill_request(&allow_with(vec![redact_obligation()]), "Email john")
746            .await
747            .unwrap_err();
748        assert!(matches!(err, AxonFlowError::ObligationNotFulfillable(_)));
749    }
750
751    #[tokio::test]
752    async fn fulfill_ignores_non_redact_obligation_types() {
753        // A non-redact obligation is pass-through; no engine call, no error.
754        let ob = Obligation {
755            r#type: "audit_only".into(),
756            detail: None,
757            fulfillment: None,
758        };
759        let client = make_client("http://127.0.0.1:1".into());
760        let (content, did_redact) = client
761            .fulfill_request(&allow_with(vec![ob]), "left alone")
762            .await
763            .unwrap();
764        assert!(!did_redact);
765        assert_eq!(content, "left alone");
766    }
767
768    // ---- decide_and_fulfill: allow + deny + unfulfillable ----
769
770    #[tokio::test]
771    async fn decide_and_fulfill_allow_redacts() {
772        let server = MockServer::start().await;
773        Mock::given(method("POST"))
774            .and(path("/api/v1/decide"))
775            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
776                "verdict": "allow",
777                "obligations": [{
778                    "type": "redact_pii",
779                    "fulfillment": {
780                        "endpoint": "/api/v1/mcp/check-input",
781                        "phase": "request",
782                        "content_types": ["text/plain"],
783                    },
784                }],
785            })))
786            .mount(&server)
787            .await;
788        Mock::given(method("POST"))
789            .and(path("/api/v1/mcp/check-input"))
790            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
791                "allowed": true,
792                "redacted": true,
793                "redacted_statement": "card 4****1",
794                "redaction_evaluated": true,
795            })))
796            .mount(&server)
797            .await;
798
799        let client = make_client(server.uri());
800        let (verdict, content, decision) = client
801            .decide_and_fulfill(DecideRequest::new("tool", "card 4111111111111111"))
802            .await
803            .unwrap();
804        assert_eq!(verdict, "allow");
805        assert_eq!(content, "card 4****1");
806        assert_eq!(decision.verdict, "allow");
807        assert!(!content.contains("4111111111111111"));
808    }
809
810    #[tokio::test]
811    async fn decide_and_fulfill_deny_returns_original_without_engine_call() {
812        let server = MockServer::start().await;
813        // Only the decide mock is mounted — a check-input call would 404/error.
814        Mock::given(method("POST"))
815            .and(path("/api/v1/decide"))
816            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
817                "verdict": "deny",
818                "reasons": ["blocked by policy"],
819            })))
820            .mount(&server)
821            .await;
822
823        let client = make_client(server.uri());
824        let (verdict, content, _) = client
825            .decide_and_fulfill(DecideRequest::new("tool", "original query"))
826            .await
827            .unwrap();
828        assert_eq!(verdict, "deny");
829        assert_eq!(content, "original query");
830    }
831
832    #[tokio::test]
833    async fn decide_and_fulfill_unfulfillable_surfaces_error_not_original() {
834        let server = MockServer::start().await;
835        Mock::given(method("POST"))
836            .and(path("/api/v1/decide"))
837            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
838                "verdict": "allow",
839                "obligations": [{
840                    "type": "redact_pii",
841                    "fulfillment": {
842                        "endpoint": "https://evil.example.com/steal",
843                        "phase": "request",
844                    },
845                }],
846            })))
847            .mount(&server)
848            .await;
849
850        let client = make_client(server.uri());
851        let err = client
852            .decide_and_fulfill(DecideRequest::new("tool", "leak me a@b.com"))
853            .await
854            .unwrap_err();
855        // The caller gets the fail-closed signal, never the unredacted query.
856        assert!(matches!(err, AxonFlowError::ObligationNotFulfillable(_)));
857    }
858}