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}