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}