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    /// Elicitation effect — dispatch a question to a human (approval,
179    /// confirmation, step-up, …) through a channel plugin, hold pending
180    /// state across the agent's retries, validate the response, resume.
181    /// The elicitation analogue of `Delegate`. See
182    /// `docs/apl-manager-approval-ciba-design.md` and
183    /// [`crate::step::ElicitStep`].
184    Elicit(crate::step::ElicitStep),
185    Taint {
186        label: String,
187        scopes: Vec<crate::pipeline::TaintScope>,
188    },
189    /// Content effect (DSL §3) — apply a pipe chain (`redact`, `mask`,
190    /// `omit`, `hash`, validators, transforms) to a field in the
191    /// route's args or result. The author writes
192    /// `result.salary | redact` inside a `do:` body; the parser
193    /// splits the dotted path from the pipeline.
194    ///
195    /// `path` must start with `args.` or `result.` — the evaluator
196    /// dispatches the lookup against `RoutePayload.args` or
197    /// `RoutePayload.result`. A FieldOp inside a Pre-phase route's
198    /// `do:` that targets `result.X` is a no-op (the result hasn't
199    /// been produced yet); same goes for a Post-phase rule that
200    /// targets `args.X` (the args are already on the wire). The
201    /// evaluator silently skips out-of-phase ops so the same
202    /// `when:`/`do:` shape can describe both phases without
203    /// branching.
204    FieldOp {
205        path: String,
206        stages: Vec<crate::pipeline::Stage>,
207    },
208    /// Run a list of effects in declaration order, stopping on the
209    /// first Deny. Semantically equivalent to inlining the list into
210    /// the enclosing scope; the variant exists to make grouping
211    /// explicit and to pair with `Parallel`.
212    Sequential(Vec<Effect>),
213    /// Run a list of effects concurrently. Any Deny → overall Deny.
214    /// Taints from all branches accumulate. Bag and payload mutations
215    /// inside parallel branches are **discarded** when the branch
216    /// completes — each branch gets a clone of the state, never the
217    /// shared mutable original. Plugins inside `Parallel` can still
218    /// emit taints (those merge); any other mutation they try to make
219    /// (bag writes, args/result rewrites) vanishes.
220    ///
221    /// Config-load rejects `FieldOp` and `Delegate` directly inside
222    /// `Parallel` (recursively), since both would silently drop their
223    /// effect. The escape valve is `Sequential`.
224    Parallel(Vec<Effect>),
225    /// Predicate-gated body. `body` runs in order when `condition`
226    /// evaluates to true; any Deny in the body halts the surrounding
227    /// phase. Replaces the historical `Step::Rule(Rule)` shape —
228    /// `when:` / `do:` directly desugars to this. A bare `require(X)`
229    /// or `deny(X)` shorthand compiles to `When { condition: X,
230    /// body: vec![Effect::Allow / Deny] }`.
231    ///
232    /// `source` is the human-readable origin (e.g. `"routes.X.policy[2]"`)
233    /// surfaced in `Decision::Deny.rule_source` when the body denies
234    /// without supplying its own code.
235    When {
236        condition: Expression,
237        body: Vec<Effect>,
238        source: String,
239    },
240    /// External PDP call. `on_allow` / `on_deny` are reaction effect
241    /// lists fired against the PDP's decision (DSL §7.5). Replaces
242    /// `Step::Pdp { ... }` — `args`-shape stays identical.
243    Pdp {
244        call: crate::step::PdpCall,
245        #[serde(default, skip_serializing_if = "Vec::is_empty")]
246        on_allow: Vec<Effect>,
247        #[serde(default, skip_serializing_if = "Vec::is_empty")]
248        on_deny: Vec<Effect>,
249    },
250}
251
252impl Effect {
253    /// Walk this effect (and any nested effects) checking whether any
254    /// node would mutate route state. Used by the config-load
255    /// validator to reject `FieldOp` / `Delegate` inside `Parallel`
256    /// since both would silently drop their effect in a discarded
257    /// branch.
258    pub fn contains_mutation(&self) -> bool {
259        match self {
260            // `Elicit` has an external side effect (posts to a channel,
261            // registers an intent) — like `Delegate` it must not sit in
262            // a `Parallel` branch that could be silently discarded.
263            Effect::FieldOp { .. } | Effect::Delegate(_) | Effect::Elicit(_) => true,
264            Effect::Sequential(effects) | Effect::Parallel(effects) => {
265                effects.iter().any(Effect::contains_mutation)
266            },
267            Effect::When { body, .. } => body.iter().any(Effect::contains_mutation),
268            Effect::Pdp {
269                on_allow, on_deny, ..
270            } => {
271                on_allow.iter().any(Effect::contains_mutation)
272                    || on_deny.iter().any(Effect::contains_mutation)
273            },
274            Effect::Allow | Effect::Deny { .. } | Effect::Plugin { .. } | Effect::Taint { .. } => {
275                false
276            },
277        }
278    }
279
280    /// Walk the effect tree rejecting any `FieldOp` / `Delegate` that
281    /// lives directly or transitively under a `Parallel` node. Returns
282    /// the path string of the first violation found (or `Ok(())` if
283    /// the tree is clean). Run at config-load.
284    pub fn validate_parallel_purity(&self) -> Result<(), String> {
285        match self {
286            Effect::Parallel(effects) => {
287                for e in effects {
288                    if e.contains_mutation() {
289                        return Err(format!(
290                            "`parallel:` contains a mutation effect ({:?}); \
291                             use `sequential:` for ordered mutations",
292                            e
293                        ));
294                    }
295                    // Still validate nested parallels even if this one
296                    // is "clean at the top" — e.g. parallel → sequential
297                    // → parallel(field_op) is still illegal.
298                    e.validate_parallel_purity()?;
299                }
300                Ok(())
301            },
302            Effect::Sequential(effects) => {
303                for e in effects {
304                    e.validate_parallel_purity()?;
305                }
306                Ok(())
307            },
308            Effect::When { body, .. } => {
309                for e in body {
310                    e.validate_parallel_purity()?;
311                }
312                Ok(())
313            },
314            Effect::Pdp {
315                on_allow, on_deny, ..
316            } => {
317                for e in on_allow.iter().chain(on_deny.iter()) {
318                    e.validate_parallel_purity()?;
319                }
320                Ok(())
321            },
322            _ => Ok(()),
323        }
324    }
325}
326
327/// One compiled rule: a predicate plus the effects to fire when it
328/// matches.
329///
330/// `effects` is always non-empty for parser-produced rules. The
331/// historical "single Allow/Deny" cases are represented by a one-element
332/// `Vec` — slightly more allocation than a flat enum, but keeps one
333/// dispatch path instead of two and eliminates the ambiguity of having
334/// both `Action::Allow` and `Effect::Allow` in the IR.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct Rule {
337    pub condition: Expression,
338    pub effects: Vec<Effect>,
339    /// Human-readable source (original YAML line, file path, etc.).
340    /// Surfaces in audit logs and policy violation diagnostics.
341    pub source: String,
342}
343
344impl Rule {
345    /// Construct a single-effect rule. Convenience for the common
346    /// `Allow` / `Deny` shapes that don't need a `vec![]` at the
347    /// call site.
348    pub fn single(condition: Expression, effect: Effect, source: impl Into<String>) -> Self {
349        Self {
350            condition,
351            effects: vec![effect],
352            source: source.into(),
353        }
354    }
355}
356
357/// `Rule` is structurally identical to `Effect::When`. The From impl lets
358/// callers that already hold a `Rule` (notably the parser's inner helpers
359/// and the test fixtures) drop a `.into()` instead of re-spelling all
360/// three fields. Bridges the few remaining producers while the migration
361/// completes; will probably stay long-term because the parser still
362/// builds Rule incrementally before deciding it's an Effect::When.
363impl From<Rule> for Effect {
364    fn from(r: Rule) -> Effect {
365        Effect::When {
366            condition: r.condition,
367            body: r.effects,
368            source: r.source,
369        }
370    }
371}
372
373/// One of the four lifecycle phases the evaluator runs per route.
374///
375/// See docs/specs/apl-design.md §3 — the `PolicyEvaluator` trait has one
376/// async method per phase. `declared_phases()` lets the host skip phases
377/// the route doesn't use.
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
379#[serde(rename_all = "snake_case")]
380pub enum Phase {
381    Args,
382    Policy,
383    Result,
384    PostPolicy,
385}
386
387/// Bit-packed set of phases a route declared.
388#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
389pub struct PhaseSet(u8);
390
391impl PhaseSet {
392    pub fn new() -> Self {
393        Self(0)
394    }
395
396    pub fn insert(&mut self, p: Phase) {
397        self.0 |= Self::bit(p);
398    }
399
400    pub fn contains(&self, p: Phase) -> bool {
401        self.0 & Self::bit(p) != 0
402    }
403
404    pub fn is_empty(&self) -> bool {
405        self.0 == 0
406    }
407
408    fn bit(p: Phase) -> u8 {
409        match p {
410            Phase::Args => 0b0001,
411            Phase::Policy => 0b0010,
412            Phase::Result => 0b0100,
413            Phase::PostPolicy => 0b1000,
414        }
415    }
416}
417
418/// Custom response to attach when a route's policy denies (e.g., equivalent
419/// to a Kuadrant `AuthPolicy` `response.unauthorized` `denyWith`).
420/// Carried on the route and surfaced on the deny outcome's
421/// `details` map by the host (apl-cpex), so a host can render a custom
422/// HTTP response. All fields optional; an absent block leaves the host's
423/// default denial response unchanged.
424#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
425pub struct DenyResponse {
426    /// HTTP status to use for the denial (e.g. 403, 302).
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub status: Option<u16>,
429    /// Response body.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub body: Option<String>,
432    /// Response headers (e.g. `Location` for a redirect, `WWW-Authenticate`).
433    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
434    pub headers: std::collections::BTreeMap<String, String>,
435}
436
437/// Compiler output for a single route.
438///
439/// One `CompiledRoute` per route_key. The compiler merges global / default /
440/// tag / route-specific rules from the config hierarchy down into these four
441/// phase lists before the evaluator sees them — the IR has no notion of
442/// "tag rules" or "route overrides," only "steps that fire in phase P."
443///
444/// `args` and `result` are per-field pipelines (validators + transforms).
445/// `policy` and `post_policy` are step lists — predicate-and-action rules
446/// plus PDP calls, plugin invocations, and taint effects. See
447/// apl-dsl-spec §1.2 / §4 / §7.
448#[derive(Debug, Clone, Default, Serialize, Deserialize)]
449pub struct CompiledRoute {
450    pub route_key: String,
451    #[serde(default, skip_serializing_if = "Vec::is_empty")]
452    pub args: Vec<crate::pipeline::FieldRule>,
453    #[serde(default, skip_serializing_if = "Vec::is_empty")]
454    pub policy: Vec<Effect>,
455    #[serde(default, skip_serializing_if = "Vec::is_empty")]
456    pub result: Vec<crate::pipeline::FieldRule>,
457    #[serde(default, skip_serializing_if = "Vec::is_empty")]
458    pub post_policy: Vec<Effect>,
459    /// Per-plugin overrides declared on this route's `plugins:` block.
460    /// Keyed by plugin name; merged at dispatch time via
461    /// `EffectivePlugin::resolve(name, registry, &this.plugin_overrides)`.
462    /// Per spec only `config`, `capabilities`, `on_error` are overridable;
463    /// hooks/kind/source always come from the global declaration.
464    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
465    pub plugin_overrides: std::collections::HashMap<String, crate::plugin_decl::PluginOverride>,
466
467    /// Custom denial response (transpiled `denyWith`). Most-specific layer
468    /// wins on collision. `None` leaves the host's default denial behavior.
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub response: Option<DenyResponse>,
471}
472
473impl CompiledRoute {
474    pub fn new(route_key: impl Into<String>) -> Self {
475        Self {
476            route_key: route_key.into(),
477            ..Default::default()
478        }
479    }
480
481    /// Which phases this route uses. Empty phases are not declared.
482    pub fn declared_phases(&self) -> PhaseSet {
483        let mut set = PhaseSet::new();
484        if !self.args.is_empty() {
485            set.insert(Phase::Args);
486        }
487        if !self.policy.is_empty() {
488            set.insert(Phase::Policy);
489        }
490        if !self.result.is_empty() {
491            set.insert(Phase::Result);
492        }
493        if !self.post_policy.is_empty() {
494            set.insert(Phase::PostPolicy);
495        }
496        set
497    }
498
499    /// Apply a more-specific policy layer on top of this one. Used by
500    /// orchestrators (apl-cpex's visitor) to stack the unified-config
501    /// hierarchy least-to-most-specific:
502    ///
503    /// ```text
504    /// effective = CompiledRoute::default()
505    /// effective.apply_layer(global_block)
506    /// effective.apply_layer(default_block)
507    /// effective.apply_layer(tag_block)
508    /// effective.apply_layer(route_block)
509    /// ```
510    ///
511    /// Each call adds the parameter on top of what's already there;
512    /// `more_specific` wins on collisions because it represents a
513    /// later/narrower layer in the inheritance chain.
514    ///
515    /// Merge semantics:
516    /// - **`policy` / `post_policy`**: `more_specific`'s steps append
517    ///   *after* self's. Earlier layers run first — globals deny before
518    ///   route-specific rules get a chance.
519    /// - **`args` / `result`**: per-field; if both layers declare the
520    ///   same field, `more_specific`'s rule replaces self's. Fields
521    ///   only in self stay; fields only in `more_specific` are added.
522    /// - **`plugin_overrides`**: HashMap merge; `more_specific` wins
523    ///   on key collisions, otherwise prefix's entries fill gaps.
524    ///
525    /// `self.route_key` is preserved — apply_layer doesn't overwrite
526    /// identity, just policy content.
527    pub fn apply_layer(&mut self, more_specific: CompiledRoute) {
528        // policy / post_policy: more_specific's steps append AFTER self.
529        // Order of accumulated calls = order of evaluation.
530        self.policy.extend(more_specific.policy);
531        self.post_policy.extend(more_specific.post_policy);
532
533        // args: more_specific wins on field collision — drop any self.args
534        // entries the new layer redefines, then push the new layer's.
535        let ms_fields: std::collections::HashSet<String> =
536            more_specific.args.iter().map(|f| f.field.clone()).collect();
537        self.args.retain(|f| !ms_fields.contains(&f.field));
538        self.args.extend(more_specific.args);
539
540        // result: same shape as args.
541        let ms_result_fields: std::collections::HashSet<String> = more_specific
542            .result
543            .iter()
544            .map(|f| f.field.clone())
545            .collect();
546        self.result.retain(|f| !ms_result_fields.contains(&f.field));
547        self.result.extend(more_specific.result);
548
549        // plugin_overrides: HashMap::extend overwrites on key collision,
550        // which is exactly the more_specific-wins semantic.
551        self.plugin_overrides.extend(more_specific.plugin_overrides);
552
553        // response: deliberately NOT layered. A custom denial response is
554        // scope-local — the entity-less HTTP handler carries the `global`
555        // block directly, and an entity route carries only its own
556        // `response:`. Propagating it here let a `global` catch-all
557        // `denyWith` leak onto every inherited entity (MCP tool / llm /
558        // prompt / resource) denial with no way to opt back out. Callers
559        // set `response` explicitly at the scope that owns it.
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn apply_layer_does_not_propagate_response() {
569        // `response` is scope-local and must never cross a layer boundary —
570        // a `global` catch-all denyWith must not leak onto entity routes.
571        let mut base = CompiledRoute::new("tool:x");
572        base.response = Some(DenyResponse {
573            status: Some(401),
574            ..Default::default()
575        });
576
577        let mut layer = CompiledRoute::new("tool:x");
578        layer.response = Some(DenyResponse {
579            status: Some(403),
580            body: Some("forbidden".to_string()),
581            ..Default::default()
582        });
583        base.apply_layer(layer);
584        // base keeps its own response; the layer's is dropped.
585        assert_eq!(base.response.as_ref().unwrap().status, Some(401));
586
587        // A layer's response never populates an empty base either.
588        let mut empty = CompiledRoute::new("tool:x");
589        let mut with_resp = CompiledRoute::new("tool:x");
590        with_resp.response = Some(DenyResponse {
591            status: Some(418),
592            ..Default::default()
593        });
594        empty.apply_layer(with_resp);
595        assert!(empty.response.is_none());
596    }
597
598    #[test]
599    fn phase_set_basic() {
600        let mut set = PhaseSet::new();
601        assert!(set.is_empty());
602        set.insert(Phase::Policy);
603        set.insert(Phase::Result);
604        assert!(set.contains(Phase::Policy));
605        assert!(set.contains(Phase::Result));
606        assert!(!set.contains(Phase::Args));
607        assert!(!set.contains(Phase::PostPolicy));
608        assert!(!set.is_empty());
609    }
610
611    #[test]
612    fn compiled_route_declared_phases() {
613        let mut route = CompiledRoute::new("get_compensation");
614        assert!(route.declared_phases().is_empty());
615
616        route.policy.push(Effect::When {
617            condition: Expression::Condition(Condition::IsTrue {
618                key: "authenticated".into(),
619            }),
620            body: vec![Effect::Allow],
621            source: "policy[0]".into(),
622        });
623        let phases = route.declared_phases();
624        assert!(phases.contains(Phase::Policy));
625        assert!(!phases.contains(Phase::Args));
626    }
627
628    #[test]
629    fn literal_from_impls() {
630        // From impls keep test/builder code readable.
631        let r = Rule {
632            condition: Expression::Condition(Condition::Comparison {
633                key: "delegation.depth".into(),
634                op: CompareOp::Gt,
635                value: 2_i64.into(),
636            }),
637            effects: vec![Effect::Deny {
638                reason: Some("too deep".into()),
639                code: None,
640            }],
641            source: "policy[0]".into(),
642        };
643        if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition {
644            assert_eq!(value, Literal::Int(2));
645        } else {
646            panic!("expected Comparison");
647        }
648    }
649
650    #[test]
651    fn rule_serde_roundtrip() {
652        let r = Rule {
653            condition: Expression::And(vec![
654                Expression::Condition(Condition::IsTrue {
655                    key: "authenticated".into(),
656                }),
657                Expression::Condition(Condition::Comparison {
658                    key: "delegation.depth".into(),
659                    op: CompareOp::LtEq,
660                    value: 3_i64.into(),
661                }),
662            ]),
663            effects: vec![Effect::Allow],
664            source: "policy[1]".into(),
665        };
666        let json = serde_json::to_string(&r).unwrap();
667        let back: Rule = serde_json::from_str(&json).unwrap();
668        // No PartialEq on Rule (would force PartialEq on Action's variants
669        // with floats etc.); spot-check the discriminator path instead.
670        assert!(matches!(back.effects.as_slice(), [Effect::Allow]));
671        assert_eq!(back.source, "policy[1]");
672    }
673
674    #[test]
675    fn compiled_route_serde_skips_empty_phases() {
676        let route = CompiledRoute::new("ping");
677        let json = serde_json::to_string(&route).unwrap();
678        // Empty phase vecs should not serialize — keeps audit logs clean.
679        assert_eq!(json, r#"{"route_key":"ping"}"#);
680    }
681
682    #[test]
683    fn apply_layer_appends_policy_and_post_policy_in_evaluation_order() {
684        // Start with global (least specific), then layer route on top.
685        // After: global.policy[0] runs first, route.policy[0] runs second.
686        let mut effective = CompiledRoute::new("route.get_compensation");
687        // Seed effective with global content (simulating having already
688        // applied the global layer once).
689        effective.policy.push(Effect::When {
690            condition: Expression::Always,
691            body: vec![Effect::Allow],
692            source: "global.policy[0]".into(),
693        });
694        effective.post_policy.push(Effect::When {
695            condition: Expression::Always,
696            body: vec![Effect::Allow],
697            source: "global.post_policy[0]".into(),
698        });
699
700        // Now apply the route-specific layer on top.
701        let mut route_layer = CompiledRoute::new("ignored");
702        route_layer.policy.push(Effect::When {
703            condition: Expression::Always,
704            body: vec![Effect::Allow],
705            source: "route.policy[0]".into(),
706        });
707        route_layer.post_policy.push(Effect::When {
708            condition: Expression::Always,
709            body: vec![Effect::Allow],
710            source: "route.post_policy[0]".into(),
711        });
712
713        effective.apply_layer(route_layer);
714
715        // global ran first, route ran second — first-deny-wins respects
716        // the hierarchy.
717        assert_eq!(effective.policy.len(), 2);
718        match &effective.policy[0] {
719            Effect::When { source, .. } => assert_eq!(source, "global.policy[0]"),
720            _ => panic!(),
721        }
722        match &effective.policy[1] {
723            Effect::When { source, .. } => assert_eq!(source, "route.policy[0]"),
724            _ => panic!(),
725        }
726        assert_eq!(effective.post_policy.len(), 2);
727
728        // route_key preserved (apply_layer doesn't touch identity).
729        assert_eq!(effective.route_key, "route.get_compensation");
730    }
731
732    #[test]
733    fn apply_layer_args_more_specific_wins_on_field_collision() {
734        use crate::pipeline::{FieldRule, Pipeline, Stage, TypeCheck};
735
736        // Start with the default (less specific) layer.
737        let mut effective = CompiledRoute::new("route.X");
738        effective.args.push(FieldRule {
739            field: "id".into(),
740            pipeline: Pipeline {
741                stages: vec![Stage::Type(TypeCheck::Str)],
742            },
743            source: "default.args.id".into(),
744        });
745        effective.args.push(FieldRule {
746            field: "trace_id".into(),
747            pipeline: Pipeline {
748                stages: vec![Stage::Type(TypeCheck::Str)],
749            },
750            source: "default.args.trace_id".into(),
751        });
752
753        // Layer route (more specific) on top — it redefines `id`.
754        let mut route_layer = CompiledRoute::new("ignored");
755        route_layer.args.push(FieldRule {
756            field: "id".into(),
757            pipeline: Pipeline {
758                stages: vec![Stage::Type(TypeCheck::Uuid)],
759            },
760            source: "route.args.id".into(),
761        });
762
763        effective.apply_layer(route_layer);
764
765        assert_eq!(effective.args.len(), 2);
766        // `id` is now the route's (Uuid), not the default's (Str).
767        let id_rule = effective.args.iter().find(|f| f.field == "id").unwrap();
768        assert!(matches!(
769            id_rule.pipeline.stages[0],
770            Stage::Type(TypeCheck::Uuid)
771        ));
772        assert_eq!(id_rule.source, "route.args.id");
773        // `trace_id` survives from the default — route didn't touch it.
774        let trace = effective
775            .args
776            .iter()
777            .find(|f| f.field == "trace_id")
778            .unwrap();
779        assert_eq!(trace.source, "default.args.trace_id");
780    }
781
782    #[test]
783    fn apply_layer_plugin_overrides_more_specific_wins() {
784        use crate::plugin_decl::PluginOverride;
785
786        // Default (less specific) layer.
787        let mut effective = CompiledRoute::new("route.X");
788        effective.plugin_overrides.insert(
789            "rate_limiter".into(),
790            PluginOverride {
791                on_error: Some("ignore".into()),
792                ..Default::default()
793            },
794        );
795        effective.plugin_overrides.insert(
796            "audit_logger".into(),
797            PluginOverride {
798                on_error: Some("ignore".into()),
799                ..Default::default()
800            },
801        );
802
803        // Route (more specific) layer overrides rate_limiter.
804        let mut route_layer = CompiledRoute::new("ignored");
805        route_layer.plugin_overrides.insert(
806            "rate_limiter".into(),
807            PluginOverride {
808                on_error: Some("fail".into()),
809                ..Default::default()
810            },
811        );
812
813        effective.apply_layer(route_layer);
814
815        assert_eq!(effective.plugin_overrides.len(), 2);
816        assert_eq!(
817            effective.plugin_overrides["rate_limiter"]
818                .on_error
819                .as_deref(),
820            Some("fail"),
821            "route's override wins on collision",
822        );
823        // audit_logger untouched — route didn't redefine it.
824        assert_eq!(
825            effective.plugin_overrides["audit_logger"]
826                .on_error
827                .as_deref(),
828            Some("ignore"),
829        );
830    }
831
832    #[test]
833    fn apply_layer_chained_walks_hierarchy_in_specificity_order() {
834        // Build effective policy by applying layers least-to-most-specific.
835        // Mirrors how AplConfigVisitor will compose global/default/tag/route.
836        let mut effective = CompiledRoute::new("route.get_compensation");
837
838        let mut global = CompiledRoute::default();
839        global.policy.push(Effect::When {
840            condition: Expression::Always,
841            body: vec![Effect::Allow],
842            source: "global.policy[0]".into(),
843        });
844
845        let mut default = CompiledRoute::default();
846        default.policy.push(Effect::When {
847            condition: Expression::Always,
848            body: vec![Effect::Allow],
849            source: "default.policy[0]".into(),
850        });
851
852        let mut tag = CompiledRoute::default();
853        tag.policy.push(Effect::When {
854            condition: Expression::Always,
855            body: vec![Effect::Allow],
856            source: "tag.hr.policy[0]".into(),
857        });
858
859        let mut route = CompiledRoute::default();
860        route.policy.push(Effect::When {
861            condition: Expression::Always,
862            body: vec![Effect::Allow],
863            source: "route.policy[0]".into(),
864        });
865
866        effective.apply_layer(global);
867        effective.apply_layer(default);
868        effective.apply_layer(tag);
869        effective.apply_layer(route);
870
871        // Order of calls = order of evaluation. global runs first,
872        // route runs last (first-deny-wins lets globals deny early).
873        let sources: Vec<&str> = effective
874            .policy
875            .iter()
876            .map(|s| match s {
877                Effect::When { source, .. } => source.as_str(),
878                _ => "<not-rule>",
879            })
880            .collect();
881        assert_eq!(
882            sources,
883            vec![
884                "global.policy[0]",
885                "default.policy[0]",
886                "tag.hr.policy[0]",
887                "route.policy[0]",
888            ]
889        );
890    }
891
892    // ----- E3: parallel-purity validation -----
893
894    #[test]
895    fn validate_parallel_pure_block_passes() {
896        // A parallel block of read-only effects validates clean.
897        let effect = Effect::Parallel(vec![
898            Effect::Plugin {
899                name: "rate_limiter".into(),
900            },
901            Effect::Plugin {
902                name: "audit".into(),
903            },
904            Effect::Allow,
905        ]);
906        assert!(effect.validate_parallel_purity().is_ok());
907    }
908
909    #[test]
910    fn validate_parallel_rejects_field_op() {
911        // FieldOp would silently lose its mutation in a discarded
912        // branch — config-load surfaces this loudly.
913        let effect = Effect::Parallel(vec![
914            Effect::Plugin {
915                name: "audit".into(),
916            },
917            Effect::FieldOp {
918                path: "args.ssn".into(),
919                stages: vec![],
920            },
921        ]);
922        let err = effect.validate_parallel_purity().unwrap_err();
923        assert!(err.contains("mutation"), "got: {}", err);
924        assert!(err.contains("FieldOp"), "should name the offender: {}", err);
925    }
926
927    #[test]
928    fn validate_parallel_rejects_delegate() {
929        // Same reason as FieldOp — the minted token would land in a
930        // bag that gets discarded.
931        let delegate = Effect::Delegate(crate::step::DelegateStep {
932            plugin_name: "workday".into(),
933            config_override: None,
934            on_error: None,
935            source: "test".into(),
936        });
937        let effect = Effect::Parallel(vec![Effect::Allow, delegate]);
938        let err = effect.validate_parallel_purity().unwrap_err();
939        assert!(err.contains("mutation"));
940    }
941
942    #[test]
943    fn validate_parallel_recurses_into_nested_parallel() {
944        // `parallel → sequential → parallel(field_op)` — the inner
945        // parallel still illegal. Recursion must catch it.
946        let inner_parallel = Effect::Parallel(vec![Effect::FieldOp {
947            path: "args.x".into(),
948            stages: vec![],
949        }]);
950        let outer = Effect::Parallel(vec![Effect::Sequential(vec![
951            Effect::Allow,
952            inner_parallel,
953        ])]);
954        assert!(outer.validate_parallel_purity().is_err());
955    }
956
957    #[test]
958    fn validate_top_level_sequential_allows_mutations() {
959        // FieldOp / Delegate are allowed under Sequential (or at top
960        // level) — only Parallel rejects them.
961        let effect = Effect::Sequential(vec![
962            Effect::FieldOp {
963                path: "args.ssn".into(),
964                stages: vec![],
965            },
966            Effect::Allow,
967        ]);
968        assert!(effect.validate_parallel_purity().is_ok());
969    }
970
971    #[test]
972    fn validate_contains_mutation_classifies_each_variant() {
973        // White-box check on the helper so future Effect additions
974        // get flagged here when they should be classified.
975        assert!(!Effect::Allow.contains_mutation());
976        assert!(!Effect::Deny {
977            reason: None,
978            code: None
979        }
980        .contains_mutation());
981        assert!(!Effect::Plugin { name: "x".into() }.contains_mutation());
982        assert!(!Effect::Taint {
983            label: "x".into(),
984            scopes: vec![],
985        }
986        .contains_mutation());
987
988        assert!(Effect::FieldOp {
989            path: "args.x".into(),
990            stages: vec![],
991        }
992        .contains_mutation());
993        assert!(Effect::Delegate(crate::step::DelegateStep {
994            plugin_name: "x".into(),
995            config_override: None,
996            on_error: None,
997            source: "x".into(),
998        })
999        .contains_mutation());
1000
1001        // Composite — mutates iff any child mutates.
1002        let pure_seq = Effect::Sequential(vec![Effect::Allow]);
1003        assert!(!pure_seq.contains_mutation());
1004        let dirty_seq = Effect::Sequential(vec![Effect::FieldOp {
1005            path: "args.x".into(),
1006            stages: vec![],
1007        }]);
1008        assert!(dirty_seq.contains_mutation());
1009    }
1010}