Skip to main content

apl_core/
route.rs

1// Location: ./crates/apl-core/src/route.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Phase orchestration: runs `args → pre_invocation → result → post_invocation` against a
7// `CompiledRoute` and a mutable payload, returning a unified decision plus
8// accumulated taints.
9//
10// This is the entry point apl-cpex calls into. Each phase has its own
11// evaluator (see `evaluator.rs`); this module's job is to drive them in
12// the right order with the right transitions (apply field mutations, halt
13// on deny, thread taints across phases).
14//
15// Phase semantics (anchored in apl-dsl-spec.md §3):
16//   - args: walk field rules; Replace/Omit mutate `payload.args`; Deny halts
17//   - policy: walk steps; Deny halts
18//   - result: only runs if `payload.result.is_some()`; same as args
19//   - post_policy: walks steps; the spec leaves room for "observed only"
20//     handling, but apl-core surfaces the deny — the host (apl-cpex) chooses
21//     whether to enforce it
22//
23// Missing fields are skipped silently — a pipeline can't transform what
24// isn't there. If a route needs to require presence, that's a policy-phase
25// `require(exists(args.X))` rule.
26
27use std::sync::Arc;
28
29use crate::attributes::AttributeBag;
30use crate::evaluator::{evaluate_effects, evaluate_pipeline, Decision, FieldOutcome};
31use crate::pipeline::TaintEvent;
32use crate::rules::CompiledRoute;
33use crate::step::{
34    DelegationInvoker, DispatchPhase, ElicitationInvoker, PdpResolver, PluginInvoker,
35};
36
37/// Mutable payload for a route invocation. `args` is the request arguments
38/// object; `result` is the response object (`None` on the inbound path,
39/// `Some` once the tool/resource has produced a value).
40#[derive(Debug, Clone)]
41pub struct RoutePayload {
42    pub args: serde_json::Value,
43    pub result: Option<serde_json::Value>,
44}
45
46impl RoutePayload {
47    pub fn new(args: serde_json::Value) -> Self {
48        Self { args, result: None }
49    }
50
51    pub fn with_result(args: serde_json::Value, result: serde_json::Value) -> Self {
52        Self {
53            args,
54            result: Some(result),
55        }
56    }
57}
58
59/// Full outcome of running all four phases for a route.
60#[derive(Debug, Clone)]
61pub struct RouteDecision {
62    pub decision: Decision,
63    /// Taints accumulated from any phase. Empty unless a pipeline emitted them.
64    pub taints: Vec<TaintEvent>,
65    /// True if any args field was rewritten or omitted.
66    pub args_modified: bool,
67    /// True if any result field was rewritten or omitted.
68    pub result_modified: bool,
69    /// Set when a phase suspended on an unresolved elicitation. `Some`
70    /// means the host must emit JSON-RPC `-32120` (retry) and **not**
71    /// forward — `decision` is `Allow` in that case. The host forwards
72    /// only when `decision` is `Allow` AND `pending.is_none()`. See
73    /// [`crate::step::PendingElicitation`].
74    pub pending: Option<crate::step::PendingElicitation>,
75}
76
77/// Run the **pre-invocation** phases: `args` then `policy`. Used by
78/// orchestrators bound to a `tool_pre_invoke`-style hook — by the time
79/// post-invoke fires, the tool has produced a response, so result/
80/// post_policy belong to [`evaluate_post`].
81///
82/// On a phase Deny, halts and returns immediately. `args_modified` is
83/// set if any args field was rewritten or omitted; `result_modified` is
84/// always `false` (post hasn't run). Taints emitted during args/policy
85/// land in the returned `taints` vec — survive even on a Deny so audit
86/// sees what fired before the halt.
87pub async fn evaluate_pre(
88    route: &CompiledRoute,
89    bag: &mut AttributeBag,
90    payload: &mut RoutePayload,
91    pdp: &Arc<dyn PdpResolver>,
92    plugins: &Arc<dyn PluginInvoker>,
93    delegations: &Arc<dyn DelegationInvoker>,
94    elicitations: &Arc<dyn ElicitationInvoker>,
95) -> RouteDecision {
96    let mut taints: Vec<TaintEvent> = Vec::new();
97    let mut args_modified = false;
98
99    // ----- args -----
100    for rule in &route.args {
101        let Some(current) = get_dotted(&payload.args, &rule.field).cloned() else {
102            continue; // missing field → no pipeline to run
103        };
104        let eval = evaluate_pipeline(
105            &rule.pipeline,
106            &current,
107            bag,
108            plugins,
109            &rule.field,
110            DispatchPhase::Pre,
111        )
112        .await;
113        taints.extend(eval.taints);
114        match eval.outcome {
115            FieldOutcome::Pass => {},
116            FieldOutcome::Replace(new_val) => {
117                if set_dotted(&mut payload.args, &rule.field, new_val) {
118                    args_modified = true;
119                }
120            },
121            FieldOutcome::Omit => {
122                if remove_dotted(&mut payload.args, &rule.field) {
123                    args_modified = true;
124                }
125            },
126            FieldOutcome::Deny {
127                reason,
128                stage_index: _,
129            } => {
130                return RouteDecision {
131                    decision: Decision::Deny {
132                        reason: Some(reason),
133                        rule_source: rule.source.clone(),
134                    },
135                    taints,
136                    args_modified,
137                    result_modified: false,
138                    pending: None,
139                };
140            },
141        }
142    }
143
144    // ----- policy -----
145    let policy_eval = evaluate_effects(
146        &route.policy,
147        bag,
148        pdp,
149        plugins,
150        delegations,
151        elicitations,
152        DispatchPhase::Pre,
153        payload,
154    )
155    .await;
156    // FieldOps inside `do:` may have rewritten args during policy —
157    // surface that to the host the same way as an `args:` pipeline.
158    args_modified |= policy_eval.args_modified;
159    taints.extend(policy_eval.taints);
160    RouteDecision {
161        decision: policy_eval.decision,
162        taints,
163        args_modified,
164        result_modified: false,
165        pending: policy_eval.pending,
166    }
167}
168
169/// Run the **post-invocation** phases: `result` (if a response payload
170/// is present) then `post_policy`. Used by orchestrators bound to a
171/// `tool_post_invoke`-style hook.
172///
173/// On a phase Deny, halts. `result_modified` is set if any result field
174/// was rewritten or omitted; `args_modified` is always `false` (this
175/// function doesn't touch args).
176pub async fn evaluate_post(
177    route: &CompiledRoute,
178    bag: &mut AttributeBag,
179    payload: &mut RoutePayload,
180    pdp: &Arc<dyn PdpResolver>,
181    plugins: &Arc<dyn PluginInvoker>,
182    delegations: &Arc<dyn DelegationInvoker>,
183    elicitations: &Arc<dyn ElicitationInvoker>,
184) -> RouteDecision {
185    let mut taints: Vec<TaintEvent> = Vec::new();
186    let mut result_modified = false;
187
188    // ----- result (only when a response payload is present) -----
189    if let Some(result) = payload.result.as_mut() {
190        for rule in &route.result {
191            let Some(current) = get_dotted(result, &rule.field).cloned() else {
192                continue;
193            };
194            let eval = evaluate_pipeline(
195                &rule.pipeline,
196                &current,
197                bag,
198                plugins,
199                &rule.field,
200                DispatchPhase::Post,
201            )
202            .await;
203            taints.extend(eval.taints);
204            match eval.outcome {
205                FieldOutcome::Pass => {},
206                FieldOutcome::Replace(new_val) => {
207                    if set_dotted(result, &rule.field, new_val) {
208                        result_modified = true;
209                    }
210                },
211                FieldOutcome::Omit => {
212                    if remove_dotted(result, &rule.field) {
213                        result_modified = true;
214                    }
215                },
216                FieldOutcome::Deny {
217                    reason,
218                    stage_index: _,
219                } => {
220                    return RouteDecision {
221                        decision: Decision::Deny {
222                            reason: Some(reason),
223                            rule_source: rule.source.clone(),
224                        },
225                        taints,
226                        args_modified: false,
227                        result_modified,
228                        pending: None,
229                    };
230                },
231            }
232        }
233    }
234
235    // ----- post_policy -----
236    let post_eval = evaluate_effects(
237        &route.post_policy,
238        bag,
239        pdp,
240        plugins,
241        delegations,
242        elicitations,
243        DispatchPhase::Post,
244        payload,
245    )
246    .await;
247    // Same reason as the policy phase: a `do:`-embedded FieldOp may
248    // have rewritten result fields during post_policy.
249    result_modified |= post_eval.result_modified;
250    taints.extend(post_eval.taints);
251
252    RouteDecision {
253        decision: post_eval.decision,
254        taints,
255        args_modified: false,
256        result_modified,
257        pending: post_eval.pending,
258    }
259}
260
261/// Run all four phases against `payload`, mutating it in place.
262/// Convenience wrapper for callers that don't need the pre/post split
263/// (tests, single-hook hosts). Calls [`evaluate_pre`] then [`evaluate_post`],
264/// skipping post entirely on a pre-side Deny. Taints from both halves
265/// concatenate; `args_modified` and `result_modified` carry their
266/// respective flags independently.
267///
268/// Orchestrators that need to fire on distinct pre/post hooks should
269/// call [`evaluate_pre`] and [`evaluate_post`] separately so the post
270/// half sees the payload after the tool has produced its response.
271pub async fn evaluate_route(
272    route: &CompiledRoute,
273    bag: &mut AttributeBag,
274    payload: &mut RoutePayload,
275    pdp: &Arc<dyn PdpResolver>,
276    plugins: &Arc<dyn PluginInvoker>,
277    delegations: &Arc<dyn DelegationInvoker>,
278    elicitations: &Arc<dyn ElicitationInvoker>,
279) -> RouteDecision {
280    let pre = evaluate_pre(route, bag, payload, pdp, plugins, delegations, elicitations).await;
281    // Halt before the tool call on a pre-side Deny OR a pending
282    // elicitation. Pending means the inbound phase suspended awaiting a
283    // human — the tool must not run, so `post` (which processes the tool's
284    // response) is skipped and the host emits `-32120` from `pre.pending`.
285    if matches!(pre.decision, Decision::Deny { .. }) || pre.pending.is_some() {
286        return pre;
287    }
288    let post = evaluate_post(route, bag, payload, pdp, plugins, delegations, elicitations).await;
289    let mut taints = pre.taints;
290    taints.extend(post.taints);
291    RouteDecision {
292        decision: post.decision,
293        taints,
294        args_modified: pre.args_modified,
295        result_modified: post.result_modified,
296        pending: post.pending,
297    }
298}
299
300// =====================================================================
301// Dotted-path JSON helpers
302// =====================================================================
303
304/// Read `root.a.b.c` from a JSON value via dot-separated path. Returns
305/// `None` if any segment is missing or the path crosses a non-object.
306pub(crate) fn get_dotted<'a>(
307    root: &'a serde_json::Value,
308    path: &str,
309) -> Option<&'a serde_json::Value> {
310    let mut cur = root;
311    for seg in path.split('.') {
312        cur = cur.get(seg)?;
313    }
314    Some(cur)
315}
316
317/// Write to `root.a.b.c` via dot-separated path. Returns true on success;
318/// false if the parent path doesn't exist or doesn't resolve to an object.
319/// Does not create missing parent objects — that'd hide schema bugs.
320pub(crate) fn set_dotted(
321    root: &mut serde_json::Value,
322    path: &str,
323    value: serde_json::Value,
324) -> bool {
325    let parts: Vec<&str> = path.split('.').collect();
326    let (leaf, parents) = match parts.split_last() {
327        Some(x) => x,
328        None => return false,
329    };
330    let mut cur = root;
331    for seg in parents {
332        let Some(next) = cur.get_mut(*seg) else {
333            return false;
334        };
335        if !next.is_object() {
336            return false;
337        }
338        cur = next;
339    }
340    if let serde_json::Value::Object(map) = cur {
341        map.insert((*leaf).to_string(), value);
342        true
343    } else {
344        false
345    }
346}
347
348/// Remove `root.a.b.c` from a JSON value. Returns true if removal happened.
349pub(crate) fn remove_dotted(root: &mut serde_json::Value, path: &str) -> bool {
350    let parts: Vec<&str> = path.split('.').collect();
351    let (leaf, parents) = match parts.split_last() {
352        Some(x) => x,
353        None => return false,
354    };
355    let mut cur = root;
356    for seg in parents {
357        let Some(next) = cur.get_mut(*seg) else {
358            return false;
359        };
360        if !next.is_object() {
361            return false;
362        }
363        cur = next;
364    }
365    if let serde_json::Value::Object(map) = cur {
366        map.remove(*leaf).is_some()
367    } else {
368        false
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::pipeline::{FieldRule, Pipeline, Stage, TaintScope, TypeCheck};
376    use crate::rules::{Effect, Expression, Rule};
377    use crate::step::{
378        NoopDelegationInvoker, NoopElicitationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError,
379        PluginError, PluginInvocation, PluginOutcome,
380    };
381    use async_trait::async_trait;
382    use serde_json::json;
383
384    // ----- Fixtures -----
385
386    struct AllowPdp;
387    #[async_trait]
388    impl PdpResolver for AllowPdp {
389        fn dialect(&self) -> PdpDialect {
390            PdpDialect::Cedar
391        }
392        async fn evaluate(
393            &self,
394            _call: &PdpCall,
395            _bag: &AttributeBag,
396        ) -> Result<PdpDecision, PdpError> {
397            Ok(PdpDecision {
398                decision: Decision::Allow,
399                diagnostics: vec![],
400            })
401        }
402    }
403
404    struct NoPlugins;
405    #[async_trait]
406    impl PluginInvoker for NoPlugins {
407        async fn invoke(
408            &self,
409            name: &str,
410            _bag: &AttributeBag,
411            _invocation: PluginInvocation<'_>,
412        ) -> Result<PluginOutcome, PluginError> {
413            Err(PluginError::NotFound(name.into()))
414        }
415    }
416
417    /// Elicitation invoker that always reports Pending — for the
418    /// route-level suspend test.
419    struct PendingElicitor;
420    #[async_trait]
421    impl ElicitationInvoker for PendingElicitor {
422        async fn dispatch(
423            &self,
424            _step: &crate::step::ElicitStep,
425            _resolved_from: &str,
426        ) -> Result<crate::step::ElicitationDispatch, crate::step::ElicitationError> {
427            Ok(crate::step::ElicitationDispatch {
428                id: "elic-route-1".into(),
429                approver: None,
430                intent_id: None,
431                expires_at: None,
432            })
433        }
434        async fn check(
435            &self,
436            _step: &crate::step::ElicitStep,
437            _id: &str,
438        ) -> Result<crate::step::ElicitationStatus, crate::step::ElicitationError> {
439            Ok(crate::step::ElicitationStatus::Pending)
440        }
441        async fn validate(
442            &self,
443            _step: &crate::step::ElicitStep,
444            _id: &str,
445        ) -> Result<crate::step::ElicitationValidation, crate::step::ElicitationError> {
446            unreachable!("validate must not run while pending")
447        }
448    }
449
450    // `evaluate_route` takes `&Arc<dyn PluginInvoker>` / `&Arc<dyn DelegationInvoker>`
451    // so the path through `dispatch_parallel` can `Arc::clone` into each
452    // spawned branch. These helpers wrap the no-op test stubs once per call.
453    fn pdp_arc() -> Arc<dyn PdpResolver> {
454        Arc::new(AllowPdp)
455    }
456    fn plugins() -> Arc<dyn PluginInvoker> {
457        Arc::new(NoPlugins)
458    }
459    fn delegations() -> Arc<dyn DelegationInvoker> {
460        Arc::new(NoopDelegationInvoker)
461    }
462    fn elicitations() -> Arc<dyn ElicitationInvoker> {
463        Arc::new(NoopElicitationInvoker)
464    }
465
466    fn field_rule(field: &str, stages: Vec<Stage>) -> FieldRule {
467        FieldRule {
468            field: field.into(),
469            pipeline: Pipeline { stages },
470            source: format!("test.{}", field),
471        }
472    }
473
474    fn deny_rule(source: &str, reason: &str) -> Rule {
475        Rule::single(
476            Expression::Always,
477            Effect::Deny {
478                reason: Some(reason.into()),
479                code: None,
480            },
481            source,
482        )
483    }
484
485    // ----- Tests -----
486
487    #[tokio::test]
488    async fn empty_route_allows() {
489        let route = CompiledRoute::new("noop");
490        let mut bag = AttributeBag::new();
491        let mut payload = RoutePayload::new(json!({}));
492        let r = evaluate_route(
493            &route,
494            &mut bag,
495            &mut payload,
496            &pdp_arc(),
497            &plugins(),
498            &delegations(),
499            &elicitations(),
500        )
501        .await;
502        assert_eq!(r.decision, Decision::Allow);
503        assert!(!r.args_modified);
504        assert!(!r.result_modified);
505        assert!(r.taints.is_empty());
506    }
507
508    #[tokio::test]
509    async fn pending_elicitation_suspends_route_and_skips_post() {
510        // A pending elicitation in `policy:` must suspend the whole route:
511        // decision Allow + pending Some, and the `result:` phase (which
512        // would mutate the response) must NOT run.
513        let mut route = CompiledRoute::new("payroll");
514        route.policy.push(Effect::Elicit(crate::step::ElicitStep {
515            kind: crate::step::ElicitKind::Approval,
516            plugin_name: "manager-approver".into(),
517            channel: Some("ciba".into()),
518            from: "user.manager".into(),
519            purpose: None,
520            scope: None,
521            timeout: None,
522            config_override: None,
523            on_error: None,
524            source: "payroll.policy[0]".into(),
525        }));
526        // A result rule that WOULD mask — proves post didn't run if untouched.
527        route
528            .result
529            .push(field_rule("ssn", vec![Stage::Mask { keep_last: 4 }]));
530
531        let elicitor: Arc<dyn ElicitationInvoker> = Arc::new(PendingElicitor);
532        let mut bag = AttributeBag::new();
533        // The step's `from` is an attribute ref — seed it so it resolves
534        // (an unresolved attribute `from` now fails closed by design).
535        bag.set("user.manager", "manager@corp.com");
536        let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123-45-6789" }));
537        let r = evaluate_route(
538            &route,
539            &mut bag,
540            &mut payload,
541            &pdp_arc(),
542            &plugins(),
543            &delegations(),
544            &elicitor,
545        )
546        .await;
547
548        assert_eq!(r.decision, Decision::Allow, "pending is not a deny");
549        let bundle = r.pending.expect("route surfaced the pending bundle");
550        assert_eq!(bundle.id, "elic-route-1");
551        assert_eq!(bundle.plugin_name, "manager-approver");
552        // post never ran → result untouched (no masking applied).
553        assert!(!r.result_modified);
554        assert_eq!(
555            payload.result.as_ref().unwrap()["ssn"],
556            json!("123-45-6789")
557        );
558    }
559
560    #[tokio::test]
561    async fn args_pipeline_mutates_payload() {
562        let mut route = CompiledRoute::new("ping");
563        route
564            .args
565            .push(field_rule("ssn", vec![Stage::Mask { keep_last: 4 }]));
566        let mut bag = AttributeBag::new();
567        let mut payload = RoutePayload::new(json!({ "ssn": "123-45-6789" }));
568        let r = evaluate_route(
569            &route,
570            &mut bag,
571            &mut payload,
572            &pdp_arc(),
573            &plugins(),
574            &delegations(),
575            &elicitations(),
576        )
577        .await;
578        assert_eq!(r.decision, Decision::Allow);
579        assert!(r.args_modified);
580        assert_eq!(payload.args["ssn"], json!("*******6789"));
581    }
582
583    #[tokio::test]
584    async fn args_deny_halts_route() {
585        let mut route = CompiledRoute::new("ping");
586        route.args.push(field_rule(
587            "amount",
588            vec![
589                Stage::Type(TypeCheck::Int),
590                Stage::Range {
591                    min: Some(0),
592                    max: Some(100),
593                },
594            ],
595        ));
596        // Also has a policy rule that would deny — should NOT be reached
597        // (args deny short-circuits). If reached, source would be "policy[0]"
598        // instead of the args rule's source.
599        route
600            .policy
601            .push(Effect::from(deny_rule("policy[0]", "policy denied too")));
602
603        let mut bag = AttributeBag::new();
604        let mut payload = RoutePayload::new(json!({ "amount": 200 }));
605        let r = evaluate_route(
606            &route,
607            &mut bag,
608            &mut payload,
609            &pdp_arc(),
610            &plugins(),
611            &delegations(),
612            &elicitations(),
613        )
614        .await;
615        match r.decision {
616            Decision::Deny { rule_source, .. } => {
617                assert!(
618                    rule_source.contains("amount"),
619                    "expected args rule source, got {}",
620                    rule_source
621                );
622            },
623            d => panic!("expected Deny from args phase, got {:?}", d),
624        }
625    }
626
627    #[tokio::test]
628    async fn args_missing_field_is_skipped() {
629        // Pipeline references `compensation`, payload doesn't have it →
630        // missing-field rule is skipped silently, route allows.
631        let mut route = CompiledRoute::new("ping");
632        route.args.push(field_rule(
633            "compensation",
634            vec![Stage::Type(TypeCheck::Int)],
635        ));
636        let mut bag = AttributeBag::new();
637        let mut payload = RoutePayload::new(json!({ "other_field": 5 }));
638        let r = evaluate_route(
639            &route,
640            &mut bag,
641            &mut payload,
642            &pdp_arc(),
643            &plugins(),
644            &delegations(),
645            &elicitations(),
646        )
647        .await;
648        assert_eq!(r.decision, Decision::Allow);
649        assert!(!r.args_modified);
650    }
651
652    #[tokio::test]
653    async fn args_omit_drops_field() {
654        let mut route = CompiledRoute::new("ping");
655        route.args.push(field_rule("secret", vec![Stage::Omit]));
656        let mut bag = AttributeBag::new();
657        let mut payload = RoutePayload::new(json!({ "secret": "xyz", "keep": 1 }));
658        let r = evaluate_route(
659            &route,
660            &mut bag,
661            &mut payload,
662            &pdp_arc(),
663            &plugins(),
664            &delegations(),
665            &elicitations(),
666        )
667        .await;
668        assert_eq!(r.decision, Decision::Allow);
669        assert!(r.args_modified);
670        assert!(payload.args.get("secret").is_none());
671        assert_eq!(payload.args["keep"], json!(1));
672    }
673
674    #[tokio::test]
675    async fn policy_deny_halts_before_result() {
676        let mut route = CompiledRoute::new("ping");
677        route
678            .policy
679            .push(Effect::from(deny_rule("policy[0]", "blocked")));
680        // Result rule should never run.
681        route
682            .result
683            .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
684
685        let mut bag = AttributeBag::new();
686        let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" }));
687        let r = evaluate_route(
688            &route,
689            &mut bag,
690            &mut payload,
691            &pdp_arc(),
692            &plugins(),
693            &delegations(),
694            &elicitations(),
695        )
696        .await;
697        match r.decision {
698            Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "policy[0]"),
699            d => panic!("expected policy deny, got {:?}", d),
700        }
701        assert!(!r.result_modified);
702        // Result payload not mutated — redact didn't run.
703        assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("123"));
704    }
705
706    #[tokio::test]
707    async fn result_phase_skipped_when_no_response() {
708        let mut route = CompiledRoute::new("ping");
709        route
710            .result
711            .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
712        let mut bag = AttributeBag::new();
713        let mut payload = RoutePayload::new(json!({})); // no result
714        let r = evaluate_route(
715            &route,
716            &mut bag,
717            &mut payload,
718            &pdp_arc(),
719            &plugins(),
720            &delegations(),
721            &elicitations(),
722        )
723        .await;
724        assert_eq!(r.decision, Decision::Allow);
725        assert!(!r.result_modified);
726    }
727
728    #[tokio::test]
729    async fn result_pipeline_redacts_field() {
730        let mut route = CompiledRoute::new("ping");
731        route
732            .result
733            .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
734        let mut bag = AttributeBag::new();
735        let mut payload =
736            RoutePayload::with_result(json!({}), json!({ "ssn": "123-45-6789", "name": "alice" }));
737        let r = evaluate_route(
738            &route,
739            &mut bag,
740            &mut payload,
741            &pdp_arc(),
742            &plugins(),
743            &delegations(),
744            &elicitations(),
745        )
746        .await;
747        assert_eq!(r.decision, Decision::Allow);
748        assert!(r.result_modified);
749        let result = payload.result.as_ref().unwrap();
750        assert_eq!(result["ssn"], json!("[REDACTED]"));
751        assert_eq!(result["name"], json!("alice"));
752    }
753
754    #[tokio::test]
755    async fn taints_accumulate_across_phases() {
756        let mut route = CompiledRoute::new("ping");
757        // args emits a taint
758        route.args.push(field_rule(
759            "input",
760            vec![Stage::Taint {
761                label: "args_seen".into(),
762                scopes: vec![TaintScope::Session],
763            }],
764        ));
765        // result emits a different taint
766        route.result.push(field_rule(
767            "output",
768            vec![Stage::Taint {
769                label: "result_seen".into(),
770                scopes: vec![TaintScope::Message],
771            }],
772        ));
773        let mut bag = AttributeBag::new();
774        let mut payload =
775            RoutePayload::with_result(json!({ "input": "hello" }), json!({ "output": "world" }));
776        let r = evaluate_route(
777            &route,
778            &mut bag,
779            &mut payload,
780            &pdp_arc(),
781            &plugins(),
782            &delegations(),
783            &elicitations(),
784        )
785        .await;
786        assert_eq!(r.decision, Decision::Allow);
787        let labels: Vec<&str> = r.taints.iter().map(|t| t.label.as_str()).collect();
788        assert_eq!(labels, vec!["args_seen", "result_seen"]);
789    }
790
791    #[tokio::test]
792    async fn nested_field_path_resolves_and_writes() {
793        let mut route = CompiledRoute::new("ping");
794        route.args.push(field_rule(
795            "user.profile.ssn",
796            vec![Stage::Mask { keep_last: 4 }],
797        ));
798        let mut bag = AttributeBag::new();
799        let mut payload = RoutePayload::new(json!({
800            "user": { "profile": { "ssn": "123-45-6789", "name": "alice" } }
801        }));
802        let r = evaluate_route(
803            &route,
804            &mut bag,
805            &mut payload,
806            &pdp_arc(),
807            &plugins(),
808            &delegations(),
809            &elicitations(),
810        )
811        .await;
812        assert_eq!(r.decision, Decision::Allow);
813        assert!(r.args_modified);
814        assert_eq!(payload.args["user"]["profile"]["ssn"], json!("*******6789"));
815        assert_eq!(payload.args["user"]["profile"]["name"], json!("alice"));
816    }
817
818    #[tokio::test]
819    async fn nested_field_missing_intermediate_is_skipped() {
820        let mut route = CompiledRoute::new("ping");
821        route.args.push(field_rule(
822            "user.profile.ssn",
823            vec![Stage::Mask { keep_last: 4 }],
824        ));
825        let mut bag = AttributeBag::new();
826        // `profile` segment is missing → get_dotted returns None → skip.
827        let mut payload = RoutePayload::new(json!({ "user": { "name": "alice" } }));
828        let r = evaluate_route(
829            &route,
830            &mut bag,
831            &mut payload,
832            &pdp_arc(),
833            &plugins(),
834            &delegations(),
835            &elicitations(),
836        )
837        .await;
838        assert_eq!(r.decision, Decision::Allow);
839        assert!(!r.args_modified);
840    }
841
842    #[tokio::test]
843    async fn post_policy_runs_after_result() {
844        let mut route = CompiledRoute::new("ping");
845        // Result mutates a field, then post_policy denies.
846        route
847            .result
848            .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
849        route
850            .post_policy
851            .push(Effect::from(deny_rule("post_policy[0]", "after-the-fact")));
852
853        let mut bag = AttributeBag::new();
854        let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" }));
855        let r = evaluate_route(
856            &route,
857            &mut bag,
858            &mut payload,
859            &pdp_arc(),
860            &plugins(),
861            &delegations(),
862            &elicitations(),
863        )
864        .await;
865        match r.decision {
866            Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "post_policy[0]"),
867            d => panic!("expected post_policy deny, got {:?}", d),
868        }
869        // Result was still mutated before the post_policy deny fired.
870        assert!(r.result_modified);
871        assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]"));
872    }
873
874    // ----- Helper unit tests -----
875
876    #[test]
877    fn dotted_get_simple_and_nested() {
878        let v = json!({ "a": { "b": { "c": 7 } } });
879        assert_eq!(get_dotted(&v, "a.b.c"), Some(&json!(7)));
880        assert_eq!(get_dotted(&v, "a.b"), Some(&json!({ "c": 7 })));
881        assert!(get_dotted(&v, "a.b.x").is_none());
882        assert!(get_dotted(&v, "missing").is_none());
883    }
884
885    #[test]
886    fn dotted_set_overwrites_leaf() {
887        let mut v = json!({ "a": { "b": 1 } });
888        assert!(set_dotted(&mut v, "a.b", json!(99)));
889        assert_eq!(v["a"]["b"], json!(99));
890    }
891
892    #[test]
893    fn dotted_set_does_not_create_missing_parents() {
894        // Strict: if `a.b` parent doesn't exist, set fails (no auto-vivify).
895        let mut v = json!({});
896        assert!(!set_dotted(&mut v, "a.b", json!(1)));
897        assert_eq!(v, json!({}));
898    }
899
900    #[test]
901    fn dotted_remove_leaf() {
902        let mut v = json!({ "a": { "b": 1, "c": 2 } });
903        assert!(remove_dotted(&mut v, "a.b"));
904        assert_eq!(v, json!({ "a": { "c": 2 } }));
905        // Removing a missing leaf returns false.
906        assert!(!remove_dotted(&mut v, "a.b"));
907    }
908
909    // ----- evaluate_pre / evaluate_post (phase split) -----
910
911    #[tokio::test]
912    async fn evaluate_pre_runs_args_and_policy_only() {
913        // Route with both args validators + result transforms. evaluate_pre
914        // should run args (mutating payload.args), policy (allow here),
915        // but NOT result — payload.result stays exactly as given.
916        let mut route = CompiledRoute::new("test");
917        route
918            .args
919            .push(field_rule("id", vec![Stage::Mask { keep_last: 2 }]));
920        route
921            .result
922            .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
923
924        let mut payload =
925            RoutePayload::with_result(json!({ "id": "ABCDEFGH" }), json!({ "ssn": "555-12-3456" }));
926        let mut bag = AttributeBag::new();
927        let r = evaluate_pre(
928            &route,
929            &mut bag,
930            &mut payload,
931            &pdp_arc(),
932            &plugins(),
933            &delegations(),
934            &elicitations(),
935        )
936        .await;
937        assert_eq!(r.decision, Decision::Allow);
938        assert!(
939            r.args_modified,
940            "args mask stage should have rewritten the field"
941        );
942        assert!(!r.result_modified, "evaluate_pre must not touch result");
943        // Args was rewritten by mask(2).
944        assert_eq!(payload.args["id"], json!("******GH"));
945        // Result is untouched — post hasn't run.
946        assert_eq!(
947            payload.result.as_ref().unwrap()["ssn"],
948            json!("555-12-3456")
949        );
950    }
951
952    #[tokio::test]
953    async fn evaluate_post_runs_result_and_post_policy_only() {
954        // Route with args + result. evaluate_post skips args entirely
955        // (no mutation), runs result + post_policy.
956        let mut route = CompiledRoute::new("test");
957        route
958            .args
959            .push(field_rule("id", vec![Stage::Mask { keep_last: 2 }]));
960        route
961            .result
962            .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
963
964        let mut payload =
965            RoutePayload::with_result(json!({ "id": "ABCDEFGH" }), json!({ "ssn": "555-12-3456" }));
966        let mut bag = AttributeBag::new();
967        let r = evaluate_post(
968            &route,
969            &mut bag,
970            &mut payload,
971            &pdp_arc(),
972            &plugins(),
973            &delegations(),
974            &elicitations(),
975        )
976        .await;
977        assert_eq!(r.decision, Decision::Allow);
978        assert!(!r.args_modified, "evaluate_post must not touch args");
979        assert!(r.result_modified, "result redact should have fired");
980        // Args is untouched by evaluate_post.
981        assert_eq!(payload.args["id"], json!("ABCDEFGH"));
982        // Result was redacted.
983        assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]"));
984    }
985
986    #[tokio::test]
987    async fn evaluate_pre_deny_halts_before_policy() {
988        // Args has a type validator that fails → pre denies before policy runs.
989        let mut route = CompiledRoute::new("test");
990        route
991            .args
992            .push(field_rule("id", vec![Stage::Type(TypeCheck::Uuid)]));
993        // Policy that would always deny if it ran — assert it doesn't.
994        route.policy.push(Effect::from(Rule::single(
995            Expression::Always,
996            Effect::Deny {
997                reason: Some("policy_should_not_run".into()),
998                code: None,
999            },
1000            "test.policy[0]",
1001        )));
1002
1003        let mut payload = RoutePayload::new(json!({ "id": "not-a-uuid" }));
1004        let mut bag = AttributeBag::new();
1005        let r = evaluate_pre(
1006            &route,
1007            &mut bag,
1008            &mut payload,
1009            &pdp_arc(),
1010            &plugins(),
1011            &delegations(),
1012            &elicitations(),
1013        )
1014        .await;
1015        match r.decision {
1016            Decision::Deny { rule_source, .. } => {
1017                assert!(
1018                    rule_source.contains("test.id"),
1019                    "args denial got source {}",
1020                    rule_source
1021                );
1022            },
1023            d => panic!("expected args-side Deny, got {:?}", d),
1024        }
1025    }
1026
1027    #[tokio::test]
1028    async fn evaluate_route_skips_post_on_pre_deny() {
1029        // Wrapper preserves "deny halts before post" — proves the
1030        // refactor didn't regress evaluate_route's semantics.
1031        let mut route = CompiledRoute::new("test");
1032        route.policy.push(Effect::from(Rule::single(
1033            Expression::Always,
1034            Effect::Deny {
1035                reason: Some("policy_deny".into()),
1036                code: None,
1037            },
1038            "test.policy[0]",
1039        )));
1040        route
1041            .result
1042            .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
1043        route.post_policy.push(Effect::Taint {
1044            label: "should_not_emit".into(),
1045            scopes: vec![TaintScope::Session],
1046        });
1047
1048        let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "555-12-3456" }));
1049        let mut bag = AttributeBag::new();
1050        let r = evaluate_route(
1051            &route,
1052            &mut bag,
1053            &mut payload,
1054            &pdp_arc(),
1055            &plugins(),
1056            &delegations(),
1057            &elicitations(),
1058        )
1059        .await;
1060        assert!(matches!(r.decision, Decision::Deny { .. }));
1061        assert!(!r.result_modified, "post must be skipped on pre-side Deny");
1062        // post_policy never ran, so its taint never landed.
1063        assert!(r.taints.is_empty());
1064        // Result untouched.
1065        assert_eq!(
1066            payload.result.as_ref().unwrap()["ssn"],
1067            json!("555-12-3456")
1068        );
1069    }
1070}