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}