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