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/// Custom response to attach when a route's policy denies (e.g., equivalent
409/// to a Kuadrant `AuthPolicy` `response.unauthorized` `denyWith`).
410/// Carried on the route and surfaced on the deny outcome's
411/// `details` map by the host (apl-cpex), so a host can render a custom
412/// HTTP response. All fields optional; an absent block leaves the host's
413/// default denial response unchanged.
414#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
415pub struct DenyResponse {
416    /// HTTP status to use for the denial (e.g. 403, 302).
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub status: Option<u16>,
419    /// Response body.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub body: Option<String>,
422    /// Response headers (e.g. `Location` for a redirect, `WWW-Authenticate`).
423    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
424    pub headers: std::collections::BTreeMap<String, String>,
425}
426
427/// Compiler output for a single route.
428///
429/// One `CompiledRoute` per route_key. The compiler merges global / default /
430/// tag / route-specific rules from the config hierarchy down into these four
431/// phase lists before the evaluator sees them — the IR has no notion of
432/// "tag rules" or "route overrides," only "steps that fire in phase P."
433///
434/// `args` and `result` are per-field pipelines (validators + transforms).
435/// `policy` and `post_policy` are step lists — predicate-and-action rules
436/// plus PDP calls, plugin invocations, and taint effects. See
437/// apl-dsl-spec §1.2 / §4 / §7.
438#[derive(Debug, Clone, Default, Serialize, Deserialize)]
439pub struct CompiledRoute {
440    pub route_key: String,
441    #[serde(default, skip_serializing_if = "Vec::is_empty")]
442    pub args: Vec<crate::pipeline::FieldRule>,
443    #[serde(default, skip_serializing_if = "Vec::is_empty")]
444    pub policy: Vec<Effect>,
445    #[serde(default, skip_serializing_if = "Vec::is_empty")]
446    pub result: Vec<crate::pipeline::FieldRule>,
447    #[serde(default, skip_serializing_if = "Vec::is_empty")]
448    pub post_policy: Vec<Effect>,
449    /// Per-plugin overrides declared on this route's `plugins:` block.
450    /// Keyed by plugin name; merged at dispatch time via
451    /// `EffectivePlugin::resolve(name, registry, &this.plugin_overrides)`.
452    /// Per spec only `config`, `capabilities`, `on_error` are overridable;
453    /// hooks/kind/source always come from the global declaration.
454    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
455    pub plugin_overrides: std::collections::HashMap<String, crate::plugin_decl::PluginOverride>,
456
457    /// Custom denial response (transpiled `denyWith`). Most-specific layer
458    /// wins on collision. `None` leaves the host's default denial behavior.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub response: Option<DenyResponse>,
461}
462
463impl CompiledRoute {
464    pub fn new(route_key: impl Into<String>) -> Self {
465        Self {
466            route_key: route_key.into(),
467            ..Default::default()
468        }
469    }
470
471    /// Which phases this route uses. Empty phases are not declared.
472    pub fn declared_phases(&self) -> PhaseSet {
473        let mut set = PhaseSet::new();
474        if !self.args.is_empty() {
475            set.insert(Phase::Args);
476        }
477        if !self.policy.is_empty() {
478            set.insert(Phase::Policy);
479        }
480        if !self.result.is_empty() {
481            set.insert(Phase::Result);
482        }
483        if !self.post_policy.is_empty() {
484            set.insert(Phase::PostPolicy);
485        }
486        set
487    }
488
489    /// Apply a more-specific policy layer on top of this one. Used by
490    /// orchestrators (apl-cpex's visitor) to stack the unified-config
491    /// hierarchy least-to-most-specific:
492    ///
493    /// ```text
494    /// effective = CompiledRoute::default()
495    /// effective.apply_layer(global_block)
496    /// effective.apply_layer(default_block)
497    /// effective.apply_layer(tag_block)
498    /// effective.apply_layer(route_block)
499    /// ```
500    ///
501    /// Each call adds the parameter on top of what's already there;
502    /// `more_specific` wins on collisions because it represents a
503    /// later/narrower layer in the inheritance chain.
504    ///
505    /// Merge semantics:
506    /// - **`policy` / `post_policy`**: `more_specific`'s steps append
507    ///   *after* self's. Earlier layers run first — globals deny before
508    ///   route-specific rules get a chance.
509    /// - **`args` / `result`**: per-field; if both layers declare the
510    ///   same field, `more_specific`'s rule replaces self's. Fields
511    ///   only in self stay; fields only in `more_specific` are added.
512    /// - **`plugin_overrides`**: HashMap merge; `more_specific` wins
513    ///   on key collisions, otherwise prefix's entries fill gaps.
514    ///
515    /// `self.route_key` is preserved — apply_layer doesn't overwrite
516    /// identity, just policy content.
517    pub fn apply_layer(&mut self, more_specific: CompiledRoute) {
518        // policy / post_policy: more_specific's steps append AFTER self.
519        // Order of accumulated calls = order of evaluation.
520        self.policy.extend(more_specific.policy);
521        self.post_policy.extend(more_specific.post_policy);
522
523        // args: more_specific wins on field collision — drop any self.args
524        // entries the new layer redefines, then push the new layer's.
525        let ms_fields: std::collections::HashSet<String> =
526            more_specific.args.iter().map(|f| f.field.clone()).collect();
527        self.args.retain(|f| !ms_fields.contains(&f.field));
528        self.args.extend(more_specific.args);
529
530        // result: same shape as args.
531        let ms_result_fields: std::collections::HashSet<String> = more_specific
532            .result
533            .iter()
534            .map(|f| f.field.clone())
535            .collect();
536        self.result.retain(|f| !ms_result_fields.contains(&f.field));
537        self.result.extend(more_specific.result);
538
539        // plugin_overrides: HashMap::extend overwrites on key collision,
540        // which is exactly the more_specific-wins semantic.
541        self.plugin_overrides.extend(more_specific.plugin_overrides);
542
543        // response: deliberately NOT layered. A custom denial response is
544        // scope-local — the entity-less HTTP handler carries the `global`
545        // block directly, and an entity route carries only its own
546        // `response:`. Propagating it here let a `global` catch-all
547        // `denyWith` leak onto every inherited entity (MCP tool / llm /
548        // prompt / resource) denial with no way to opt back out. Callers
549        // set `response` explicitly at the scope that owns it.
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn apply_layer_does_not_propagate_response() {
559        // `response` is scope-local and must never cross a layer boundary —
560        // a `global` catch-all denyWith must not leak onto entity routes.
561        let mut base = CompiledRoute::new("tool:x");
562        base.response = Some(DenyResponse {
563            status: Some(401),
564            ..Default::default()
565        });
566
567        let mut layer = CompiledRoute::new("tool:x");
568        layer.response = Some(DenyResponse {
569            status: Some(403),
570            body: Some("forbidden".to_string()),
571            ..Default::default()
572        });
573        base.apply_layer(layer);
574        // base keeps its own response; the layer's is dropped.
575        assert_eq!(base.response.as_ref().unwrap().status, Some(401));
576
577        // A layer's response never populates an empty base either.
578        let mut empty = CompiledRoute::new("tool:x");
579        let mut with_resp = CompiledRoute::new("tool:x");
580        with_resp.response = Some(DenyResponse {
581            status: Some(418),
582            ..Default::default()
583        });
584        empty.apply_layer(with_resp);
585        assert!(empty.response.is_none());
586    }
587
588    #[test]
589    fn phase_set_basic() {
590        let mut set = PhaseSet::new();
591        assert!(set.is_empty());
592        set.insert(Phase::Policy);
593        set.insert(Phase::Result);
594        assert!(set.contains(Phase::Policy));
595        assert!(set.contains(Phase::Result));
596        assert!(!set.contains(Phase::Args));
597        assert!(!set.contains(Phase::PostPolicy));
598        assert!(!set.is_empty());
599    }
600
601    #[test]
602    fn compiled_route_declared_phases() {
603        let mut route = CompiledRoute::new("get_compensation");
604        assert!(route.declared_phases().is_empty());
605
606        route.policy.push(Effect::When {
607            condition: Expression::Condition(Condition::IsTrue {
608                key: "authenticated".into(),
609            }),
610            body: vec![Effect::Allow],
611            source: "policy[0]".into(),
612        });
613        let phases = route.declared_phases();
614        assert!(phases.contains(Phase::Policy));
615        assert!(!phases.contains(Phase::Args));
616    }
617
618    #[test]
619    fn literal_from_impls() {
620        // From impls keep test/builder code readable.
621        let r = Rule {
622            condition: Expression::Condition(Condition::Comparison {
623                key: "delegation.depth".into(),
624                op: CompareOp::Gt,
625                value: 2_i64.into(),
626            }),
627            effects: vec![Effect::Deny {
628                reason: Some("too deep".into()),
629                code: None,
630            }],
631            source: "policy[0]".into(),
632        };
633        if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition {
634            assert_eq!(value, Literal::Int(2));
635        } else {
636            panic!("expected Comparison");
637        }
638    }
639
640    #[test]
641    fn rule_serde_roundtrip() {
642        let r = Rule {
643            condition: Expression::And(vec![
644                Expression::Condition(Condition::IsTrue {
645                    key: "authenticated".into(),
646                }),
647                Expression::Condition(Condition::Comparison {
648                    key: "delegation.depth".into(),
649                    op: CompareOp::LtEq,
650                    value: 3_i64.into(),
651                }),
652            ]),
653            effects: vec![Effect::Allow],
654            source: "policy[1]".into(),
655        };
656        let json = serde_json::to_string(&r).unwrap();
657        let back: Rule = serde_json::from_str(&json).unwrap();
658        // No PartialEq on Rule (would force PartialEq on Action's variants
659        // with floats etc.); spot-check the discriminator path instead.
660        assert!(matches!(back.effects.as_slice(), [Effect::Allow]));
661        assert_eq!(back.source, "policy[1]");
662    }
663
664    #[test]
665    fn compiled_route_serde_skips_empty_phases() {
666        let route = CompiledRoute::new("ping");
667        let json = serde_json::to_string(&route).unwrap();
668        // Empty phase vecs should not serialize — keeps audit logs clean.
669        assert_eq!(json, r#"{"route_key":"ping"}"#);
670    }
671
672    #[test]
673    fn apply_layer_appends_policy_and_post_policy_in_evaluation_order() {
674        // Start with global (least specific), then layer route on top.
675        // After: global.policy[0] runs first, route.policy[0] runs second.
676        let mut effective = CompiledRoute::new("route.get_compensation");
677        // Seed effective with global content (simulating having already
678        // applied the global layer once).
679        effective.policy.push(Effect::When {
680            condition: Expression::Always,
681            body: vec![Effect::Allow],
682            source: "global.policy[0]".into(),
683        });
684        effective.post_policy.push(Effect::When {
685            condition: Expression::Always,
686            body: vec![Effect::Allow],
687            source: "global.post_policy[0]".into(),
688        });
689
690        // Now apply the route-specific layer on top.
691        let mut route_layer = CompiledRoute::new("ignored");
692        route_layer.policy.push(Effect::When {
693            condition: Expression::Always,
694            body: vec![Effect::Allow],
695            source: "route.policy[0]".into(),
696        });
697        route_layer.post_policy.push(Effect::When {
698            condition: Expression::Always,
699            body: vec![Effect::Allow],
700            source: "route.post_policy[0]".into(),
701        });
702
703        effective.apply_layer(route_layer);
704
705        // global ran first, route ran second — first-deny-wins respects
706        // the hierarchy.
707        assert_eq!(effective.policy.len(), 2);
708        match &effective.policy[0] {
709            Effect::When { source, .. } => assert_eq!(source, "global.policy[0]"),
710            _ => panic!(),
711        }
712        match &effective.policy[1] {
713            Effect::When { source, .. } => assert_eq!(source, "route.policy[0]"),
714            _ => panic!(),
715        }
716        assert_eq!(effective.post_policy.len(), 2);
717
718        // route_key preserved (apply_layer doesn't touch identity).
719        assert_eq!(effective.route_key, "route.get_compensation");
720    }
721
722    #[test]
723    fn apply_layer_args_more_specific_wins_on_field_collision() {
724        use crate::pipeline::{FieldRule, Pipeline, Stage, TypeCheck};
725
726        // Start with the default (less specific) layer.
727        let mut effective = CompiledRoute::new("route.X");
728        effective.args.push(FieldRule {
729            field: "id".into(),
730            pipeline: Pipeline {
731                stages: vec![Stage::Type(TypeCheck::Str)],
732            },
733            source: "default.args.id".into(),
734        });
735        effective.args.push(FieldRule {
736            field: "trace_id".into(),
737            pipeline: Pipeline {
738                stages: vec![Stage::Type(TypeCheck::Str)],
739            },
740            source: "default.args.trace_id".into(),
741        });
742
743        // Layer route (more specific) on top — it redefines `id`.
744        let mut route_layer = CompiledRoute::new("ignored");
745        route_layer.args.push(FieldRule {
746            field: "id".into(),
747            pipeline: Pipeline {
748                stages: vec![Stage::Type(TypeCheck::Uuid)],
749            },
750            source: "route.args.id".into(),
751        });
752
753        effective.apply_layer(route_layer);
754
755        assert_eq!(effective.args.len(), 2);
756        // `id` is now the route's (Uuid), not the default's (Str).
757        let id_rule = effective.args.iter().find(|f| f.field == "id").unwrap();
758        assert!(matches!(
759            id_rule.pipeline.stages[0],
760            Stage::Type(TypeCheck::Uuid)
761        ));
762        assert_eq!(id_rule.source, "route.args.id");
763        // `trace_id` survives from the default — route didn't touch it.
764        let trace = effective
765            .args
766            .iter()
767            .find(|f| f.field == "trace_id")
768            .unwrap();
769        assert_eq!(trace.source, "default.args.trace_id");
770    }
771
772    #[test]
773    fn apply_layer_plugin_overrides_more_specific_wins() {
774        use crate::plugin_decl::PluginOverride;
775
776        // Default (less specific) layer.
777        let mut effective = CompiledRoute::new("route.X");
778        effective.plugin_overrides.insert(
779            "rate_limiter".into(),
780            PluginOverride {
781                on_error: Some("ignore".into()),
782                ..Default::default()
783            },
784        );
785        effective.plugin_overrides.insert(
786            "audit_logger".into(),
787            PluginOverride {
788                on_error: Some("ignore".into()),
789                ..Default::default()
790            },
791        );
792
793        // Route (more specific) layer overrides rate_limiter.
794        let mut route_layer = CompiledRoute::new("ignored");
795        route_layer.plugin_overrides.insert(
796            "rate_limiter".into(),
797            PluginOverride {
798                on_error: Some("fail".into()),
799                ..Default::default()
800            },
801        );
802
803        effective.apply_layer(route_layer);
804
805        assert_eq!(effective.plugin_overrides.len(), 2);
806        assert_eq!(
807            effective.plugin_overrides["rate_limiter"]
808                .on_error
809                .as_deref(),
810            Some("fail"),
811            "route's override wins on collision",
812        );
813        // audit_logger untouched — route didn't redefine it.
814        assert_eq!(
815            effective.plugin_overrides["audit_logger"]
816                .on_error
817                .as_deref(),
818            Some("ignore"),
819        );
820    }
821
822    #[test]
823    fn apply_layer_chained_walks_hierarchy_in_specificity_order() {
824        // Build effective policy by applying layers least-to-most-specific.
825        // Mirrors how AplConfigVisitor will compose global/default/tag/route.
826        let mut effective = CompiledRoute::new("route.get_compensation");
827
828        let mut global = CompiledRoute::default();
829        global.policy.push(Effect::When {
830            condition: Expression::Always,
831            body: vec![Effect::Allow],
832            source: "global.policy[0]".into(),
833        });
834
835        let mut default = CompiledRoute::default();
836        default.policy.push(Effect::When {
837            condition: Expression::Always,
838            body: vec![Effect::Allow],
839            source: "default.policy[0]".into(),
840        });
841
842        let mut tag = CompiledRoute::default();
843        tag.policy.push(Effect::When {
844            condition: Expression::Always,
845            body: vec![Effect::Allow],
846            source: "tag.hr.policy[0]".into(),
847        });
848
849        let mut route = CompiledRoute::default();
850        route.policy.push(Effect::When {
851            condition: Expression::Always,
852            body: vec![Effect::Allow],
853            source: "route.policy[0]".into(),
854        });
855
856        effective.apply_layer(global);
857        effective.apply_layer(default);
858        effective.apply_layer(tag);
859        effective.apply_layer(route);
860
861        // Order of calls = order of evaluation. global runs first,
862        // route runs last (first-deny-wins lets globals deny early).
863        let sources: Vec<&str> = effective
864            .policy
865            .iter()
866            .map(|s| match s {
867                Effect::When { source, .. } => source.as_str(),
868                _ => "<not-rule>",
869            })
870            .collect();
871        assert_eq!(
872            sources,
873            vec![
874                "global.policy[0]",
875                "default.policy[0]",
876                "tag.hr.policy[0]",
877                "route.policy[0]",
878            ]
879        );
880    }
881
882    // ----- E3: parallel-purity validation -----
883
884    #[test]
885    fn validate_parallel_pure_block_passes() {
886        // A parallel block of read-only effects validates clean.
887        let effect = Effect::Parallel(vec![
888            Effect::Plugin {
889                name: "rate_limiter".into(),
890            },
891            Effect::Plugin {
892                name: "audit".into(),
893            },
894            Effect::Allow,
895        ]);
896        assert!(effect.validate_parallel_purity().is_ok());
897    }
898
899    #[test]
900    fn validate_parallel_rejects_field_op() {
901        // FieldOp would silently lose its mutation in a discarded
902        // branch — config-load surfaces this loudly.
903        let effect = Effect::Parallel(vec![
904            Effect::Plugin {
905                name: "audit".into(),
906            },
907            Effect::FieldOp {
908                path: "args.ssn".into(),
909                stages: vec![],
910            },
911        ]);
912        let err = effect.validate_parallel_purity().unwrap_err();
913        assert!(err.contains("mutation"), "got: {}", err);
914        assert!(err.contains("FieldOp"), "should name the offender: {}", err);
915    }
916
917    #[test]
918    fn validate_parallel_rejects_delegate() {
919        // Same reason as FieldOp — the minted token would land in a
920        // bag that gets discarded.
921        let delegate = Effect::Delegate(crate::step::DelegateStep {
922            plugin_name: "workday".into(),
923            config_override: None,
924            on_error: None,
925            source: "test".into(),
926        });
927        let effect = Effect::Parallel(vec![Effect::Allow, delegate]);
928        let err = effect.validate_parallel_purity().unwrap_err();
929        assert!(err.contains("mutation"));
930    }
931
932    #[test]
933    fn validate_parallel_recurses_into_nested_parallel() {
934        // `parallel → sequential → parallel(field_op)` — the inner
935        // parallel still illegal. Recursion must catch it.
936        let inner_parallel = Effect::Parallel(vec![Effect::FieldOp {
937            path: "args.x".into(),
938            stages: vec![],
939        }]);
940        let outer = Effect::Parallel(vec![Effect::Sequential(vec![
941            Effect::Allow,
942            inner_parallel,
943        ])]);
944        assert!(outer.validate_parallel_purity().is_err());
945    }
946
947    #[test]
948    fn validate_top_level_sequential_allows_mutations() {
949        // FieldOp / Delegate are allowed under Sequential (or at top
950        // level) — only Parallel rejects them.
951        let effect = Effect::Sequential(vec![
952            Effect::FieldOp {
953                path: "args.ssn".into(),
954                stages: vec![],
955            },
956            Effect::Allow,
957        ]);
958        assert!(effect.validate_parallel_purity().is_ok());
959    }
960
961    #[test]
962    fn validate_contains_mutation_classifies_each_variant() {
963        // White-box check on the helper so future Effect additions
964        // get flagged here when they should be classified.
965        assert!(!Effect::Allow.contains_mutation());
966        assert!(!Effect::Deny {
967            reason: None,
968            code: None
969        }
970        .contains_mutation());
971        assert!(!Effect::Plugin { name: "x".into() }.contains_mutation());
972        assert!(!Effect::Taint {
973            label: "x".into(),
974            scopes: vec![],
975        }
976        .contains_mutation());
977
978        assert!(Effect::FieldOp {
979            path: "args.x".into(),
980            stages: vec![],
981        }
982        .contains_mutation());
983        assert!(Effect::Delegate(crate::step::DelegateStep {
984            plugin_name: "x".into(),
985            config_override: None,
986            on_error: None,
987            source: "x".into(),
988        })
989        .contains_mutation());
990
991        // Composite — mutates iff any child mutates.
992        let pure_seq = Effect::Sequential(vec![Effect::Allow]);
993        assert!(!pure_seq.contains_mutation());
994        let dirty_seq = Effect::Sequential(vec![Effect::FieldOp {
995            path: "args.x".into(),
996            stages: vec![],
997        }]);
998        assert!(dirty_seq.contains_mutation());
999    }
1000}