Skip to main content

apl_core/
rules.rs

1// Location: ./crates/apl-core/src/rules.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// APL intermediate representation.
7//
8// The compiler (later) produces a `CompiledRoute` per route_key from
9// YAML / database / any other ConfigSource. The evaluator (later)
10// consumes the IR plus an AttributeBag and returns a decision.
11//
12// IR types are kept small and pure-data — no dependencies on cpex-core
13// extensions, no evaluation logic. See docs/specs/apl-design.md §7.
14
15use serde::{Deserialize, Serialize};
16
17/// Comparison operators in DSL predicates.
18///
19/// `In` / `NotIn` are intentionally absent: the DSL spec §2.4 has them as
20/// `value_key in set_key` — both sides are attribute references, not a
21/// key-vs-literal shape. They'll land as a dedicated `Condition` variant
22/// when the parser arrives.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum CompareOp {
26    Eq,
27    NotEq,
28    Gt,
29    GtEq,
30    Lt,
31    LtEq,
32    /// `<set_key> contains <literal>` — left is a StringSet attribute,
33    /// right is a string literal.
34    Contains,
35}
36
37/// Right-hand side of a comparison.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(untagged)]
40pub enum Literal {
41    Bool(bool),
42    Int(i64),
43    Float(f64),
44    String(String),
45}
46
47impl From<bool> for Literal {
48    fn from(v: bool) -> Self {
49        Literal::Bool(v)
50    }
51}
52impl From<i64> for Literal {
53    fn from(v: i64) -> Self {
54        Literal::Int(v)
55    }
56}
57impl From<f64> for Literal {
58    fn from(v: f64) -> Self {
59        Literal::Float(v)
60    }
61}
62impl From<&str> for Literal {
63    fn from(v: &str) -> Self {
64        Literal::String(v.to_string())
65    }
66}
67impl From<String> for Literal {
68    fn from(v: String) -> Self {
69        Literal::String(v)
70    }
71}
72
73/// Leaf predicate.
74///
75/// `Comparison` covers `key op value`. The truthiness checks are split out
76/// (`IsTrue` / `IsFalse`) because they're the most common form — `authenticated`,
77/// `role.hr`, `delegated`.
78///
79/// The DSL's `require(...)` keyword is **not** represented here — it's a
80/// rule-level shorthand for "deny when the condition fails," and the parser
81/// desugars it into `Not` / `And` / `Or` over `IsFalse` expressions plus
82/// an `Action::Deny`. See DSL spec §8.1 desugarings.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub enum Condition {
85    Comparison {
86        key: String,
87        op: CompareOp,
88        value: Literal,
89    },
90    IsTrue {
91        key: String,
92    },
93    IsFalse {
94        key: String,
95    },
96    /// DSL `exists(key)` — true iff the key is present in the
97    /// AttributeBag, regardless of its value. Distinct from `IsTrue`
98    /// (which only succeeds for truthy values). Per DSL §2.2.
99    Exists {
100        key: String,
101    },
102    /// DSL `value_key in set_key` (negate=false) / `value_key not in set_key`
103    /// (negate=true). Both operands are attribute keys, not literals — the
104    /// scalar at `value_key` is checked for membership in the StringSet at
105    /// `set_key`. Per DSL §2.4. Returns `false` if either key is missing or
106    /// the types don't match (scalar must resolve to a string).
107    InSet {
108        value_key: String,
109        set_key: String,
110        negate: bool,
111    },
112}
113
114/// Compound predicate.
115///
116/// `Always` is the implicit-true predicate for bare-effect rules
117/// (DSL §3.1): `- plugin(rate_limiter)` / `- taint(audit)` / unconditional
118/// `- deny` / `- allow`. It's never produced by predicate-string parsing
119/// — only by rule-level forms where no `when:` is supplied.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub enum Expression {
122    Condition(Condition),
123    And(Vec<Expression>),
124    Or(Vec<Expression>),
125    Not(Box<Expression>),
126    Always,
127}
128
129/// One thing a matching rule does. Mirrors DSL spec §3 effect classes:
130///
131///   * Control — `Allow`, `Deny`
132///   * Label — `Taint`
133///   * Host — `Plugin`, `Delegate`
134///
135/// Content effects (`redact`, `mask`, `omit`, `hash`) and orchestration
136/// (`Sequential`, `Parallel`) land in later slices (E2 / E3).
137///
138/// PDP calls (`cedar:(…)`, `opa(…)`, …) remain top-level [`Step`]
139/// variants for now; folding them into `Effect` is an E4 cleanup.
140///
141/// # Inside a `Vec<Effect>` (a rule's `effects` body)
142///
143///   * `Allow` is a no-op — lets evaluation continue to the next effect
144///     in the list, then to the next step in `policy:`.
145///   * `Deny` short-circuits the rest of the list, the rest of the
146///     `policy:` block, and the route. The `reason` propagates into
147///     the violation message.
148///   * `Plugin` / `Delegate` dispatch identically to their top-level
149///     `Step` counterparts (same invoker traits).
150///   * `Taint` accumulates into the phase's taint events.
151///
152/// [`Step`]: crate::step::Step
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum Effect {
156    Allow,
157    Deny {
158        reason: Option<String>,
159        /// Author-supplied stable violation code. When `Some`, it
160        /// overrides the rule's auto-generated source-position code
161        /// (`routes.tool:X.apl.policy[N]`) downstream. Useful when
162        /// MCP clients want to dispatch on category (`quota.exceeded`,
163        /// `delegation.depth_exceeded`) rather than position, or when
164        /// multiple routes share a deny category that should
165        /// aggregate consistently in audit dashboards. When `None`,
166        /// the evaluator falls back to `rule.source` as the code —
167        /// matches the historical behavior.
168        ///
169        /// Parser shape: `deny('reason', 'code')` (two positional
170        /// arguments) or the structured `deny: { reason: ..., code: ... }`
171        /// map form.
172        code: Option<String>,
173    },
174    Plugin {
175        name: String,
176    },
177    Delegate(crate::step::DelegateStep),
178    Taint {
179        label: String,
180        scopes: Vec<crate::pipeline::TaintScope>,
181    },
182    /// Content effect (DSL §3) — apply a pipe chain (`redact`, `mask`,
183    /// `omit`, `hash`, validators, transforms) to a field in the
184    /// route's args or result. The author writes
185    /// `result.salary | redact` inside a `do:` body; the parser
186    /// splits the dotted path from the pipeline.
187    ///
188    /// `path` must start with `args.` or `result.` — the evaluator
189    /// dispatches the lookup against `RoutePayload.args` or
190    /// `RoutePayload.result`. A FieldOp inside a Pre-phase route's
191    /// `do:` that targets `result.X` is a no-op (the result hasn't
192    /// been produced yet); same goes for a Post-phase rule that
193    /// targets `args.X` (the args are already on the wire). The
194    /// evaluator silently skips out-of-phase ops so the same
195    /// `when:`/`do:` shape can describe both phases without
196    /// branching.
197    FieldOp {
198        path: String,
199        stages: Vec<crate::pipeline::Stage>,
200    },
201    /// Run a list of effects in declaration order, stopping on the
202    /// first Deny. Semantically equivalent to inlining the list into
203    /// the enclosing scope; the variant exists to make grouping
204    /// explicit and to pair with `Parallel`.
205    Sequential(Vec<Effect>),
206    /// Run a list of effects concurrently. Any Deny → overall Deny.
207    /// Taints from all branches accumulate. Bag and payload mutations
208    /// inside parallel branches are **discarded** when the branch
209    /// completes — each branch gets a clone of the state, never the
210    /// shared mutable original. Plugins inside `Parallel` can still
211    /// emit taints (those merge); any other mutation they try to make
212    /// (bag writes, args/result rewrites) vanishes.
213    ///
214    /// Config-load rejects `FieldOp` and `Delegate` directly inside
215    /// `Parallel` (recursively), since both would silently drop their
216    /// effect. The escape valve is `Sequential`.
217    Parallel(Vec<Effect>),
218    /// Predicate-gated body. `body` runs in order when `condition`
219    /// evaluates to true; any Deny in the body halts the surrounding
220    /// phase. Replaces the historical `Step::Rule(Rule)` shape —
221    /// `when:` / `do:` directly desugars to this. A bare `require(X)`
222    /// or `deny(X)` shorthand compiles to `When { condition: X,
223    /// body: vec![Effect::Allow / Deny] }`.
224    ///
225    /// `source` is the human-readable origin (e.g. `"routes.X.policy[2]"`)
226    /// surfaced in `Decision::Deny.rule_source` when the body denies
227    /// without supplying its own code.
228    When {
229        condition: Expression,
230        body: Vec<Effect>,
231        source: String,
232    },
233    /// External PDP call. `on_allow` / `on_deny` are reaction effect
234    /// lists fired against the PDP's decision (DSL §7.5). Replaces
235    /// `Step::Pdp { ... }` — `args`-shape stays identical.
236    Pdp {
237        call: crate::step::PdpCall,
238        #[serde(default, skip_serializing_if = "Vec::is_empty")]
239        on_allow: Vec<Effect>,
240        #[serde(default, skip_serializing_if = "Vec::is_empty")]
241        on_deny: Vec<Effect>,
242    },
243}
244
245impl Effect {
246    /// Walk this effect (and any nested effects) checking whether any
247    /// node would mutate route state. Used by the config-load
248    /// validator to reject `FieldOp` / `Delegate` inside `Parallel`
249    /// since both would silently drop their effect in a discarded
250    /// branch.
251    pub fn contains_mutation(&self) -> bool {
252        match self {
253            Effect::FieldOp { .. } | Effect::Delegate(_) => true,
254            Effect::Sequential(effects) | Effect::Parallel(effects) => {
255                effects.iter().any(Effect::contains_mutation)
256            },
257            Effect::When { body, .. } => body.iter().any(Effect::contains_mutation),
258            Effect::Pdp {
259                on_allow, on_deny, ..
260            } => {
261                on_allow.iter().any(Effect::contains_mutation)
262                    || on_deny.iter().any(Effect::contains_mutation)
263            },
264            Effect::Allow | Effect::Deny { .. } | Effect::Plugin { .. } | Effect::Taint { .. } => {
265                false
266            },
267        }
268    }
269
270    /// Walk the effect tree rejecting any `FieldOp` / `Delegate` that
271    /// lives directly or transitively under a `Parallel` node. Returns
272    /// the path string of the first violation found (or `Ok(())` if
273    /// the tree is clean). Run at config-load.
274    pub fn validate_parallel_purity(&self) -> Result<(), String> {
275        match self {
276            Effect::Parallel(effects) => {
277                for e in effects {
278                    if e.contains_mutation() {
279                        return Err(format!(
280                            "`parallel:` contains a mutation effect ({:?}); \
281                             use `sequential:` for ordered mutations",
282                            e
283                        ));
284                    }
285                    // Still validate nested parallels even if this one
286                    // is "clean at the top" — e.g. parallel → sequential
287                    // → parallel(field_op) is still illegal.
288                    e.validate_parallel_purity()?;
289                }
290                Ok(())
291            },
292            Effect::Sequential(effects) => {
293                for e in effects {
294                    e.validate_parallel_purity()?;
295                }
296                Ok(())
297            },
298            Effect::When { body, .. } => {
299                for e in body {
300                    e.validate_parallel_purity()?;
301                }
302                Ok(())
303            },
304            Effect::Pdp {
305                on_allow, on_deny, ..
306            } => {
307                for e in on_allow.iter().chain(on_deny.iter()) {
308                    e.validate_parallel_purity()?;
309                }
310                Ok(())
311            },
312            _ => Ok(()),
313        }
314    }
315}
316
317/// One compiled rule: a predicate plus the effects to fire when it
318/// matches.
319///
320/// `effects` is always non-empty for parser-produced rules. The
321/// historical "single Allow/Deny" cases are represented by a one-element
322/// `Vec` — slightly more allocation than a flat enum, but keeps one
323/// dispatch path instead of two and eliminates the ambiguity of having
324/// both `Action::Allow` and `Effect::Allow` in the IR.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct Rule {
327    pub condition: Expression,
328    pub effects: Vec<Effect>,
329    /// Human-readable source (original YAML line, file path, etc.).
330    /// Surfaces in audit logs and policy violation diagnostics.
331    pub source: String,
332}
333
334impl Rule {
335    /// Construct a single-effect rule. Convenience for the common
336    /// `Allow` / `Deny` shapes that don't need a `vec![]` at the
337    /// call site.
338    pub fn single(condition: Expression, effect: Effect, source: impl Into<String>) -> Self {
339        Self {
340            condition,
341            effects: vec![effect],
342            source: source.into(),
343        }
344    }
345}
346
347/// `Rule` is structurally identical to `Effect::When`. The From impl lets
348/// callers that already hold a `Rule` (notably the parser's inner helpers
349/// and the test fixtures) drop a `.into()` instead of re-spelling all
350/// three fields. Bridges the few remaining producers while the migration
351/// completes; will probably stay long-term because the parser still
352/// builds Rule incrementally before deciding it's an Effect::When.
353impl From<Rule> for Effect {
354    fn from(r: Rule) -> Effect {
355        Effect::When {
356            condition: r.condition,
357            body: r.effects,
358            source: r.source,
359        }
360    }
361}
362
363/// One of the four lifecycle phases the evaluator runs per route.
364///
365/// See docs/specs/apl-design.md §3 — the `PolicyEvaluator` trait has one
366/// async method per phase. `declared_phases()` lets the host skip phases
367/// the route doesn't use.
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
369#[serde(rename_all = "snake_case")]
370pub enum Phase {
371    Args,
372    Policy,
373    Result,
374    PostPolicy,
375}
376
377/// Bit-packed set of phases a route declared.
378#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
379pub struct PhaseSet(u8);
380
381impl PhaseSet {
382    pub fn new() -> Self {
383        Self(0)
384    }
385
386    pub fn insert(&mut self, p: Phase) {
387        self.0 |= Self::bit(p);
388    }
389
390    pub fn contains(&self, p: Phase) -> bool {
391        self.0 & Self::bit(p) != 0
392    }
393
394    pub fn is_empty(&self) -> bool {
395        self.0 == 0
396    }
397
398    fn bit(p: Phase) -> u8 {
399        match p {
400            Phase::Args => 0b0001,
401            Phase::Policy => 0b0010,
402            Phase::Result => 0b0100,
403            Phase::PostPolicy => 0b1000,
404        }
405    }
406}
407
408/// Compiler output for a single route.
409///
410/// One `CompiledRoute` per route_key. The compiler merges global / default /
411/// tag / route-specific rules from the config hierarchy down into these four
412/// phase lists before the evaluator sees them — the IR has no notion of
413/// "tag rules" or "route overrides," only "steps that fire in phase P."
414///
415/// `args` and `result` are per-field pipelines (validators + transforms).
416/// `policy` and `post_policy` are step lists — predicate-and-action rules
417/// plus PDP calls, plugin invocations, and taint effects. See
418/// apl-dsl-spec §1.2 / §4 / §7.
419#[derive(Debug, Clone, Default, Serialize, Deserialize)]
420pub struct CompiledRoute {
421    pub route_key: String,
422    #[serde(default, skip_serializing_if = "Vec::is_empty")]
423    pub args: Vec<crate::pipeline::FieldRule>,
424    #[serde(default, skip_serializing_if = "Vec::is_empty")]
425    pub policy: Vec<Effect>,
426    #[serde(default, skip_serializing_if = "Vec::is_empty")]
427    pub result: Vec<crate::pipeline::FieldRule>,
428    #[serde(default, skip_serializing_if = "Vec::is_empty")]
429    pub post_policy: Vec<Effect>,
430    /// Per-plugin overrides declared on this route's `plugins:` block.
431    /// Keyed by plugin name; merged at dispatch time via
432    /// `EffectivePlugin::resolve(name, registry, &this.plugin_overrides)`.
433    /// Per spec only `config`, `capabilities`, `on_error` are overridable;
434    /// hooks/kind/source always come from the global declaration.
435    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
436    pub plugin_overrides: std::collections::HashMap<String, crate::plugin_decl::PluginOverride>,
437}
438
439impl CompiledRoute {
440    pub fn new(route_key: impl Into<String>) -> Self {
441        Self {
442            route_key: route_key.into(),
443            ..Default::default()
444        }
445    }
446
447    /// Which phases this route uses. Empty phases are not declared.
448    pub fn declared_phases(&self) -> PhaseSet {
449        let mut set = PhaseSet::new();
450        if !self.args.is_empty() {
451            set.insert(Phase::Args);
452        }
453        if !self.policy.is_empty() {
454            set.insert(Phase::Policy);
455        }
456        if !self.result.is_empty() {
457            set.insert(Phase::Result);
458        }
459        if !self.post_policy.is_empty() {
460            set.insert(Phase::PostPolicy);
461        }
462        set
463    }
464
465    /// Apply a more-specific policy layer on top of this one. Used by
466    /// orchestrators (apl-cpex's visitor) to stack the unified-config
467    /// hierarchy least-to-most-specific:
468    ///
469    /// ```text
470    /// effective = CompiledRoute::default()
471    /// effective.apply_layer(global_block)
472    /// effective.apply_layer(default_block)
473    /// effective.apply_layer(tag_block)
474    /// effective.apply_layer(route_block)
475    /// ```
476    ///
477    /// Each call adds the parameter on top of what's already there;
478    /// `more_specific` wins on collisions because it represents a
479    /// later/narrower layer in the inheritance chain.
480    ///
481    /// Merge semantics:
482    /// - **`policy` / `post_policy`**: `more_specific`'s steps append
483    ///   *after* self's. Earlier layers run first — globals deny before
484    ///   route-specific rules get a chance.
485    /// - **`args` / `result`**: per-field; if both layers declare the
486    ///   same field, `more_specific`'s rule replaces self's. Fields
487    ///   only in self stay; fields only in `more_specific` are added.
488    /// - **`plugin_overrides`**: HashMap merge; `more_specific` wins
489    ///   on key collisions, otherwise prefix's entries fill gaps.
490    ///
491    /// `self.route_key` is preserved — apply_layer doesn't overwrite
492    /// identity, just policy content.
493    pub fn apply_layer(&mut self, more_specific: CompiledRoute) {
494        // policy / post_policy: more_specific's steps append AFTER self.
495        // Order of accumulated calls = order of evaluation.
496        self.policy.extend(more_specific.policy);
497        self.post_policy.extend(more_specific.post_policy);
498
499        // args: more_specific wins on field collision — drop any self.args
500        // entries the new layer redefines, then push the new layer's.
501        let ms_fields: std::collections::HashSet<String> =
502            more_specific.args.iter().map(|f| f.field.clone()).collect();
503        self.args.retain(|f| !ms_fields.contains(&f.field));
504        self.args.extend(more_specific.args);
505
506        // result: same shape as args.
507        let ms_result_fields: std::collections::HashSet<String> = more_specific
508            .result
509            .iter()
510            .map(|f| f.field.clone())
511            .collect();
512        self.result.retain(|f| !ms_result_fields.contains(&f.field));
513        self.result.extend(more_specific.result);
514
515        // plugin_overrides: HashMap::extend overwrites on key collision,
516        // which is exactly the more_specific-wins semantic.
517        self.plugin_overrides.extend(more_specific.plugin_overrides);
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn phase_set_basic() {
527        let mut set = PhaseSet::new();
528        assert!(set.is_empty());
529        set.insert(Phase::Policy);
530        set.insert(Phase::Result);
531        assert!(set.contains(Phase::Policy));
532        assert!(set.contains(Phase::Result));
533        assert!(!set.contains(Phase::Args));
534        assert!(!set.contains(Phase::PostPolicy));
535        assert!(!set.is_empty());
536    }
537
538    #[test]
539    fn compiled_route_declared_phases() {
540        let mut route = CompiledRoute::new("get_compensation");
541        assert!(route.declared_phases().is_empty());
542
543        route.policy.push(Effect::When {
544            condition: Expression::Condition(Condition::IsTrue {
545                key: "authenticated".into(),
546            }),
547            body: vec![Effect::Allow],
548            source: "policy[0]".into(),
549        });
550        let phases = route.declared_phases();
551        assert!(phases.contains(Phase::Policy));
552        assert!(!phases.contains(Phase::Args));
553    }
554
555    #[test]
556    fn literal_from_impls() {
557        // From impls keep test/builder code readable.
558        let r = Rule {
559            condition: Expression::Condition(Condition::Comparison {
560                key: "delegation.depth".into(),
561                op: CompareOp::Gt,
562                value: 2_i64.into(),
563            }),
564            effects: vec![Effect::Deny {
565                reason: Some("too deep".into()),
566                code: None,
567            }],
568            source: "policy[0]".into(),
569        };
570        if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition {
571            assert_eq!(value, Literal::Int(2));
572        } else {
573            panic!("expected Comparison");
574        }
575    }
576
577    #[test]
578    fn rule_serde_roundtrip() {
579        let r = Rule {
580            condition: Expression::And(vec![
581                Expression::Condition(Condition::IsTrue {
582                    key: "authenticated".into(),
583                }),
584                Expression::Condition(Condition::Comparison {
585                    key: "delegation.depth".into(),
586                    op: CompareOp::LtEq,
587                    value: 3_i64.into(),
588                }),
589            ]),
590            effects: vec![Effect::Allow],
591            source: "policy[1]".into(),
592        };
593        let json = serde_json::to_string(&r).unwrap();
594        let back: Rule = serde_json::from_str(&json).unwrap();
595        // No PartialEq on Rule (would force PartialEq on Action's variants
596        // with floats etc.); spot-check the discriminator path instead.
597        assert!(matches!(back.effects.as_slice(), [Effect::Allow]));
598        assert_eq!(back.source, "policy[1]");
599    }
600
601    #[test]
602    fn compiled_route_serde_skips_empty_phases() {
603        let route = CompiledRoute::new("ping");
604        let json = serde_json::to_string(&route).unwrap();
605        // Empty phase vecs should not serialize — keeps audit logs clean.
606        assert_eq!(json, r#"{"route_key":"ping"}"#);
607    }
608
609    #[test]
610    fn apply_layer_appends_policy_and_post_policy_in_evaluation_order() {
611        // Start with global (least specific), then layer route on top.
612        // After: global.policy[0] runs first, route.policy[0] runs second.
613        let mut effective = CompiledRoute::new("route.get_compensation");
614        // Seed effective with global content (simulating having already
615        // applied the global layer once).
616        effective.policy.push(Effect::When {
617            condition: Expression::Always,
618            body: vec![Effect::Allow],
619            source: "global.policy[0]".into(),
620        });
621        effective.post_policy.push(Effect::When {
622            condition: Expression::Always,
623            body: vec![Effect::Allow],
624            source: "global.post_policy[0]".into(),
625        });
626
627        // Now apply the route-specific layer on top.
628        let mut route_layer = CompiledRoute::new("ignored");
629        route_layer.policy.push(Effect::When {
630            condition: Expression::Always,
631            body: vec![Effect::Allow],
632            source: "route.policy[0]".into(),
633        });
634        route_layer.post_policy.push(Effect::When {
635            condition: Expression::Always,
636            body: vec![Effect::Allow],
637            source: "route.post_policy[0]".into(),
638        });
639
640        effective.apply_layer(route_layer);
641
642        // global ran first, route ran second — first-deny-wins respects
643        // the hierarchy.
644        assert_eq!(effective.policy.len(), 2);
645        match &effective.policy[0] {
646            Effect::When { source, .. } => assert_eq!(source, "global.policy[0]"),
647            _ => panic!(),
648        }
649        match &effective.policy[1] {
650            Effect::When { source, .. } => assert_eq!(source, "route.policy[0]"),
651            _ => panic!(),
652        }
653        assert_eq!(effective.post_policy.len(), 2);
654
655        // route_key preserved (apply_layer doesn't touch identity).
656        assert_eq!(effective.route_key, "route.get_compensation");
657    }
658
659    #[test]
660    fn apply_layer_args_more_specific_wins_on_field_collision() {
661        use crate::pipeline::{FieldRule, Pipeline, Stage, TypeCheck};
662
663        // Start with the default (less specific) layer.
664        let mut effective = CompiledRoute::new("route.X");
665        effective.args.push(FieldRule {
666            field: "id".into(),
667            pipeline: Pipeline {
668                stages: vec![Stage::Type(TypeCheck::Str)],
669            },
670            source: "default.args.id".into(),
671        });
672        effective.args.push(FieldRule {
673            field: "trace_id".into(),
674            pipeline: Pipeline {
675                stages: vec![Stage::Type(TypeCheck::Str)],
676            },
677            source: "default.args.trace_id".into(),
678        });
679
680        // Layer route (more specific) on top — it redefines `id`.
681        let mut route_layer = CompiledRoute::new("ignored");
682        route_layer.args.push(FieldRule {
683            field: "id".into(),
684            pipeline: Pipeline {
685                stages: vec![Stage::Type(TypeCheck::Uuid)],
686            },
687            source: "route.args.id".into(),
688        });
689
690        effective.apply_layer(route_layer);
691
692        assert_eq!(effective.args.len(), 2);
693        // `id` is now the route's (Uuid), not the default's (Str).
694        let id_rule = effective.args.iter().find(|f| f.field == "id").unwrap();
695        assert!(matches!(
696            id_rule.pipeline.stages[0],
697            Stage::Type(TypeCheck::Uuid)
698        ));
699        assert_eq!(id_rule.source, "route.args.id");
700        // `trace_id` survives from the default — route didn't touch it.
701        let trace = effective
702            .args
703            .iter()
704            .find(|f| f.field == "trace_id")
705            .unwrap();
706        assert_eq!(trace.source, "default.args.trace_id");
707    }
708
709    #[test]
710    fn apply_layer_plugin_overrides_more_specific_wins() {
711        use crate::plugin_decl::PluginOverride;
712
713        // Default (less specific) layer.
714        let mut effective = CompiledRoute::new("route.X");
715        effective.plugin_overrides.insert(
716            "rate_limiter".into(),
717            PluginOverride {
718                on_error: Some("ignore".into()),
719                ..Default::default()
720            },
721        );
722        effective.plugin_overrides.insert(
723            "audit_logger".into(),
724            PluginOverride {
725                on_error: Some("ignore".into()),
726                ..Default::default()
727            },
728        );
729
730        // Route (more specific) layer overrides rate_limiter.
731        let mut route_layer = CompiledRoute::new("ignored");
732        route_layer.plugin_overrides.insert(
733            "rate_limiter".into(),
734            PluginOverride {
735                on_error: Some("fail".into()),
736                ..Default::default()
737            },
738        );
739
740        effective.apply_layer(route_layer);
741
742        assert_eq!(effective.plugin_overrides.len(), 2);
743        assert_eq!(
744            effective.plugin_overrides["rate_limiter"]
745                .on_error
746                .as_deref(),
747            Some("fail"),
748            "route's override wins on collision",
749        );
750        // audit_logger untouched — route didn't redefine it.
751        assert_eq!(
752            effective.plugin_overrides["audit_logger"]
753                .on_error
754                .as_deref(),
755            Some("ignore"),
756        );
757    }
758
759    #[test]
760    fn apply_layer_chained_walks_hierarchy_in_specificity_order() {
761        // Build effective policy by applying layers least-to-most-specific.
762        // Mirrors how AplConfigVisitor will compose global/default/tag/route.
763        let mut effective = CompiledRoute::new("route.get_compensation");
764
765        let mut global = CompiledRoute::default();
766        global.policy.push(Effect::When {
767            condition: Expression::Always,
768            body: vec![Effect::Allow],
769            source: "global.policy[0]".into(),
770        });
771
772        let mut default = CompiledRoute::default();
773        default.policy.push(Effect::When {
774            condition: Expression::Always,
775            body: vec![Effect::Allow],
776            source: "default.policy[0]".into(),
777        });
778
779        let mut tag = CompiledRoute::default();
780        tag.policy.push(Effect::When {
781            condition: Expression::Always,
782            body: vec![Effect::Allow],
783            source: "tag.hr.policy[0]".into(),
784        });
785
786        let mut route = CompiledRoute::default();
787        route.policy.push(Effect::When {
788            condition: Expression::Always,
789            body: vec![Effect::Allow],
790            source: "route.policy[0]".into(),
791        });
792
793        effective.apply_layer(global);
794        effective.apply_layer(default);
795        effective.apply_layer(tag);
796        effective.apply_layer(route);
797
798        // Order of calls = order of evaluation. global runs first,
799        // route runs last (first-deny-wins lets globals deny early).
800        let sources: Vec<&str> = effective
801            .policy
802            .iter()
803            .map(|s| match s {
804                Effect::When { source, .. } => source.as_str(),
805                _ => "<not-rule>",
806            })
807            .collect();
808        assert_eq!(
809            sources,
810            vec![
811                "global.policy[0]",
812                "default.policy[0]",
813                "tag.hr.policy[0]",
814                "route.policy[0]",
815            ]
816        );
817    }
818
819    // ----- E3: parallel-purity validation -----
820
821    #[test]
822    fn validate_parallel_pure_block_passes() {
823        // A parallel block of read-only effects validates clean.
824        let effect = Effect::Parallel(vec![
825            Effect::Plugin {
826                name: "rate_limiter".into(),
827            },
828            Effect::Plugin {
829                name: "audit".into(),
830            },
831            Effect::Allow,
832        ]);
833        assert!(effect.validate_parallel_purity().is_ok());
834    }
835
836    #[test]
837    fn validate_parallel_rejects_field_op() {
838        // FieldOp would silently lose its mutation in a discarded
839        // branch — config-load surfaces this loudly.
840        let effect = Effect::Parallel(vec![
841            Effect::Plugin {
842                name: "audit".into(),
843            },
844            Effect::FieldOp {
845                path: "args.ssn".into(),
846                stages: vec![],
847            },
848        ]);
849        let err = effect.validate_parallel_purity().unwrap_err();
850        assert!(err.contains("mutation"), "got: {}", err);
851        assert!(err.contains("FieldOp"), "should name the offender: {}", err);
852    }
853
854    #[test]
855    fn validate_parallel_rejects_delegate() {
856        // Same reason as FieldOp — the minted token would land in a
857        // bag that gets discarded.
858        let delegate = Effect::Delegate(crate::step::DelegateStep {
859            plugin_name: "workday".into(),
860            config_override: None,
861            on_error: None,
862            source: "test".into(),
863        });
864        let effect = Effect::Parallel(vec![Effect::Allow, delegate]);
865        let err = effect.validate_parallel_purity().unwrap_err();
866        assert!(err.contains("mutation"));
867    }
868
869    #[test]
870    fn validate_parallel_recurses_into_nested_parallel() {
871        // `parallel → sequential → parallel(field_op)` — the inner
872        // parallel still illegal. Recursion must catch it.
873        let inner_parallel = Effect::Parallel(vec![Effect::FieldOp {
874            path: "args.x".into(),
875            stages: vec![],
876        }]);
877        let outer = Effect::Parallel(vec![Effect::Sequential(vec![
878            Effect::Allow,
879            inner_parallel,
880        ])]);
881        assert!(outer.validate_parallel_purity().is_err());
882    }
883
884    #[test]
885    fn validate_top_level_sequential_allows_mutations() {
886        // FieldOp / Delegate are allowed under Sequential (or at top
887        // level) — only Parallel rejects them.
888        let effect = Effect::Sequential(vec![
889            Effect::FieldOp {
890                path: "args.ssn".into(),
891                stages: vec![],
892            },
893            Effect::Allow,
894        ]);
895        assert!(effect.validate_parallel_purity().is_ok());
896    }
897
898    #[test]
899    fn validate_contains_mutation_classifies_each_variant() {
900        // White-box check on the helper so future Effect additions
901        // get flagged here when they should be classified.
902        assert!(!Effect::Allow.contains_mutation());
903        assert!(!Effect::Deny {
904            reason: None,
905            code: None
906        }
907        .contains_mutation());
908        assert!(!Effect::Plugin { name: "x".into() }.contains_mutation());
909        assert!(!Effect::Taint {
910            label: "x".into(),
911            scopes: vec![],
912        }
913        .contains_mutation());
914
915        assert!(Effect::FieldOp {
916            path: "args.x".into(),
917            stages: vec![],
918        }
919        .contains_mutation());
920        assert!(Effect::Delegate(crate::step::DelegateStep {
921            plugin_name: "x".into(),
922            config_override: None,
923            on_error: None,
924            source: "x".into(),
925        })
926        .contains_mutation());
927
928        // Composite — mutates iff any child mutates.
929        let pure_seq = Effect::Sequential(vec![Effect::Allow]);
930        assert!(!pure_seq.contains_mutation());
931        let dirty_seq = Effect::Sequential(vec![Effect::FieldOp {
932            path: "args.x".into(),
933            stages: vec![],
934        }]);
935        assert!(dirty_seq.contains_mutation());
936    }
937}