Skip to main content

apl_core/
evaluator.rs

1// Location: ./crates/apl-core/src/evaluator.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// APL evaluator — walks the IR against an AttributeBag and returns a Decision.
7//
8// The evaluator is sync and infallible by design. Missing attributes resolve
9// to `false` (DSL spec §2.6); operator type mismatches resolve to `false`.
10// The host drives the four phases separately by calling `evaluate_rules` once
11// per declared phase — phase orchestration lives in `apl-cpex`.
12//
13// Semantics anchored in:
14//   - DSL spec apl-dsl-spec.md §2 (operators), §3 (actions), §8.1 (require)
15//   - apl-design.md §7 (native fast-path, sync inside async outer)
16
17use std::sync::Arc;
18
19use crate::attributes::{AttributeBag, AttributeValue};
20use crate::pipeline::{Pipeline, ScanKind, Stage, TaintEvent, TaintScope, TypeCheck};
21use crate::rules::{CompareOp, Condition, Effect, Expression, Literal, Rule};
22use crate::step::{PdpResolver, PluginInvocation, PluginInvoker};
23
24/// Outcome of evaluating a phase's rule list.
25#[derive(Debug, Clone, PartialEq)]
26pub enum Decision {
27    /// No `deny` rule fired. Pipeline proceeds.
28    Allow,
29    /// A `deny` rule fired. Pipeline halts.
30    Deny {
31        reason: Option<String>,
32        /// `Rule.source` of the rule that produced the deny — for audit logs.
33        rule_source: String,
34    },
35}
36
37/// Evaluate a phase's rules against the bag.
38///
39/// Spec §3 semantics:
40/// - First `deny` halts; subsequent rules / effects don't run.
41/// - `allow` effects *do not* short-circuit — evaluation continues to
42///   the next effect (then to the next rule).
43/// - If no rule denies, the phase resolves to `Decision::Allow`.
44///
45/// Sync fast path — only handles control effects (`Allow` / `Deny`).
46/// Rules containing `Plugin` / `Delegate` / `Taint` effects must go
47/// through [`evaluate_steps`] instead, which has the async invoker
48/// traits wired up. This function silently skips non-control effects
49/// so a rule list mixed with `Plugin` still terminates cleanly on a
50/// later `Deny` — but the side effects don't fire. Caller's job to
51/// pick the right entry point for the effects in the rules.
52pub fn evaluate_rules(rules: &[Rule], bag: &AttributeBag) -> Decision {
53    for rule in rules {
54        if !eval_expression(&rule.condition, bag) {
55            continue;
56        }
57        for effect in &rule.effects {
58            match effect {
59                Effect::Allow => continue,
60                Effect::Deny { reason, code } => {
61                    // `code` override on the effect takes precedence
62                    // over the auto-generated rule source position,
63                    // so author-stable categories survive YAML edits.
64                    let rule_source = code.clone().unwrap_or_else(|| rule.source.clone());
65                    return Decision::Deny {
66                        reason: reason.clone(),
67                        rule_source,
68                    };
69                },
70                // Plugin / Delegate / Taint require the async step
71                // path; ignore here. See doc comment above.
72                _ => continue,
73            }
74        }
75    }
76    Decision::Allow
77}
78
79fn eval_expression(expr: &Expression, bag: &AttributeBag) -> bool {
80    match expr {
81        Expression::Condition(c) => eval_condition(c, bag),
82        Expression::And(parts) => parts.iter().all(|e| eval_expression(e, bag)),
83        Expression::Or(parts) => parts.iter().any(|e| eval_expression(e, bag)),
84        Expression::Not(inner) => !eval_expression(inner, bag),
85        Expression::Always => true,
86    }
87}
88
89fn eval_condition(cond: &Condition, bag: &AttributeBag) -> bool {
90    match cond {
91        Condition::IsTrue { key } => bag.get_bool(key).unwrap_or(false),
92        Condition::IsFalse { key } => !bag.get_bool(key).unwrap_or(false),
93        Condition::Exists { key } => bag.contains(key),
94        Condition::Comparison { key, op, value } => eval_comparison(key, *op, value, bag),
95        Condition::InSet {
96            value_key,
97            set_key,
98            negate,
99        } => {
100            let in_set = match (bag.get_string(value_key), bag.get_string_set(set_key)) {
101                (Some(s), Some(set)) => set.contains(s),
102                _ => false, // missing key or wrong type → not in set
103            };
104            if *negate {
105                !in_set
106            } else {
107                in_set
108            }
109        },
110    }
111}
112
113fn eval_comparison(key: &str, op: CompareOp, lit: &Literal, bag: &AttributeBag) -> bool {
114    let attr = match bag.get(key) {
115        Some(v) => v,
116        None => return false, // missing → false (spec §2.6)
117    };
118
119    match op {
120        CompareOp::Contains => match (attr, lit) {
121            (AttributeValue::StringSet(_), Literal::String(s)) => bag.set_contains(key, s),
122            _ => false,
123        },
124        CompareOp::Eq => values_eq(attr, lit),
125        CompareOp::NotEq => !values_eq(attr, lit),
126        CompareOp::Gt | CompareOp::GtEq | CompareOp::Lt | CompareOp::LtEq => {
127            numeric_compare(attr, lit, op)
128        },
129    }
130}
131
132fn values_eq(attr: &AttributeValue, lit: &Literal) -> bool {
133    match (attr, lit) {
134        (AttributeValue::Bool(a), Literal::Bool(b)) => a == b,
135        (AttributeValue::Int(a), Literal::Int(b)) => a == b,
136        (AttributeValue::Float(a), Literal::Float(b)) => a == b,
137        (AttributeValue::String(a), Literal::String(b)) => a == b,
138        // Int↔Float promotion for equality (matches AttributeBag::get_float).
139        (AttributeValue::Int(a), Literal::Float(b)) => (*a as f64) == *b,
140        (AttributeValue::Float(a), Literal::Int(b)) => *a == (*b as f64),
141        _ => false,
142    }
143}
144
145fn numeric_compare(attr: &AttributeValue, lit: &Literal, op: CompareOp) -> bool {
146    let (a, b) = match (attr, lit) {
147        (AttributeValue::Int(a), Literal::Int(b)) => (*a as f64, *b as f64),
148        (AttributeValue::Int(a), Literal::Float(b)) => (*a as f64, *b),
149        (AttributeValue::Float(a), Literal::Int(b)) => (*a, *b as f64),
150        (AttributeValue::Float(a), Literal::Float(b)) => (*a, *b),
151        // Non-numeric operands: order operators don't apply → false (spec §2.3).
152        _ => return false,
153    };
154    match op {
155        CompareOp::Gt => a > b,
156        CompareOp::GtEq => a >= b,
157        CompareOp::Lt => a < b,
158        CompareOp::LtEq => a <= b,
159        _ => unreachable!("numeric_compare called with non-numeric op"),
160    }
161}
162
163// =====================================================================
164// Async effect evaluator (policy: / post_policy: walks Vec<Effect>)
165// =====================================================================
166
167/// Walk an Effect list against the bag, dispatching PDP calls via `pdp`
168/// and plugin invocations via `plugins`. Returns the phase's overall
169/// decision.
170///
171/// Semantics (DSL §3, §7.5):
172/// - `Effect::When` — evaluate the condition; if true, run the body in
173///   order with the same first-deny-wins logic.
174/// - `Effect::Pdp` — call resolver; on Allow run `on_allow` reactions and
175///   continue; on Deny run `on_deny` reactions and return the deny
176///   (reactions can override with their own deny, but cannot turn a deny
177///   into an allow).
178/// - `Effect::Plugin` — invoke; Allow continues, Deny returns.
179/// - `Effect::Delegate` — mint downstream credential; writes
180///   `delegation.granted.*` keys back into the bag; deny-on-failure unless
181///   the step's `on_error` overrides.
182/// - `Effect::Taint` — record the label; never halts.
183/// - `Effect::FieldOp` — apply a pipe chain to `args.X` / `result.X`;
184///   may set `args_modified` / `result_modified`.
185/// - `Effect::Sequential` — run children in order, halt on first Deny.
186/// - `Effect::Parallel` — run children concurrently, abort on first Deny.
187/// - `Effect::Allow` — explicit no-op; continues the phase.
188/// - `Effect::Deny` — halt with the supplied reason/code.
189///
190/// PDP / plugin errors map to a Deny with the error in the reason, per
191/// the design's fail-closed default (DSL §8.9). Pre-E4 `evaluate_steps`
192/// is preserved as a deprecated alias that forwards here.
193#[allow(clippy::too_many_arguments)]
194pub async fn evaluate_effects(
195    effects: &[Effect],
196    bag: &mut AttributeBag,
197    pdp: &Arc<dyn PdpResolver>,
198    plugins: &Arc<dyn PluginInvoker>,
199    delegations: &Arc<dyn crate::step::DelegationInvoker>,
200    phase: crate::step::DispatchPhase,
201    payload: &mut crate::route::RoutePayload,
202) -> StepsEvaluation {
203    let mut taints: Vec<crate::pipeline::TaintEvent> = Vec::new();
204    let mut args_modified = false;
205    let mut result_modified = false;
206    for effect in effects {
207        // Each top-level effect runs against the shared mutable state.
208        // `Effect::When` / `Effect::Pdp` handle their own internal
209        // walking via dispatch_effect's recursive call.
210        let fallback_source = match effect {
211            Effect::When { source, .. } => source.as_str(),
212            _ => "",
213        };
214        match Box::pin(dispatch_effect(
215            effect,
216            fallback_source,
217            bag,
218            pdp,
219            plugins,
220            delegations,
221            phase,
222            &mut taints,
223            &mut args_modified,
224            &mut result_modified,
225            payload,
226        ))
227        .await
228        {
229            EffectOutcome::Continue => {},
230            EffectOutcome::Halt(decision) => {
231                return StepsEvaluation::deny(decision, taints, args_modified, result_modified);
232            },
233        }
234    }
235    StepsEvaluation {
236        decision: Decision::Allow,
237        taints,
238        args_modified,
239        result_modified,
240    }
241}
242
243/// Outcome of `evaluate_effects`: the phase's decision plus taints emitted
244/// by any plugin steps that ran. Taints are accumulated even when the
245/// phase ultimately denies — audit needs to see what the plugins
246/// reported before the deny landed. Empty `taints` is the common case
247/// (most steps are predicates / PDP calls, not label emitters).
248///
249/// `args_modified` / `result_modified` are set when an `Effect::FieldOp`
250/// inside a `do:` body successfully mutated the route payload — the
251/// orchestrator uses them to OR-into the route-level "did anything
252/// change" signals so the host knows to re-serialize the body.
253#[derive(Debug, Clone)]
254pub struct StepsEvaluation {
255    pub decision: Decision,
256    pub taints: Vec<crate::pipeline::TaintEvent>,
257    pub args_modified: bool,
258    pub result_modified: bool,
259}
260
261impl StepsEvaluation {
262    fn deny(
263        d: Decision,
264        taints: Vec<crate::pipeline::TaintEvent>,
265        args_modified: bool,
266        result_modified: bool,
267    ) -> Self {
268        Self {
269            decision: d,
270            taints,
271            args_modified,
272            result_modified,
273        }
274    }
275}
276
277/// Outcome of dispatching one effect. Internal control-flow signal —
278/// never serialized, never exposed in the IR. Sits between the per-
279/// effect dispatch (When / Pdp / Plugin / Delegate / Taint / Allow /
280/// Deny / FieldOp / Sequential / Parallel) and the caller's "do I keep
281/// walking the effects list or halt?" loop.
282enum EffectOutcome {
283    /// Effect completed without producing a Deny — caller moves on to
284    /// the next effect in the surrounding list.
285    Continue,
286    /// Effect produced a Deny decision — caller halts the rest of the
287    /// surrounding list, the rest of the phase, and the route.
288    Halt(Decision),
289}
290
291/// Run a single effect against the evaluator's state. Called by both
292/// `evaluate_effects` (top-level walk of `policy:` / `post_policy:`)
293/// and by recursive arms (Sequential, Parallel, When body, Pdp
294/// reactions), so there's exactly one place that knows how each
295/// effect kind dispatches.
296///
297/// `fallback_source` is the rule-source-position string used as the
298/// `rule_source` field on a `Decision::Deny` when the effect itself
299/// doesn't carry an explicit code (i.e. `Effect::Deny { code: None }`,
300/// or a deny coming back from a plugin / delegator without overriding
301/// the default).
302#[allow(clippy::too_many_arguments)]
303async fn dispatch_effect(
304    effect: &Effect,
305    fallback_source: &str,
306    bag: &mut AttributeBag,
307    pdp: &Arc<dyn PdpResolver>,
308    plugins: &Arc<dyn PluginInvoker>,
309    delegations: &Arc<dyn crate::step::DelegationInvoker>,
310    phase: crate::step::DispatchPhase,
311    taints: &mut Vec<crate::pipeline::TaintEvent>,
312    args_modified: &mut bool,
313    result_modified: &mut bool,
314    payload: &mut crate::route::RoutePayload,
315) -> EffectOutcome {
316    match effect {
317        Effect::Allow => EffectOutcome::Continue,
318
319        Effect::Deny { reason, code } => {
320            // Author-supplied code overrides the auto-generated source
321            // position. Lets MCP clients dispatch on stable categories
322            // (`quota.exceeded`) rather than positional codes that
323            // shift with YAML edits.
324            let rule_source = code.clone().unwrap_or_else(|| fallback_source.to_string());
325            EffectOutcome::Halt(Decision::Deny {
326                reason: reason.clone(),
327                rule_source,
328            })
329        },
330
331        Effect::Plugin { name } => {
332            match plugins
333                .invoke(name, bag, PluginInvocation::Step { phase })
334                .await
335            {
336                Ok(outcome) => {
337                    // Plugins can emit taints regardless of decision —
338                    // collect first, then act on the decision.
339                    taints.extend(outcome.taints);
340                    match outcome.decision {
341                        Decision::Allow => EffectOutcome::Continue,
342                        deny @ Decision::Deny { .. } => EffectOutcome::Halt(deny),
343                    }
344                },
345                Err(e) => EffectOutcome::Halt(Decision::Deny {
346                    reason: Some(format!("plugin `{}` error: {}", name, e)),
347                    rule_source: format!("plugin:{}", name),
348                }),
349            }
350        },
351
352        Effect::Delegate(delegate_step) => {
353            match delegations.delegate(delegate_step).await {
354                Ok(outcome) => match &outcome.decision {
355                    Decision::Allow => {
356                        // Surface granted_* keys into the bag so
357                        // downstream rules in this same step list can
358                        // read them (`require(delegation.granted.permissions
359                        // contains "X")`, etc.).
360                        use crate::attributes::AttributeValue;
361                        use crate::step::delegation_bag_keys as bk;
362
363                        bag.set(bk::GRANTED, AttributeValue::Bool(true));
364                        if !outcome.granted_permissions.is_empty() {
365                            let set: std::collections::HashSet<String> =
366                                outcome.granted_permissions.iter().cloned().collect();
367                            bag.set(bk::GRANTED_PERMISSIONS, AttributeValue::StringSet(set));
368                        }
369                        if let Some(aud) = &outcome.granted_audience {
370                            bag.set(bk::GRANTED_AUDIENCE, aud.clone());
371                        }
372                        if let Some(exp) = &outcome.granted_expires_at {
373                            bag.set(bk::GRANTED_EXPIRES_AT, exp.clone());
374                        }
375                        EffectOutcome::Continue
376                    },
377                    Decision::Deny { .. } => {
378                        // Apply the step's on_error policy. Default
379                        // ("deny") halts; "continue" lets the pipeline
380                        // keep going so subsequent rules can branch on
381                        // the absent `delegation.granted` flag.
382                        let on_error = delegate_step
383                            .on_error
384                            .as_deref()
385                            .unwrap_or("deny")
386                            .to_ascii_lowercase();
387                        if on_error == "continue" {
388                            EffectOutcome::Continue
389                        } else {
390                            EffectOutcome::Halt(outcome.decision)
391                        }
392                    },
393                },
394                Err(e) => {
395                    // Transport / lookup failure. on_error treats this
396                    // the same way as a plugin-side deny.
397                    let on_error = delegate_step
398                        .on_error
399                        .as_deref()
400                        .unwrap_or("deny")
401                        .to_ascii_lowercase();
402                    if on_error == "continue" {
403                        EffectOutcome::Continue
404                    } else {
405                        EffectOutcome::Halt(Decision::Deny {
406                            reason: Some(format!(
407                                "delegate `{}` error: {}",
408                                delegate_step.plugin_name, e
409                            )),
410                            rule_source: delegate_step.source.clone(),
411                        })
412                    }
413                },
414            }
415        },
416
417        Effect::Taint { label, scopes } => {
418            // Emit the taint into the phase's accumulator so it flows
419            // into `RouteDecision.taints`. Apl-cpex's invoker handles
420            // the session-store persistence side at request end — here
421            // we only record the event. Scopes come straight from the
422            // parser (`taint(label, session, message)` syntax).
423            taints.push(crate::pipeline::TaintEvent {
424                label: label.clone(),
425                scopes: scopes.clone(),
426            });
427            EffectOutcome::Continue
428        },
429
430        Effect::FieldOp { path, stages } => {
431            dispatch_field_op(
432                path,
433                stages,
434                fallback_source,
435                bag,
436                plugins,
437                phase,
438                taints,
439                args_modified,
440                result_modified,
441                payload,
442            )
443            .await
444        },
445
446        Effect::Sequential(effects) => {
447            // Semantically the same as inlining the list into the
448            // enclosing scope — walk in order, stop on first Halt.
449            // The variant exists for explicit grouping and to pair
450            // with `Parallel` in the IR.
451            for inner in effects {
452                match Box::pin(dispatch_effect(
453                    inner,
454                    fallback_source,
455                    bag,
456                    pdp,
457                    plugins,
458                    delegations,
459                    phase,
460                    taints,
461                    args_modified,
462                    result_modified,
463                    payload,
464                ))
465                .await
466                {
467                    EffectOutcome::Continue => continue,
468                    halt @ EffectOutcome::Halt(_) => return halt,
469                }
470            }
471            EffectOutcome::Continue
472        },
473
474        Effect::Parallel(effects) => {
475            // `dispatch_parallel` returns an explicit `BoxFuture<'_, _>`
476            // (Send by construction) so the recursive
477            // dispatch_effect → dispatch_parallel → dispatch_effect
478            // chain doesn't trip the compiler's Send-inference cycle.
479            dispatch_parallel(
480                effects,
481                fallback_source,
482                bag,
483                pdp,
484                plugins,
485                delegations,
486                phase,
487                taints,
488                payload,
489            )
490            .await
491        },
492
493        Effect::When {
494            condition,
495            body,
496            source,
497        } => {
498            // Predicate-gated body — replaces the historical
499            // `Step::Rule`. Skip silently when the condition is false;
500            // otherwise walk the body in order and halt on first Deny.
501            if !eval_expression(condition, bag) {
502                return EffectOutcome::Continue;
503            }
504            for inner in body {
505                match Box::pin(dispatch_effect(
506                    inner,
507                    source,
508                    bag,
509                    pdp,
510                    plugins,
511                    delegations,
512                    phase,
513                    taints,
514                    args_modified,
515                    result_modified,
516                    payload,
517                ))
518                .await
519                {
520                    EffectOutcome::Continue => continue,
521                    halt @ EffectOutcome::Halt(_) => return halt,
522                }
523            }
524            EffectOutcome::Continue
525        },
526
527        Effect::Pdp {
528            call,
529            on_allow,
530            on_deny,
531        } => {
532            // External PDP call — replaces `Step::Pdp`. Reactions run
533            // through the same dispatch_effect path (recursively).
534            match pdp.evaluate(call, bag).await {
535                Ok(pdp_result) => match pdp_result.decision {
536                    Decision::Allow => {
537                        // Walk on_allow; if it ends without a Halt the
538                        // PDP allow stands and we continue.
539                        for inner in on_allow {
540                            match Box::pin(dispatch_effect(
541                                inner,
542                                fallback_source,
543                                bag,
544                                pdp,
545                                plugins,
546                                delegations,
547                                phase,
548                                taints,
549                                args_modified,
550                                result_modified,
551                                payload,
552                            ))
553                            .await
554                            {
555                                EffectOutcome::Continue => continue,
556                                halt @ EffectOutcome::Halt(_) => return halt,
557                            }
558                        }
559                        EffectOutcome::Continue
560                    },
561                    deny @ Decision::Deny { .. } => {
562                        // Reactions can override the PDP's deny reason
563                        // (e.g. `on_deny: [deny "..."]`) but cannot
564                        // upgrade the deny to allow — if reactions
565                        // walked clean, the PDP's original deny stands.
566                        for inner in on_deny {
567                            if let EffectOutcome::Halt(reaction_decision) =
568                                Box::pin(dispatch_effect(
569                                    inner,
570                                    fallback_source,
571                                    bag,
572                                    pdp,
573                                    plugins,
574                                    delegations,
575                                    phase,
576                                    taints,
577                                    args_modified,
578                                    result_modified,
579                                    payload,
580                                ))
581                                .await
582                            {
583                                return EffectOutcome::Halt(reaction_decision);
584                            }
585                        }
586                        EffectOutcome::Halt(deny)
587                    },
588                },
589                Err(e) => EffectOutcome::Halt(Decision::Deny {
590                    reason: Some(format!("PDP error: {}", e)),
591                    rule_source: format!("pdp:{:?}", call.dialect),
592                }),
593            }
594        },
595    }
596}
597
598/// Run a list of effects concurrently. Each branch gets its own
599/// cloned bag and payload — mutations inside a branch don't
600/// propagate back to the shared outer state. Taints from every
601/// branch are merged into the outer `taints` vec (taints are
602/// append-only event logs, safe to concatenate). First Halt by
603/// branch index wins; the remaining branches are aborted via
604/// `cpex_orchestration::run_branches`'s `short_circuit_on_deny`.
605///
606/// Config-load already rejected `FieldOp` / `Delegate` here via
607/// [`Effect::validate_parallel_purity`], so at runtime we trust the
608/// IR not to contain mutation effects.
609///
610/// # Concurrency model (E3.2)
611///
612/// Built on [`cpex_orchestration::run_branches`] — the same JoinSet
613/// + abort-on-deny primitive `cpex-core`'s executor uses for its
614/// concurrent phase. Each branch is `tokio::spawn`ed onto the
615/// runtime, so branches get true OS-thread parallelism (vs. the v1
616/// implementation's `join_all`, which only interleaved on one
617/// task). To meet the `'static + Send` bounds for spawning, the
618/// invoker references are `&Arc<dyn ...>` — we `Arc::clone` an
619/// owned reference into each branch closure.
620///
621/// Note: no per-branch timeout. The DSL doesn't expose one, and
622/// plugin-level timeouts upstream of this call (in cpex-core's
623/// executor) bound individual plugin invocations. If a route ever
624/// needs a per-branch budget the orchestration crate already
625/// supports `BranchConfig::timeout_per_branch` — wire it through a
626/// `Effect::Parallel` extension if/when needed.
627// Returns an explicit `BoxFuture` rather than `impl Future` so the
628// caller (`dispatch_effect`'s `Effect::Parallel` arm, which is itself
629// `async fn`) can break the Send-inference cycle this would otherwise
630// introduce: dispatch_effect's opaque return type would depend on
631// dispatch_parallel's, and dispatch_parallel spawns futures that
632// recursively re-enter dispatch_effect. A concrete `BoxFuture` is
633// `Pin<Box<dyn Future + Send + 'a>>` — already Send by construction,
634// no inference required.
635fn dispatch_parallel<'a>(
636    effects: &'a [Effect],
637    fallback_source: &'a str,
638    bag: &'a AttributeBag,
639    pdp: &'a Arc<dyn PdpResolver>,
640    plugins: &'a Arc<dyn PluginInvoker>,
641    delegations: &'a Arc<dyn crate::step::DelegationInvoker>,
642    phase: crate::step::DispatchPhase,
643    taints: &'a mut Vec<crate::pipeline::TaintEvent>,
644    payload: &'a crate::route::RoutePayload,
645) -> futures::future::BoxFuture<'a, EffectOutcome> {
646    Box::pin(async move {
647        use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch};
648
649        if effects.is_empty() {
650            return EffectOutcome::Continue;
651        }
652
653        // Build one spawn-ready branch future per effect. Each branch
654        // owns:
655        //   * a cloned bag and payload — branch mutations stay local;
656        //   * cloned Arcs to the invokers — `'static + Send`, ready for
657        //     `tokio::spawn`;
658        //   * an owned copy of the effect to evaluate (clone is cheap
659        //     for the variants `Parallel` can hold: Allow, Deny, Plugin,
660        //     Taint, Sequential, Parallel, When, Pdp).
661        let mut branches: Vec<ErasedBranch<(EffectOutcome, Vec<crate::pipeline::TaintEvent>)>> =
662            Vec::with_capacity(effects.len());
663        for effect in effects.iter() {
664            let effect = effect.clone();
665            let fallback = fallback_source.to_string();
666            let mut branch_bag = bag.clone();
667            let mut branch_payload = payload.clone();
668            let pdp = Arc::clone(pdp);
669            let plugins = Arc::clone(plugins);
670            let delegations = Arc::clone(delegations);
671            branches.push(Box::pin(async move {
672                let mut branch_taints: Vec<crate::pipeline::TaintEvent> = Vec::new();
673                let mut branch_args_modified = false;
674                let mut branch_result_modified = false;
675                let outcome = Box::pin(dispatch_effect(
676                    &effect,
677                    &fallback,
678                    &mut branch_bag,
679                    &pdp,
680                    &plugins,
681                    &delegations,
682                    phase,
683                    &mut branch_taints,
684                    &mut branch_args_modified,
685                    &mut branch_result_modified,
686                    &mut branch_payload,
687                ))
688                .await;
689                (outcome, branch_taints)
690            }));
691        }
692
693        // `is_deny` short-circuits the moment any branch returns
694        // `EffectOutcome::Halt(_)`. The remaining branches get
695        // `BranchOutcome::Aborted` and we drop their (already-cancelled)
696        // futures. Taints from already-completed branches still land.
697        let cfg = BranchConfig {
698            timeout_per_branch: None,
699            short_circuit_on_deny: true,
700        };
701        let outcomes = run_branches(
702            branches,
703            cfg,
704            |v: &(EffectOutcome, Vec<crate::pipeline::TaintEvent>)| {
705                matches!(v.0, EffectOutcome::Halt(_))
706            },
707        )
708        .await;
709
710        // Aggregate in input order: append every branch's taints; pick
711        // the first Halt (by branch index, not wall-clock order) as the
712        // overall result. Aborted / panicked branches contribute no
713        // taints — they didn't run to completion. A panicked branch is
714        // *not* converted into a Halt; we log via `tracing::warn!` and
715        // continue. (A misbehaving plugin shouldn't take down the
716        // parallel block any more than it would the host process.)
717        let mut first_halt: Option<Decision> = None;
718        for (idx, outcome) in outcomes.into_iter().enumerate() {
719            match outcome {
720                BranchOutcome::Completed((effect_outcome, branch_taints)) => {
721                    taints.extend(branch_taints);
722                    if first_halt.is_none() {
723                        if let EffectOutcome::Halt(d) = effect_outcome {
724                            first_halt = Some(d);
725                        }
726                    }
727                },
728                BranchOutcome::Aborted => {
729                    // Short-circuit cancelled this branch — intentional,
730                    // no diagnostic needed.
731                },
732                BranchOutcome::TimedOut => {
733                    // Unreachable today (no per-branch timeout
734                    // configured). Treat as a no-op if it ever fires
735                    // post-config-extension.
736                },
737                BranchOutcome::Panicked(msg) => {
738                    // A panicking branch is a misbehaving plugin/effect;
739                    // dropping its output (no Halt, no taints) keeps the
740                    // parallel block's other branches intact rather than
741                    // taking the whole block down. apl-core has no
742                    // tracing dep — host integrations that care can
743                    // surface the panic via cpex-core's plugin error
744                    // path. `idx`/`msg` are eaten here.
745                    let _ = (idx, msg);
746                },
747            }
748        }
749
750        match first_halt {
751            Some(d) => EffectOutcome::Halt(d),
752            None => EffectOutcome::Continue,
753        }
754    })
755}
756
757/// Apply a `FieldOp` effect — resolve the path in args/result, run
758/// the pipeline stages, write the outcome back into the payload.
759///
760/// Out-of-phase ops are silent no-ops: a Pre-phase rule with
761/// `result.X | redact` skips because the result hasn't been produced
762/// yet; a Post-phase rule with `args.X | redact` skips because the
763/// args were already sent on the wire. This is intentional so the
764/// same `when:`/`do:` rule body can be reused across phases without
765/// the author needing to branch on phase.
766///
767/// Missing fields skip silently too (same as the args:/result: phase
768/// pipelines) — a pipeline can't transform what isn't there. If the
769/// author needs presence semantics, that's a `require(exists(args.X))`
770/// upstream of the `do:` body.
771#[allow(clippy::too_many_arguments)]
772async fn dispatch_field_op(
773    path: &str,
774    stages: &[crate::pipeline::Stage],
775    fallback_source: &str,
776    bag: &mut AttributeBag,
777    plugins: &Arc<dyn PluginInvoker>,
778    phase: crate::step::DispatchPhase,
779    taints: &mut Vec<crate::pipeline::TaintEvent>,
780    args_modified: &mut bool,
781    result_modified: &mut bool,
782    payload: &mut crate::route::RoutePayload,
783) -> EffectOutcome {
784    use crate::route::{get_dotted, remove_dotted, set_dotted};
785    use crate::step::DispatchPhase;
786
787    // Pick the right side of the payload based on the path prefix.
788    // Out-of-phase ops drop silently (see the doc comment).
789    enum Side {
790        Args,
791        Result,
792    }
793    let (root, subpath, side) = if let Some(rest) = path.strip_prefix("args.") {
794        if !matches!(phase, DispatchPhase::Pre) {
795            return EffectOutcome::Continue;
796        }
797        (&mut payload.args, rest, Side::Args)
798    } else if let Some(rest) = path.strip_prefix("result.") {
799        if !matches!(phase, DispatchPhase::Post) {
800            return EffectOutcome::Continue;
801        }
802        let Some(result) = payload.result.as_mut() else {
803            return EffectOutcome::Continue;
804        };
805        (result, rest, Side::Result)
806    } else {
807        return EffectOutcome::Halt(Decision::Deny {
808            reason: Some(format!(
809                "FieldOp path `{}` must start with `args.` or `result.`",
810                path
811            )),
812            rule_source: fallback_source.to_string(),
813        });
814    };
815
816    let Some(current) = get_dotted(root, subpath).cloned() else {
817        return EffectOutcome::Continue; // missing field → silent no-op
818    };
819
820    let pipeline = crate::pipeline::Pipeline {
821        stages: stages.to_vec(),
822    };
823    let eval = evaluate_pipeline(&pipeline, &current, bag, plugins, path, phase).await;
824    taints.extend(eval.taints);
825    let mark_modified = |side: Side, args: &mut bool, result: &mut bool| match side {
826        Side::Args => *args = true,
827        Side::Result => *result = true,
828    };
829    match eval.outcome {
830        FieldOutcome::Pass => EffectOutcome::Continue,
831        FieldOutcome::Replace(new_val) => {
832            if set_dotted(root, subpath, new_val) {
833                mark_modified(side, args_modified, result_modified);
834            }
835            EffectOutcome::Continue
836        },
837        FieldOutcome::Omit => {
838            if remove_dotted(root, subpath) {
839                mark_modified(side, args_modified, result_modified);
840            }
841            EffectOutcome::Continue
842        },
843        FieldOutcome::Deny {
844            reason,
845            stage_index: _,
846        } => EffectOutcome::Halt(Decision::Deny {
847            reason: Some(reason),
848            rule_source: fallback_source.to_string(),
849        }),
850    }
851}
852
853// =====================================================================
854// Pipe-chain evaluator (args: / result: field pipelines)
855// =====================================================================
856
857/// Result of running a pipeline against one field's value.
858///
859/// `Pass`: every stage succeeded; the original value should be kept.
860/// `Replace`: a transform produced a new value (also covers conditional
861/// `redact` firing).
862/// `Omit`: an `omit` stage fired; the field should be dropped from output.
863/// `Deny`: a validator failed; pipeline halted; the route should deny.
864#[derive(Debug, Clone, PartialEq)]
865pub enum FieldOutcome {
866    Pass,
867    Replace(serde_json::Value),
868    Omit,
869    Deny { reason: String, stage_index: usize },
870}
871
872/// Full result of a pipeline run: value-level outcome plus accumulated
873/// taint side effects.
874///
875/// `taint(...)` stages, plugin invocations, and `scan(...)` stages can all
876/// emit taints; the evaluator collects them here and hands them to the host
877/// (apl-cpex) for SessionStore writes. Taints accumulate even on `Replace`
878/// and `Omit` outcomes; they do not accumulate past a `Deny` (the pipeline
879/// halts at the failing stage).
880#[derive(Debug, Clone, PartialEq)]
881pub struct PipelineEvaluation {
882    pub outcome: FieldOutcome,
883    pub taints: Vec<TaintEvent>,
884}
885
886/// Walk a pipeline against `value` and the bag, applying stages left-to-right.
887///
888/// Async because pipe-chain `plugin(name)` stages dispatch through
889/// `PluginInvoker`, which is async.
890///
891/// `field_name` is the field this pipeline is attached to (from the wrapping
892/// `FieldRule`). It's threaded into `PluginInvocation::Field` when a
893/// `Stage::Plugin` fires so the invoker knows which field is in focus.
894/// Pass `""` for standalone pipeline runs that aren't part of a field rule.
895///
896/// `Stage::Validate { name }` is currently a no-op with a TODO — the named
897/// validator registry lands in a later step.
898pub async fn evaluate_pipeline(
899    pipeline: &Pipeline,
900    value: &serde_json::Value,
901    bag: &AttributeBag,
902    plugins: &Arc<dyn PluginInvoker>,
903    field_name: &str,
904    phase: crate::step::DispatchPhase,
905) -> PipelineEvaluation {
906    let mut current = value.clone();
907    let mut replaced = false;
908    let mut taints: Vec<TaintEvent> = Vec::new();
909
910    for (idx, stage) in pipeline.stages.iter().enumerate() {
911        match stage {
912            // ----- Validators -----
913            Stage::Type(tc) => {
914                if !type_check(tc, &current) {
915                    return PipelineEvaluation {
916                        outcome: FieldOutcome::Deny {
917                            reason: format!("expected {:?}, got {}", tc, value_kind(&current)),
918                            stage_index: idx,
919                        },
920                        taints,
921                    };
922                }
923            },
924            Stage::Length { min, max } => {
925                let Some(s) = current.as_str() else {
926                    return PipelineEvaluation {
927                        outcome: FieldOutcome::Deny {
928                            reason: format!(
929                                "len(...) requires string value, got {}",
930                                value_kind(&current)
931                            ),
932                            stage_index: idx,
933                        },
934                        taints,
935                    };
936                };
937                let len = s.chars().count();
938                if min.map_or(false, |m| len < m) || max.map_or(false, |m| len > m) {
939                    return PipelineEvaluation {
940                        outcome: FieldOutcome::Deny {
941                            reason: format!("length {} outside [{:?}, {:?}]", len, min, max),
942                            stage_index: idx,
943                        },
944                        taints,
945                    };
946                }
947            },
948            Stage::Range { min, max } => {
949                let Some(n) = current.as_i64() else {
950                    return PipelineEvaluation {
951                        outcome: FieldOutcome::Deny {
952                            reason: format!(
953                                "range requires integer value, got {}",
954                                value_kind(&current)
955                            ),
956                            stage_index: idx,
957                        },
958                        taints,
959                    };
960                };
961                if min.map_or(false, |m| n < m) || max.map_or(false, |m| n > m) {
962                    return PipelineEvaluation {
963                        outcome: FieldOutcome::Deny {
964                            reason: format!("value {} outside [{:?}, {:?}]", n, min, max),
965                            stage_index: idx,
966                        },
967                        taints,
968                    };
969                }
970            },
971            Stage::Enum { values } => {
972                let Some(s) = current.as_str() else {
973                    return PipelineEvaluation {
974                        outcome: FieldOutcome::Deny {
975                            reason: format!(
976                                "enum(...) requires string value, got {}",
977                                value_kind(&current)
978                            ),
979                            stage_index: idx,
980                        },
981                        taints,
982                    };
983                };
984                if !values.iter().any(|v| v == s) {
985                    return PipelineEvaluation {
986                        outcome: FieldOutcome::Deny {
987                            reason: format!("value `{}` not in enum {:?}", s, values),
988                            stage_index: idx,
989                        },
990                        taints,
991                    };
992                }
993            },
994            Stage::Regex { pattern } => {
995                // Compile-at-eval for now. A future step can swap to a
996                // route-level pre-compile cache keyed by pattern.
997                let re = match regex::Regex::new(pattern) {
998                    Ok(r) => r,
999                    Err(e) => {
1000                        return PipelineEvaluation {
1001                            outcome: FieldOutcome::Deny {
1002                                reason: format!("invalid regex `{}`: {}", pattern, e),
1003                                stage_index: idx,
1004                            },
1005                            taints,
1006                        };
1007                    },
1008                };
1009                let Some(s) = current.as_str() else {
1010                    return PipelineEvaluation {
1011                        outcome: FieldOutcome::Deny {
1012                            reason: format!(
1013                                "regex requires string value, got {}",
1014                                value_kind(&current)
1015                            ),
1016                            stage_index: idx,
1017                        },
1018                        taints,
1019                    };
1020                };
1021                if !re.is_match(s) {
1022                    return PipelineEvaluation {
1023                        outcome: FieldOutcome::Deny {
1024                            reason: format!("value did not match regex `{}`", pattern),
1025                            stage_index: idx,
1026                        },
1027                        taints,
1028                    };
1029                }
1030            },
1031            Stage::Validate { name } => {
1032                // Named-validator dispatch is not implemented in this
1033                // build. The parser rejects `validate(...)` at compile
1034                // time (parser.rs); this branch covers IR built
1035                // programmatically bypassing the parser. Same shape
1036                // as the parser's diagnostic — operators reach for
1037                // `regex(...)` or `plugin(...)` instead.
1038                return PipelineEvaluation {
1039                    outcome: FieldOutcome::Deny {
1040                        reason: format!(
1041                            "`validate({})` is not implemented; use `regex(...)` \
1042                             or `plugin({})` instead",
1043                            name, name,
1044                        ),
1045                        stage_index: idx,
1046                    },
1047                    taints,
1048                };
1049            },
1050
1051            // ----- Transforms -----
1052            Stage::Mask { keep_last } => {
1053                let Some(s) = current.as_str() else {
1054                    return PipelineEvaluation {
1055                        outcome: FieldOutcome::Deny {
1056                            reason: format!(
1057                                "mask(...) requires string value, got {}",
1058                                value_kind(&current)
1059                            ),
1060                            stage_index: idx,
1061                        },
1062                        taints,
1063                    };
1064                };
1065                let chars: Vec<char> = s.chars().collect();
1066                let keep = (*keep_last).min(chars.len());
1067                let mask_count = chars.len() - keep;
1068                let masked: String = std::iter::repeat('*')
1069                    .take(mask_count)
1070                    .chain(chars.into_iter().skip(mask_count))
1071                    .collect();
1072                current = serde_json::Value::String(masked);
1073                replaced = true;
1074            },
1075            Stage::Redact { condition } => {
1076                let should_redact = match condition {
1077                    None => true,
1078                    Some(expr) => eval_expression(expr, bag),
1079                };
1080                if should_redact {
1081                    current = serde_json::Value::String("[REDACTED]".into());
1082                    replaced = true;
1083                }
1084            },
1085            Stage::Omit => {
1086                return PipelineEvaluation {
1087                    outcome: FieldOutcome::Omit,
1088                    taints,
1089                };
1090            },
1091            Stage::Hash => {
1092                // Simple deterministic digest — DefaultHasher is fine for
1093                // de-identification (not for cryptographic use).
1094                use std::hash::{Hash, Hasher};
1095                let mut h = std::collections::hash_map::DefaultHasher::new();
1096                value_for_hash(&current).hash(&mut h);
1097                current = serde_json::Value::String(format!("hash:{:016x}", h.finish()));
1098                replaced = true;
1099            },
1100
1101            // ----- Effects -----
1102            Stage::Taint { label, scopes } => {
1103                taints.push(TaintEvent {
1104                    label: label.clone(),
1105                    scopes: scopes.clone(),
1106                });
1107            },
1108            Stage::Plugin { name } => {
1109                let invocation = PluginInvocation::Field {
1110                    name: field_name,
1111                    value: &current,
1112                    phase,
1113                };
1114                match plugins.invoke(name, bag, invocation).await {
1115                    Ok(outcome) => {
1116                        // Plugins can emit taints regardless of decision.
1117                        taints.extend(outcome.taints);
1118                        match outcome.decision {
1119                            Decision::Allow => {
1120                                if let Some(new_value) = outcome.modified_value {
1121                                    current = new_value;
1122                                    replaced = true;
1123                                }
1124                            },
1125                            Decision::Deny {
1126                                reason,
1127                                rule_source: _,
1128                            } => {
1129                                return PipelineEvaluation {
1130                                    outcome: FieldOutcome::Deny {
1131                                        reason: reason
1132                                            .unwrap_or_else(|| format!("plugin `{}` denied", name)),
1133                                        stage_index: idx,
1134                                    },
1135                                    taints,
1136                                };
1137                            },
1138                        }
1139                    },
1140                    Err(e) => {
1141                        // Fail-closed: plugin dispatch failure halts the pipeline.
1142                        return PipelineEvaluation {
1143                            outcome: FieldOutcome::Deny {
1144                                reason: format!("plugin `{}` error: {}", name, e),
1145                                stage_index: idx,
1146                            },
1147                            taints,
1148                        };
1149                    },
1150                }
1151            },
1152            Stage::Scan { kind } => {
1153                // Spec mapping (apl-dsl-spec §4): scan stages are taint
1154                // emitters. The actual PII detection / injection signal
1155                // lives in plugin(...) variants of the same scanners; this
1156                // stage just records the label so downstream policies can
1157                // gate on it. `pii.redact` additionally rewrites the value.
1158                let (label, redact): (&str, bool) = match kind {
1159                    ScanKind::PiiDetect => ("PII", false),
1160                    ScanKind::PiiRedact => ("PII", true),
1161                    ScanKind::InjectionScan => ("injection", false),
1162                };
1163                taints.push(TaintEvent {
1164                    label: label.to_string(),
1165                    scopes: vec![TaintScope::Session],
1166                });
1167                if redact {
1168                    current = serde_json::Value::String("[REDACTED]".into());
1169                    replaced = true;
1170                }
1171            },
1172        }
1173    }
1174
1175    let outcome = if replaced {
1176        FieldOutcome::Replace(current)
1177    } else {
1178        FieldOutcome::Pass
1179    };
1180    PipelineEvaluation { outcome, taints }
1181}
1182
1183fn type_check(tc: &TypeCheck, v: &serde_json::Value) -> bool {
1184    match tc {
1185        TypeCheck::Str => v.is_string(),
1186        TypeCheck::Int => v.is_i64(),
1187        TypeCheck::Bool => v.is_boolean(),
1188        TypeCheck::Float => v.is_f64() || v.is_i64(),
1189        TypeCheck::Email => v
1190            .as_str()
1191            .map_or(false, |s| s.contains('@') && s.contains('.')),
1192        TypeCheck::Url => v.as_str().map_or(false, |s| {
1193            s.starts_with("http://") || s.starts_with("https://")
1194        }),
1195        TypeCheck::Uuid => v.as_str().map_or(false, is_uuid_shape),
1196    }
1197}
1198
1199fn is_uuid_shape(s: &str) -> bool {
1200    // 8-4-4-4-12 hex with `-` separators.
1201    let bytes = s.as_bytes();
1202    if bytes.len() != 36 {
1203        return false;
1204    }
1205    for (i, &b) in bytes.iter().enumerate() {
1206        match i {
1207            8 | 13 | 18 | 23 => {
1208                if b != b'-' {
1209                    return false;
1210                }
1211            },
1212            _ => {
1213                if !b.is_ascii_hexdigit() {
1214                    return false;
1215                }
1216            },
1217        }
1218    }
1219    true
1220}
1221
1222fn value_kind(v: &serde_json::Value) -> &'static str {
1223    match v {
1224        serde_json::Value::Null => "null",
1225        serde_json::Value::Bool(_) => "bool",
1226        serde_json::Value::Number(n) if n.is_i64() => "int",
1227        serde_json::Value::Number(_) => "float",
1228        serde_json::Value::String(_) => "string",
1229        serde_json::Value::Array(_) => "array",
1230        serde_json::Value::Object(_) => "object",
1231    }
1232}
1233
1234/// Stable byte representation of a value for hashing — serde_json's
1235/// `to_string` is canonical enough for our use.
1236fn value_for_hash(v: &serde_json::Value) -> String {
1237    serde_json::to_string(v).unwrap_or_default()
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242    use super::*;
1243    use crate::rules::{Condition, Expression, Rule};
1244    use crate::step::{DelegationInvoker, NoopDelegationInvoker};
1245    use std::collections::HashSet;
1246    use std::sync::Arc;
1247
1248    fn rule(condition: Expression, effect: Effect, source: &str) -> Rule {
1249        Rule::single(condition, effect, source)
1250    }
1251
1252    // Wrap stateless test invokers in `Arc<dyn ...>` once per call. The
1253    // public evaluator API takes `&Arc<dyn PluginInvoker>` so internal
1254    // dispatch (notably `Effect::Parallel`) can `Arc::clone` an owned,
1255    // 'static reference into each spawned branch (slice E3.2).
1256    fn null_pipe_plugins() -> Arc<dyn PluginInvoker> {
1257        Arc::new(NullPipelinePlugins)
1258    }
1259    fn null_plugins() -> Arc<dyn PluginInvoker> {
1260        Arc::new(NullPlugins)
1261    }
1262    fn noop_delegations() -> Arc<dyn DelegationInvoker> {
1263        Arc::new(NoopDelegationInvoker)
1264    }
1265
1266    fn deny(reason: &str) -> Effect {
1267        Effect::Deny {
1268            reason: Some(reason.into()),
1269            code: None,
1270        }
1271    }
1272
1273    fn cond(c: Condition) -> Expression {
1274        Expression::Condition(c)
1275    }
1276
1277    // ----- Decision-level semantics -----
1278
1279    #[test]
1280    fn empty_rules_allow() {
1281        let mut bag = AttributeBag::new();
1282        assert_eq!(evaluate_rules(&[], &bag), Decision::Allow);
1283    }
1284
1285    #[test]
1286    fn first_deny_halts() {
1287        let mut bag = AttributeBag::new();
1288        bag.set("a", true);
1289        bag.set("b", true);
1290
1291        let rules = vec![
1292            rule(
1293                cond(Condition::IsTrue { key: "a".into() }),
1294                deny("first"),
1295                "r0",
1296            ),
1297            rule(
1298                cond(Condition::IsTrue { key: "b".into() }),
1299                deny("second"),
1300                "r1",
1301            ),
1302        ];
1303
1304        match evaluate_rules(&rules, &bag) {
1305            Decision::Deny {
1306                reason,
1307                rule_source,
1308            } => {
1309                assert_eq!(reason.as_deref(), Some("first"));
1310                assert_eq!(rule_source, "r0");
1311            },
1312            d => panic!("expected Deny, got {:?}", d),
1313        }
1314    }
1315
1316    #[test]
1317    fn allow_does_not_short_circuit() {
1318        // Spec §3: explicit allow continues evaluation. A later deny still fires.
1319        let mut bag = AttributeBag::new();
1320        bag.set("ok", true);
1321        bag.set("bad", true);
1322
1323        let rules = vec![
1324            rule(
1325                cond(Condition::IsTrue { key: "ok".into() }),
1326                Effect::Allow,
1327                "r0_allow",
1328            ),
1329            rule(
1330                cond(Condition::IsTrue { key: "bad".into() }),
1331                deny("later"),
1332                "r1_deny",
1333            ),
1334        ];
1335
1336        match evaluate_rules(&rules, &bag) {
1337            Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r1_deny"),
1338            d => panic!("allow short-circuited; expected later deny, got {:?}", d),
1339        }
1340    }
1341
1342    #[test]
1343    fn unmatched_rules_dont_fire() {
1344        let mut bag = AttributeBag::new(); // "denied" missing → false
1345        let rules = vec![rule(
1346            cond(Condition::IsTrue {
1347                key: "denied".into(),
1348            }),
1349            deny("shouldn't fire"),
1350            "r0",
1351        )];
1352        assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow);
1353    }
1354
1355    // ----- Predicate semantics -----
1356
1357    #[test]
1358    fn missing_key_is_false() {
1359        let mut bag = AttributeBag::new();
1360        assert!(!eval_condition(
1361            &Condition::IsTrue {
1362                key: "missing".into()
1363            },
1364            &bag
1365        ));
1366        assert!(eval_condition(
1367            &Condition::IsFalse {
1368                key: "missing".into()
1369            },
1370            &bag
1371        ));
1372        // Comparison on missing → false (spec §2.6).
1373        assert!(!eval_condition(
1374            &Condition::Comparison {
1375                key: "missing".into(),
1376                op: CompareOp::Eq,
1377                value: 1_i64.into(),
1378            },
1379            &bag,
1380        ));
1381    }
1382
1383    #[test]
1384    fn and_or_not_combinators() {
1385        let mut bag = AttributeBag::new();
1386        bag.set("a", true);
1387        bag.set("b", false);
1388
1389        let a = cond(Condition::IsTrue { key: "a".into() });
1390        let b = cond(Condition::IsTrue { key: "b".into() });
1391
1392        assert!(eval_expression(
1393            &Expression::And(vec![a.clone(), a.clone()]),
1394            &bag
1395        ));
1396        assert!(!eval_expression(
1397            &Expression::And(vec![a.clone(), b.clone()]),
1398            &bag
1399        ));
1400        assert!(eval_expression(
1401            &Expression::Or(vec![a.clone(), b.clone()]),
1402            &bag
1403        ));
1404        assert!(!eval_expression(
1405            &Expression::Or(vec![b.clone(), b.clone()]),
1406            &bag
1407        ));
1408        assert!(eval_expression(&Expression::Not(Box::new(b)), &bag));
1409    }
1410
1411    // ----- Comparison operators -----
1412
1413    #[test]
1414    fn int_comparisons() {
1415        let mut bag = AttributeBag::new();
1416        bag.set("delegation.depth", 3_i64);
1417
1418        let cmp = |op| Condition::Comparison {
1419            key: "delegation.depth".into(),
1420            op,
1421            value: 2_i64.into(),
1422        };
1423        assert!(eval_condition(&cmp(CompareOp::Gt), &bag));
1424        assert!(eval_condition(&cmp(CompareOp::GtEq), &bag));
1425        assert!(!eval_condition(&cmp(CompareOp::Lt), &bag));
1426        assert!(!eval_condition(&cmp(CompareOp::Eq), &bag));
1427        assert!(eval_condition(&cmp(CompareOp::NotEq), &bag));
1428    }
1429
1430    #[test]
1431    fn int_to_float_promotion_in_comparison() {
1432        let mut bag = AttributeBag::new();
1433        bag.set("delegation.depth", 2_i64);
1434        // `delegation.depth > 2.5` — int promotes to float for the compare.
1435        assert!(!eval_condition(
1436            &Condition::Comparison {
1437                key: "delegation.depth".into(),
1438                op: CompareOp::Gt,
1439                value: 2.5_f64.into(),
1440            },
1441            &bag,
1442        ));
1443        assert!(eval_condition(
1444            &Condition::Comparison {
1445                key: "delegation.depth".into(),
1446                op: CompareOp::Lt,
1447                value: 2.5_f64.into(),
1448            },
1449            &bag,
1450        ));
1451    }
1452
1453    #[test]
1454    fn string_equality_no_ordering() {
1455        let mut bag = AttributeBag::new();
1456        bag.set("subject.id", "alice");
1457
1458        assert!(eval_condition(
1459            &Condition::Comparison {
1460                key: "subject.id".into(),
1461                op: CompareOp::Eq,
1462                value: "alice".into(),
1463            },
1464            &bag,
1465        ));
1466        // Order operators on strings → false (spec §2.3).
1467        assert!(!eval_condition(
1468            &Condition::Comparison {
1469                key: "subject.id".into(),
1470                op: CompareOp::Gt,
1471                value: "alice".into(),
1472            },
1473            &bag,
1474        ));
1475    }
1476
1477    #[test]
1478    fn contains_set_membership() {
1479        let mut bag = AttributeBag::new();
1480        bag.set(
1481            "session.labels",
1482            HashSet::from(["PII".to_string(), "financial".to_string()]),
1483        );
1484
1485        assert!(eval_condition(
1486            &Condition::Comparison {
1487                key: "session.labels".into(),
1488                op: CompareOp::Contains,
1489                value: "PII".into(),
1490            },
1491            &bag,
1492        ));
1493        assert!(!eval_condition(
1494            &Condition::Comparison {
1495                key: "session.labels".into(),
1496                op: CompareOp::Contains,
1497                value: "PHI".into(),
1498            },
1499            &bag,
1500        ));
1501        // Contains on a non-set attribute → false.
1502        bag.set("subject.id", "alice");
1503        assert!(!eval_condition(
1504            &Condition::Comparison {
1505                key: "subject.id".into(),
1506                op: CompareOp::Contains,
1507                value: "alice".into(),
1508            },
1509            &bag,
1510        ));
1511    }
1512
1513    // ----- Realistic end-to-end -----
1514
1515    #[test]
1516    fn hr_compensation_scenario() {
1517        // From the HR demo: alice (hr role + view_ssn perm) requests compensation
1518        // with delegation.depth = 1. Rules:
1519        //   1. require(authenticated)
1520        //   2. require(role.hr | role.finance)
1521        //   3. delegation.depth > 2 & include_ssn: deny
1522        //   4. !perm.view_ssn & include_ssn: deny
1523        let mut bag = AttributeBag::new();
1524        bag.set("authenticated", true);
1525        bag.set("role.hr", true);
1526        bag.set("perm.view_ssn", true);
1527        bag.set("delegation.depth", 1_i64);
1528        bag.set("include_ssn", true);
1529
1530        let rules = vec![
1531            // require(authenticated) → deny if !authenticated
1532            rule(
1533                Expression::Not(Box::new(cond(Condition::IsTrue {
1534                    key: "authenticated".into(),
1535                }))),
1536                deny("not authenticated"),
1537                "r0",
1538            ),
1539            // require(role.hr | role.finance) → deny if neither
1540            // Desugars to: when !(role.hr | role.finance) do deny
1541            //             = when (role.hr is false) AND (role.finance is false), deny
1542            rule(
1543                Expression::And(vec![
1544                    cond(Condition::IsFalse {
1545                        key: "role.hr".into(),
1546                    }),
1547                    cond(Condition::IsFalse {
1548                        key: "role.finance".into(),
1549                    }),
1550                ]),
1551                deny("not in hr/finance"),
1552                "r1",
1553            ),
1554            // delegation.depth > 2 & include_ssn: deny
1555            rule(
1556                Expression::And(vec![
1557                    cond(Condition::Comparison {
1558                        key: "delegation.depth".into(),
1559                        op: CompareOp::Gt,
1560                        value: 2_i64.into(),
1561                    }),
1562                    cond(Condition::IsTrue {
1563                        key: "include_ssn".into(),
1564                    }),
1565                ]),
1566                deny("delegation too deep for SSN"),
1567                "r2",
1568            ),
1569        ];
1570
1571        assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow);
1572
1573        // Now make Alice undelegated-but-deep — should still allow at depth=1.
1574        // Change to depth=3 and the SSN rule fires.
1575        bag.set("delegation.depth", 3_i64);
1576        match evaluate_rules(&rules, &bag) {
1577            Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r2"),
1578            d => panic!("expected r2 deny, got {:?}", d),
1579        }
1580    }
1581
1582    // ===================================================================
1583    // Pipe-chain evaluator tests
1584    // ===================================================================
1585
1586    use crate::pipeline::{Stage, TypeCheck};
1587    use serde_json::json;
1588
1589    fn make_pipeline(stages: Vec<Stage>) -> crate::pipeline::Pipeline {
1590        crate::pipeline::Pipeline { stages }
1591    }
1592
1593    // Helper: a plugin invoker that's never expected to fire (pipelines
1594    // without `plugin(...)` stages). Panics if called. Defined alongside
1595    // the other null fixtures further down in this module.
1596
1597    async fn run_pipeline(
1598        p: &crate::pipeline::Pipeline,
1599        v: &serde_json::Value,
1600        bag: &AttributeBag,
1601    ) -> FieldOutcome {
1602        evaluate_pipeline(
1603            p,
1604            v,
1605            bag,
1606            &null_pipe_plugins(),
1607            "test_field",
1608            crate::step::DispatchPhase::Pre,
1609        )
1610        .await
1611        .outcome
1612    }
1613
1614    /// Pipeline-test null invoker — distinct from the step-test `NullPlugins`
1615    /// so each test can panic with a clearer "wrong fixture" message if it
1616    /// ever does dispatch a plugin call by accident.
1617    struct NullPipelinePlugins;
1618    #[async_trait]
1619    impl PluginInvoker for NullPipelinePlugins {
1620        async fn invoke(
1621            &self,
1622            name: &str,
1623            _bag: &AttributeBag,
1624            _invocation: PluginInvocation<'_>,
1625        ) -> Result<PluginOutcome, PluginError> {
1626            panic!(
1627                "NullPipelinePlugins should not dispatch; got plugin({})",
1628                name
1629            );
1630        }
1631    }
1632
1633    #[tokio::test]
1634    async fn pipeline_empty_is_pass() {
1635        let mut bag = AttributeBag::new();
1636        let p = make_pipeline(vec![]);
1637        assert_eq!(
1638            run_pipeline(&p, &json!("anything"), &bag).await,
1639            FieldOutcome::Pass
1640        );
1641    }
1642
1643    #[tokio::test]
1644    async fn pipeline_type_check_passes_and_denies() {
1645        let mut bag = AttributeBag::new();
1646        let p = make_pipeline(vec![Stage::Type(TypeCheck::Str)]);
1647        assert_eq!(
1648            run_pipeline(&p, &json!("hello"), &bag).await,
1649            FieldOutcome::Pass
1650        );
1651        match run_pipeline(&p, &json!(42), &bag).await {
1652            FieldOutcome::Deny {
1653                reason,
1654                stage_index,
1655            } => {
1656                assert!(reason.contains("expected Str"));
1657                assert_eq!(stage_index, 0);
1658            },
1659            other => panic!("expected Deny, got {:?}", other),
1660        }
1661    }
1662
1663    #[tokio::test]
1664    async fn pipeline_mask_preserves_last_n() {
1665        let mut bag = AttributeBag::new();
1666        let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]);
1667        match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
1668            FieldOutcome::Replace(v) => assert_eq!(v, json!("*******6789")),
1669            other => panic!("expected Replace, got {:?}", other),
1670        }
1671    }
1672
1673    #[tokio::test]
1674    async fn pipeline_mask_handles_short_strings() {
1675        let mut bag = AttributeBag::new();
1676        let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]);
1677        // keep_last >= length → no mask chars; full string preserved.
1678        match run_pipeline(&p, &json!("ab"), &bag).await {
1679            FieldOutcome::Replace(v) => assert_eq!(v, json!("ab")),
1680            other => panic!("expected Replace, got {:?}", other),
1681        }
1682    }
1683
1684    #[tokio::test]
1685    async fn pipeline_unconditional_redact() {
1686        let mut bag = AttributeBag::new();
1687        let p = make_pipeline(vec![Stage::Redact { condition: None }]);
1688        match run_pipeline(&p, &json!("secret"), &bag).await {
1689            FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")),
1690            other => panic!("expected Replace, got {:?}", other),
1691        }
1692    }
1693
1694    #[tokio::test]
1695    async fn pipeline_conditional_redact_fires_when_condition_true() {
1696        // redact(!perm.view_ssn): condition is `!perm.view_ssn`. Missing key
1697        // → IsTrue is false → `!IsTrue` is true → redact fires.
1698        let mut bag = AttributeBag::new();
1699        let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue {
1700            key: "perm.view_ssn".into(),
1701        })));
1702        let p = make_pipeline(vec![Stage::Redact {
1703            condition: Some(cond),
1704        }]);
1705        match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
1706            FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")),
1707            other => panic!("expected Replace (redact fired), got {:?}", other),
1708        }
1709    }
1710
1711    #[tokio::test]
1712    async fn pipeline_conditional_redact_skips_when_condition_false() {
1713        let mut bag = AttributeBag::new();
1714        bag.set("perm.view_ssn", true);
1715        let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue {
1716            key: "perm.view_ssn".into(),
1717        })));
1718        let p = make_pipeline(vec![Stage::Redact {
1719            condition: Some(cond),
1720        }]);
1721        // perm.view_ssn=true → !true=false → redact skipped → Pass.
1722        assert_eq!(
1723            run_pipeline(&p, &json!("123-45-6789"), &bag).await,
1724            FieldOutcome::Pass,
1725        );
1726    }
1727
1728    #[tokio::test]
1729    async fn pipeline_omit_short_circuits() {
1730        let mut bag = AttributeBag::new();
1731        let p = make_pipeline(vec![
1732            Stage::Omit,
1733            // This stage should never run.
1734            Stage::Type(TypeCheck::Int),
1735        ]);
1736        assert_eq!(
1737            run_pipeline(&p, &json!("anything"), &bag).await,
1738            FieldOutcome::Omit
1739        );
1740    }
1741
1742    #[tokio::test]
1743    async fn pipeline_range_validator() {
1744        let mut bag = AttributeBag::new();
1745        let p = make_pipeline(vec![
1746            Stage::Type(TypeCheck::Int),
1747            Stage::Range {
1748                min: Some(0),
1749                max: Some(1_000_000),
1750            },
1751        ]);
1752        assert_eq!(
1753            run_pipeline(&p, &json!(500_000), &bag).await,
1754            FieldOutcome::Pass
1755        );
1756        // Above max → deny.
1757        match run_pipeline(&p, &json!(2_000_000), &bag).await {
1758            FieldOutcome::Deny {
1759                reason,
1760                stage_index,
1761            } => {
1762                assert!(reason.contains("outside"));
1763                assert_eq!(stage_index, 1);
1764            },
1765            other => panic!("expected Deny, got {:?}", other),
1766        }
1767    }
1768
1769    #[tokio::test]
1770    async fn pipeline_length_validator() {
1771        let mut bag = AttributeBag::new();
1772        let p = make_pipeline(vec![Stage::Length {
1773            min: None,
1774            max: Some(5),
1775        }]);
1776        assert_eq!(
1777            run_pipeline(&p, &json!("hi"), &bag).await,
1778            FieldOutcome::Pass
1779        );
1780        assert!(matches!(
1781            run_pipeline(&p, &json!("too long"), &bag).await,
1782            FieldOutcome::Deny { .. },
1783        ));
1784    }
1785
1786    #[tokio::test]
1787    async fn pipeline_enum_validator() {
1788        let mut bag = AttributeBag::new();
1789        let p = make_pipeline(vec![Stage::Enum {
1790            values: vec!["low".into(), "medium".into(), "high".into()],
1791        }]);
1792        assert_eq!(
1793            run_pipeline(&p, &json!("medium"), &bag).await,
1794            FieldOutcome::Pass
1795        );
1796        assert!(matches!(
1797            run_pipeline(&p, &json!("extreme"), &bag).await,
1798            FieldOutcome::Deny { .. },
1799        ));
1800    }
1801
1802    #[tokio::test]
1803    async fn pipeline_uuid_validator() {
1804        let mut bag = AttributeBag::new();
1805        let p = make_pipeline(vec![Stage::Type(TypeCheck::Uuid)]);
1806        assert_eq!(
1807            run_pipeline(&p, &json!("550e8400-e29b-41d4-a716-446655440000"), &bag).await,
1808            FieldOutcome::Pass,
1809        );
1810        assert!(matches!(
1811            run_pipeline(&p, &json!("not-a-uuid"), &bag).await,
1812            FieldOutcome::Deny { .. },
1813        ));
1814    }
1815
1816    #[tokio::test]
1817    async fn pipeline_hash_replaces_value() {
1818        let mut bag = AttributeBag::new();
1819        let p = make_pipeline(vec![Stage::Hash]);
1820        match run_pipeline(&p, &json!("secret"), &bag).await {
1821            FieldOutcome::Replace(v) => {
1822                let s = v.as_str().unwrap();
1823                assert!(s.starts_with("hash:"));
1824                assert_eq!(s.len(), "hash:".len() + 16);
1825            },
1826            other => panic!("expected Replace, got {:?}", other),
1827        }
1828    }
1829
1830    #[tokio::test]
1831    async fn pipeline_validate_named_denies_at_runtime() {
1832        // `validate(name)` is unimplemented in this build. The parser
1833        // rejects it at compile time; this test exercises the runtime
1834        // defense-in-depth path for IR built programmatically. The
1835        // deny message points operators at the working alternatives
1836        // (`regex(...)` / `plugin(...)`).
1837        let mut bag = AttributeBag::new();
1838        let p = make_pipeline(vec![
1839            Stage::Type(TypeCheck::Str),
1840            Stage::Validate {
1841                name: "ssn_format".into(),
1842            },
1843            Stage::Mask { keep_last: 4 },
1844        ]);
1845        match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
1846            FieldOutcome::Deny {
1847                reason,
1848                stage_index,
1849            } => {
1850                assert_eq!(stage_index, 1, "validate stage is at index 1");
1851                assert!(
1852                    reason.contains("not implemented"),
1853                    "deny reason should explain that validate is unimplemented: {reason}",
1854                );
1855                assert!(
1856                    reason.contains("regex") || reason.contains("plugin"),
1857                    "deny reason should point at alternatives: {reason}",
1858                );
1859            },
1860            other => panic!("expected Deny on validate(...) stage, got {:?}", other),
1861        }
1862    }
1863
1864    #[tokio::test]
1865    async fn pipeline_validator_short_circuits_before_transform() {
1866        // If the validator fails, the transform never runs.
1867        let mut bag = AttributeBag::new();
1868        let p = make_pipeline(vec![
1869            Stage::Type(TypeCheck::Int), // will fail on a string
1870            Stage::Mask { keep_last: 4 },
1871        ]);
1872        match run_pipeline(&p, &json!("hello"), &bag).await {
1873            FieldOutcome::Deny { stage_index, .. } => assert_eq!(stage_index, 0),
1874            other => panic!("expected Deny at stage 0, got {:?}", other),
1875        }
1876    }
1877
1878    // ----- Regex stage -----
1879
1880    #[tokio::test]
1881    async fn pipeline_regex_match_passes() {
1882        let mut bag = AttributeBag::new();
1883        let p = make_pipeline(vec![Stage::Regex {
1884            pattern: r"^\d{3}-\d{2}-\d{4}$".into(),
1885        }]);
1886        assert_eq!(
1887            run_pipeline(&p, &json!("123-45-6789"), &bag).await,
1888            FieldOutcome::Pass
1889        );
1890    }
1891
1892    #[tokio::test]
1893    async fn pipeline_regex_no_match_denies() {
1894        let mut bag = AttributeBag::new();
1895        let p = make_pipeline(vec![Stage::Regex {
1896            pattern: r"^\d{3}-\d{2}-\d{4}$".into(),
1897        }]);
1898        match run_pipeline(&p, &json!("not an ssn"), &bag).await {
1899            FieldOutcome::Deny {
1900                reason,
1901                stage_index,
1902            } => {
1903                assert!(reason.contains("did not match"));
1904                assert_eq!(stage_index, 0);
1905            },
1906            other => panic!("expected Deny, got {:?}", other),
1907        }
1908    }
1909
1910    #[tokio::test]
1911    async fn pipeline_regex_invalid_pattern_denies() {
1912        let mut bag = AttributeBag::new();
1913        let p = make_pipeline(vec![Stage::Regex {
1914            pattern: "(unclosed".into(),
1915        }]);
1916        match run_pipeline(&p, &json!("anything"), &bag).await {
1917            FieldOutcome::Deny { reason, .. } => {
1918                assert!(reason.contains("invalid regex"));
1919            },
1920            other => panic!("expected Deny, got {:?}", other),
1921        }
1922    }
1923
1924    #[tokio::test]
1925    async fn pipeline_regex_non_string_denies() {
1926        let mut bag = AttributeBag::new();
1927        let p = make_pipeline(vec![Stage::Regex {
1928            pattern: r"^\d+$".into(),
1929        }]);
1930        match run_pipeline(&p, &json!(42), &bag).await {
1931            FieldOutcome::Deny { reason, .. } => {
1932                assert!(reason.contains("requires string"));
1933            },
1934            other => panic!("expected Deny on non-string regex input, got {:?}", other),
1935        }
1936    }
1937
1938    // ----- Taint and Scan stages -----
1939
1940    #[tokio::test]
1941    async fn pipeline_taint_records_event() {
1942        let mut bag = AttributeBag::new();
1943        let p = make_pipeline(vec![
1944            Stage::Type(TypeCheck::Str),
1945            Stage::Taint {
1946                label: "PII".into(),
1947                scopes: vec![TaintScope::Session],
1948            },
1949            Stage::Mask { keep_last: 4 },
1950        ]);
1951        let result = evaluate_pipeline(
1952            &p,
1953            &json!("123-45-6789"),
1954            &bag,
1955            &null_pipe_plugins(),
1956            "test_field",
1957            crate::step::DispatchPhase::Pre,
1958        )
1959        .await;
1960        assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789")));
1961        assert_eq!(
1962            result.taints,
1963            vec![TaintEvent {
1964                label: "PII".into(),
1965                scopes: vec![TaintScope::Session],
1966            }]
1967        );
1968    }
1969
1970    #[tokio::test]
1971    async fn pipeline_scan_pii_detect_emits_taint() {
1972        let mut bag = AttributeBag::new();
1973        let p = make_pipeline(vec![Stage::Scan {
1974            kind: ScanKind::PiiDetect,
1975        }]);
1976        let result = evaluate_pipeline(
1977            &p,
1978            &json!("some text"),
1979            &bag,
1980            &null_pipe_plugins(),
1981            "test_field",
1982            crate::step::DispatchPhase::Pre,
1983        )
1984        .await;
1985        // PII detect: value unchanged, one taint event emitted.
1986        assert_eq!(result.outcome, FieldOutcome::Pass);
1987        assert_eq!(
1988            result.taints,
1989            vec![TaintEvent {
1990                label: "PII".into(),
1991                scopes: vec![TaintScope::Session],
1992            }]
1993        );
1994    }
1995
1996    #[tokio::test]
1997    async fn pipeline_scan_pii_redact_replaces_and_taints() {
1998        let mut bag = AttributeBag::new();
1999        let p = make_pipeline(vec![Stage::Scan {
2000            kind: ScanKind::PiiRedact,
2001        }]);
2002        let result = evaluate_pipeline(
2003            &p,
2004            &json!("123-45-6789"),
2005            &bag,
2006            &null_pipe_plugins(),
2007            "test_field",
2008            crate::step::DispatchPhase::Pre,
2009        )
2010        .await;
2011        assert_eq!(result.outcome, FieldOutcome::Replace(json!("[REDACTED]")));
2012        assert_eq!(result.taints.len(), 1);
2013        assert_eq!(result.taints[0].label, "PII");
2014    }
2015
2016    #[tokio::test]
2017    async fn pipeline_scan_injection_emits_injection_taint() {
2018        let mut bag = AttributeBag::new();
2019        let p = make_pipeline(vec![Stage::Scan {
2020            kind: ScanKind::InjectionScan,
2021        }]);
2022        let result = evaluate_pipeline(
2023            &p,
2024            &json!("user input"),
2025            &bag,
2026            &null_pipe_plugins(),
2027            "test_field",
2028            crate::step::DispatchPhase::Pre,
2029        )
2030        .await;
2031        assert_eq!(result.outcome, FieldOutcome::Pass);
2032        assert_eq!(result.taints[0].label, "injection");
2033    }
2034
2035    #[tokio::test]
2036    async fn pipeline_deny_does_not_accumulate_later_taints() {
2037        // Pipeline halts at the first failing validator; taints emitted
2038        // before the failure stick, taints after do not.
2039        let mut bag = AttributeBag::new();
2040        let p = make_pipeline(vec![
2041            Stage::Taint {
2042                label: "before".into(),
2043                scopes: vec![TaintScope::Session],
2044            },
2045            Stage::Type(TypeCheck::Int), // fails on string input
2046            Stage::Taint {
2047                label: "after".into(),
2048                scopes: vec![TaintScope::Session],
2049            },
2050        ]);
2051        let result = evaluate_pipeline(
2052            &p,
2053            &json!("hello"),
2054            &bag,
2055            &null_pipe_plugins(),
2056            "test_field",
2057            crate::step::DispatchPhase::Pre,
2058        )
2059        .await;
2060        assert!(matches!(result.outcome, FieldOutcome::Deny { .. }));
2061        assert_eq!(
2062            result.taints,
2063            vec![TaintEvent {
2064                label: "before".into(),
2065                scopes: vec![TaintScope::Session],
2066            }]
2067        );
2068    }
2069
2070    // ----- Plugin stage in pipe chain -----
2071
2072    /// Pipe-context plugin invoker that returns canned outcomes by name.
2073    struct PipePlugin {
2074        outcomes: std::collections::HashMap<String, PluginOutcome>,
2075    }
2076    #[async_trait]
2077    impl PluginInvoker for PipePlugin {
2078        async fn invoke(
2079            &self,
2080            name: &str,
2081            _bag: &AttributeBag,
2082            _invocation: PluginInvocation<'_>,
2083        ) -> Result<PluginOutcome, PluginError> {
2084            self.outcomes
2085                .get(name)
2086                .cloned()
2087                .ok_or_else(|| PluginError::NotFound(name.into()))
2088        }
2089    }
2090
2091    #[tokio::test]
2092    async fn pipeline_plugin_allow_continues() {
2093        let mut bag = AttributeBag::new();
2094        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2095            outcomes: std::collections::HashMap::from([(
2096                "noop".to_string(),
2097                PluginOutcome::allow(),
2098            )]),
2099        });
2100        let p = make_pipeline(vec![
2101            Stage::Type(TypeCheck::Str),
2102            Stage::Plugin {
2103                name: "noop".into(),
2104            },
2105            Stage::Mask { keep_last: 4 },
2106        ]);
2107        let result = evaluate_pipeline(
2108            &p,
2109            &json!("123-45-6789"),
2110            &bag,
2111            &plugins,
2112            "compensation",
2113            crate::step::DispatchPhase::Pre,
2114        )
2115        .await;
2116        assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789")));
2117        assert!(result.taints.is_empty());
2118    }
2119
2120    #[tokio::test]
2121    async fn pipeline_plugin_can_replace_value() {
2122        let mut bag = AttributeBag::new();
2123        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2124            outcomes: std::collections::HashMap::from([(
2125                "scrubber".to_string(),
2126                PluginOutcome {
2127                    decision: Decision::Allow,
2128                    taints: vec![TaintEvent {
2129                        label: "PII".to_string(),
2130                        scopes: vec![TaintScope::Session],
2131                    }],
2132                    modified_value: Some(json!("***scrubbed***")),
2133                },
2134            )]),
2135        });
2136        let p = make_pipeline(vec![Stage::Plugin {
2137            name: "scrubber".into(),
2138        }]);
2139        let result = evaluate_pipeline(
2140            &p,
2141            &json!("sensitive data"),
2142            &bag,
2143            &plugins,
2144            "notes",
2145            crate::step::DispatchPhase::Pre,
2146        )
2147        .await;
2148        assert_eq!(
2149            result.outcome,
2150            FieldOutcome::Replace(json!("***scrubbed***"))
2151        );
2152        assert_eq!(
2153            result.taints,
2154            vec![TaintEvent {
2155                label: "PII".into(),
2156                scopes: vec![TaintScope::Session],
2157            }]
2158        );
2159    }
2160
2161    #[tokio::test]
2162    async fn pipeline_plugin_deny_halts() {
2163        let mut bag = AttributeBag::new();
2164        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2165            outcomes: std::collections::HashMap::from([(
2166                "guard".to_string(),
2167                PluginOutcome {
2168                    decision: Decision::Deny {
2169                        reason: Some("policy violation".into()),
2170                        rule_source: "guard".into(),
2171                    },
2172                    taints: vec![],
2173                    modified_value: None,
2174                },
2175            )]),
2176        });
2177        let p = make_pipeline(vec![
2178            Stage::Plugin {
2179                name: "guard".into(),
2180            },
2181            // Should never run.
2182            Stage::Mask { keep_last: 4 },
2183        ]);
2184        let result = evaluate_pipeline(
2185            &p,
2186            &json!("data"),
2187            &bag,
2188            &plugins,
2189            "payload",
2190            crate::step::DispatchPhase::Pre,
2191        )
2192        .await;
2193        match result.outcome {
2194            FieldOutcome::Deny {
2195                reason,
2196                stage_index,
2197            } => {
2198                assert_eq!(reason, "policy violation");
2199                assert_eq!(stage_index, 0);
2200            },
2201            other => panic!("expected Deny, got {:?}", other),
2202        }
2203    }
2204
2205    #[tokio::test]
2206    async fn pipeline_plugin_missing_fails_closed() {
2207        let mut bag = AttributeBag::new();
2208        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2209            outcomes: Default::default(),
2210        });
2211        let p = make_pipeline(vec![Stage::Plugin {
2212            name: "missing".into(),
2213        }]);
2214        let result = evaluate_pipeline(
2215            &p,
2216            &json!("data"),
2217            &bag,
2218            &plugins,
2219            "payload",
2220            crate::step::DispatchPhase::Pre,
2221        )
2222        .await;
2223        match result.outcome {
2224            FieldOutcome::Deny { reason, .. } => assert!(reason.contains("missing")),
2225            other => panic!("expected Deny on missing plugin, got {:?}", other),
2226        }
2227    }
2228
2229    // ===================================================================
2230    // 5c additions: Exists, InSet, Always
2231    // ===================================================================
2232
2233    #[test]
2234    fn exists_distinguishes_missing_from_falsy() {
2235        let mut bag = AttributeBag::new();
2236        bag.set("args.flag", false);
2237        // Key is present with a falsy value — IsTrue says false, Exists says true.
2238        assert!(!eval_condition(
2239            &Condition::IsTrue {
2240                key: "args.flag".into()
2241            },
2242            &bag
2243        ));
2244        assert!(eval_condition(
2245            &Condition::Exists {
2246                key: "args.flag".into()
2247            },
2248            &bag
2249        ));
2250        // Missing key — Exists is false.
2251        assert!(!eval_condition(
2252            &Condition::Exists {
2253                key: "args.nonexistent".into()
2254            },
2255            &bag
2256        ));
2257    }
2258
2259    #[test]
2260    fn in_set_member_and_non_member() {
2261        let mut bag = AttributeBag::new();
2262        bag.set("subject.type", "user");
2263        bag.set(
2264            "allowed_types",
2265            std::collections::HashSet::from(["user".to_string(), "service".to_string()]),
2266        );
2267
2268        assert!(eval_condition(
2269            &Condition::InSet {
2270                value_key: "subject.type".into(),
2271                set_key: "allowed_types".into(),
2272                negate: false,
2273            },
2274            &bag
2275        ));
2276
2277        bag.set("subject.type", "agent");
2278        assert!(!eval_condition(
2279            &Condition::InSet {
2280                value_key: "subject.type".into(),
2281                set_key: "allowed_types".into(),
2282                negate: false,
2283            },
2284            &bag
2285        ));
2286    }
2287
2288    #[test]
2289    fn in_set_negate() {
2290        let mut bag = AttributeBag::new();
2291        bag.set("subject.type", "agent");
2292        bag.set(
2293            "blocked_types",
2294            std::collections::HashSet::from(["service".to_string()]),
2295        );
2296
2297        // agent is not in blocked_types → not in → true
2298        assert!(eval_condition(
2299            &Condition::InSet {
2300                value_key: "subject.type".into(),
2301                set_key: "blocked_types".into(),
2302                negate: true,
2303            },
2304            &bag
2305        ));
2306    }
2307
2308    #[test]
2309    fn in_set_missing_keys_resolve_to_false() {
2310        let mut bag = AttributeBag::new();
2311        // Both missing → in = false → not in = true (spec §2.6 missing→false
2312        // applies to the underlying `in` lookup; negate flips it).
2313        assert!(!eval_condition(
2314            &Condition::InSet {
2315                value_key: "x".into(),
2316                set_key: "y".into(),
2317                negate: false,
2318            },
2319            &bag
2320        ));
2321        assert!(eval_condition(
2322            &Condition::InSet {
2323                value_key: "x".into(),
2324                set_key: "y".into(),
2325                negate: true,
2326            },
2327            &bag
2328        ));
2329    }
2330
2331    #[test]
2332    fn always_evaluates_true() {
2333        let mut bag = AttributeBag::new();
2334        assert!(eval_expression(&Expression::Always, &bag));
2335    }
2336
2337    #[test]
2338    fn always_rule_unconditional_deny() {
2339        let mut bag = AttributeBag::new();
2340        let r = Rule {
2341            condition: Expression::Always,
2342            effects: vec![Effect::Deny {
2343                reason: Some("unconditional".into()),
2344                code: None,
2345            }],
2346            source: "test".into(),
2347        };
2348        match evaluate_rules(&[r], &bag) {
2349            Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("unconditional")),
2350            d => panic!("expected Deny, got {:?}", d),
2351        }
2352    }
2353
2354    // ===================================================================
2355    // 5c-v/vi: async step evaluator with mock resolvers
2356    // ===================================================================
2357
2358    use crate::step::{
2359        PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, PluginInvocation,
2360        PluginInvoker, PluginOutcome,
2361    };
2362    use async_trait::async_trait;
2363
2364    /// PDP resolver that returns the decision baked into it. Doesn't
2365    /// inspect call.args — tests assert on call.dialect / on the decision
2366    /// flow, not on Cedar/OPA-specific arg parsing.
2367    struct FakePdp {
2368        decision: Decision,
2369    }
2370    #[async_trait]
2371    impl PdpResolver for FakePdp {
2372        fn dialect(&self) -> PdpDialect {
2373            PdpDialect::Cedar
2374        }
2375        async fn evaluate(
2376            &self,
2377            _call: &PdpCall,
2378            _bag: &AttributeBag,
2379        ) -> Result<PdpDecision, PdpError> {
2380            Ok(PdpDecision {
2381                decision: self.decision.clone(),
2382                diagnostics: vec![],
2383            })
2384        }
2385    }
2386
2387    /// PDP resolver that returns an error — exercises fail-closed path.
2388    struct ErroringPdp;
2389    #[async_trait]
2390    impl PdpResolver for ErroringPdp {
2391        fn dialect(&self) -> PdpDialect {
2392            PdpDialect::Cedar
2393        }
2394        async fn evaluate(
2395            &self,
2396            _call: &PdpCall,
2397            _bag: &AttributeBag,
2398        ) -> Result<PdpDecision, PdpError> {
2399            Err(PdpError::Dispatch("simulated PDP outage".into()))
2400        }
2401    }
2402
2403    /// Plugin invoker keyed by name → outcome.
2404    struct FakePlugin {
2405        decisions: std::collections::HashMap<String, Decision>,
2406    }
2407    #[async_trait]
2408    impl PluginInvoker for FakePlugin {
2409        async fn invoke(
2410            &self,
2411            name: &str,
2412            _bag: &AttributeBag,
2413            _invocation: PluginInvocation<'_>,
2414        ) -> Result<PluginOutcome, PluginError> {
2415            match self.decisions.get(name) {
2416                Some(d) => Ok(PluginOutcome {
2417                    decision: d.clone(),
2418                    taints: vec![],
2419                    modified_value: None,
2420                }),
2421                None => Err(PluginError::NotFound(name.into())),
2422            }
2423        }
2424    }
2425
2426    /// Null invoker — fails any plugin call (for PDP-only tests).
2427    struct NullPlugins;
2428    #[async_trait]
2429    impl PluginInvoker for NullPlugins {
2430        async fn invoke(
2431            &self,
2432            name: &str,
2433            _bag: &AttributeBag,
2434            _invocation: PluginInvocation<'_>,
2435        ) -> Result<PluginOutcome, PluginError> {
2436            Err(PluginError::NotFound(name.into()))
2437        }
2438    }
2439
2440    fn pdp_step(decision_diagnostic_label: &str) -> Effect {
2441        Effect::Pdp {
2442            call: PdpCall {
2443                dialect: PdpDialect::Cedar,
2444                args: serde_yaml::Value::String(decision_diagnostic_label.into()),
2445            },
2446            on_deny: vec![],
2447            on_allow: vec![],
2448        }
2449    }
2450
2451    #[tokio::test]
2452    async fn steps_rule_only_path() {
2453        let mut bag = AttributeBag::new();
2454        let steps = vec![Effect::When {
2455            condition: Expression::Always,
2456            body: vec![Effect::Allow],
2457            source: "test".into(),
2458        }];
2459        let r = evaluate_effects(
2460            &steps,
2461            &mut bag,
2462            &(Arc::new(FakePdp {
2463                decision: Decision::Allow,
2464            }) as Arc<dyn PdpResolver>),
2465            &null_plugins(),
2466            &noop_delegations(),
2467            crate::step::DispatchPhase::Pre,
2468            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2469        )
2470        .await;
2471        assert_eq!(r.decision, Decision::Allow);
2472    }
2473
2474    #[tokio::test]
2475    async fn pdp_allow_continues() {
2476        let mut bag = AttributeBag::new();
2477        let steps = vec![pdp_step("dummy")];
2478        let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2479            decision: Decision::Allow,
2480        });
2481        assert_eq!(
2482            evaluate_effects(
2483                &steps,
2484                &mut bag,
2485                &pdp,
2486                &null_plugins(),
2487                &noop_delegations(),
2488                crate::step::DispatchPhase::Pre,
2489                &mut crate::route::RoutePayload::new(serde_json::Value::Null)
2490            )
2491            .await
2492            .decision,
2493            Decision::Allow,
2494        );
2495    }
2496
2497    #[tokio::test]
2498    async fn pdp_deny_returns_deny() {
2499        let mut bag = AttributeBag::new();
2500        let steps = vec![pdp_step("dummy")];
2501        let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2502            decision: Decision::Deny {
2503                reason: Some("forbidden".into()),
2504                rule_source: "pdp".into(),
2505            },
2506        });
2507        match evaluate_effects(
2508            &steps,
2509            &mut bag,
2510            &pdp,
2511            &null_plugins(),
2512            &noop_delegations(),
2513            crate::step::DispatchPhase::Pre,
2514            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2515        )
2516        .await
2517        .decision
2518        {
2519            Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("forbidden")),
2520            d => panic!("expected Deny, got {:?}", d),
2521        }
2522    }
2523
2524    #[tokio::test]
2525    async fn pdp_on_deny_reaction_can_override_reason() {
2526        // PDP denies, on_deny reaction includes a more specific deny rule that
2527        // fires before the PDP's deny is returned.
2528        let mut bag = AttributeBag::new();
2529        let steps = vec![Effect::Pdp {
2530            call: PdpCall {
2531                dialect: PdpDialect::Cedar,
2532                args: serde_yaml::Value::Null,
2533            },
2534            on_deny: vec![Effect::When {
2535                condition: Expression::Always,
2536                body: vec![Effect::Deny {
2537                    reason: Some("reaction took over".into()),
2538                    code: None,
2539                }],
2540                source: "on_deny[0]".into(),
2541            }],
2542            on_allow: vec![],
2543        }];
2544        let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2545            decision: Decision::Deny {
2546                reason: Some("pdp original".into()),
2547                rule_source: "p".into(),
2548            },
2549        });
2550        match evaluate_effects(
2551            &steps,
2552            &mut bag,
2553            &pdp,
2554            &null_plugins(),
2555            &noop_delegations(),
2556            crate::step::DispatchPhase::Pre,
2557            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2558        )
2559        .await
2560        .decision
2561        {
2562            Decision::Deny {
2563                reason,
2564                rule_source,
2565            } => {
2566                assert_eq!(reason.as_deref(), Some("reaction took over"));
2567                assert_eq!(rule_source, "on_deny[0]");
2568            },
2569            d => panic!("expected Deny, got {:?}", d),
2570        }
2571    }
2572
2573    #[tokio::test]
2574    async fn pdp_on_allow_can_deny() {
2575        // PDP allows, but an on_allow reaction can still deny (e.g., a
2576        // taint check that fails). Outcome: deny.
2577        let mut bag = AttributeBag::new();
2578        let steps = vec![Effect::Pdp {
2579            call: PdpCall {
2580                dialect: PdpDialect::Cedar,
2581                args: serde_yaml::Value::Null,
2582            },
2583            on_deny: vec![],
2584            on_allow: vec![Effect::When {
2585                condition: Expression::Always,
2586                body: vec![Effect::Deny {
2587                    reason: Some("reaction veto".into()),
2588                    code: None,
2589                }],
2590                source: "on_allow[0]".into(),
2591            }],
2592        }];
2593        let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2594            decision: Decision::Allow,
2595        });
2596        match evaluate_effects(
2597            &steps,
2598            &mut bag,
2599            &pdp,
2600            &null_plugins(),
2601            &noop_delegations(),
2602            crate::step::DispatchPhase::Pre,
2603            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2604        )
2605        .await
2606        .decision
2607        {
2608            Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("reaction veto")),
2609            d => panic!("expected Deny, got {:?}", d),
2610        }
2611    }
2612
2613    #[tokio::test]
2614    async fn pdp_error_is_fail_closed() {
2615        let mut bag = AttributeBag::new();
2616        let steps = vec![pdp_step("dummy")];
2617        match evaluate_effects(
2618            &steps,
2619            &mut bag,
2620            &(Arc::new(ErroringPdp) as Arc<dyn PdpResolver>),
2621            &null_plugins(),
2622            &noop_delegations(),
2623            crate::step::DispatchPhase::Pre,
2624            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2625        )
2626        .await
2627        .decision
2628        {
2629            Decision::Deny { reason, .. } => {
2630                assert!(reason.unwrap().contains("PDP error"));
2631            },
2632            d => panic!("expected Deny on PDP error, got {:?}", d),
2633        }
2634    }
2635
2636    #[tokio::test]
2637    async fn plugin_allow_continues_deny_halts() {
2638        let mut bag = AttributeBag::new();
2639        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(FakePlugin {
2640            decisions: std::collections::HashMap::from([
2641                ("ok_plugin".to_string(), Decision::Allow),
2642                (
2643                    "blocking_plugin".to_string(),
2644                    Decision::Deny {
2645                        reason: Some("rate limit hit".into()),
2646                        rule_source: "plugin".into(),
2647                    },
2648                ),
2649            ]),
2650        });
2651
2652        let allow_only = vec![Effect::Plugin {
2653            name: "ok_plugin".into(),
2654        }];
2655        assert_eq!(
2656            evaluate_effects(
2657                &allow_only,
2658                &mut bag,
2659                &(Arc::new(FakePdp {
2660                    decision: Decision::Allow
2661                }) as Arc<dyn PdpResolver>),
2662                &plugins,
2663                &noop_delegations(),
2664                crate::step::DispatchPhase::Pre,
2665                &mut crate::route::RoutePayload::new(serde_json::Value::Null)
2666            )
2667            .await
2668            .decision,
2669            Decision::Allow,
2670        );
2671
2672        let with_deny = vec![
2673            Effect::Plugin {
2674                name: "ok_plugin".into(),
2675            },
2676            Effect::Plugin {
2677                name: "blocking_plugin".into(),
2678            },
2679        ];
2680        match evaluate_effects(
2681            &with_deny,
2682            &mut bag,
2683            &(Arc::new(FakePdp {
2684                decision: Decision::Allow,
2685            }) as Arc<dyn PdpResolver>),
2686            &plugins,
2687            &noop_delegations(),
2688            crate::step::DispatchPhase::Pre,
2689            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2690        )
2691        .await
2692        .decision
2693        {
2694            Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("rate limit hit")),
2695            d => panic!("expected Deny from blocking_plugin, got {:?}", d),
2696        }
2697    }
2698
2699    #[tokio::test]
2700    async fn plugin_error_is_fail_closed() {
2701        let mut bag = AttributeBag::new();
2702        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(FakePlugin {
2703            decisions: Default::default(),
2704        });
2705        let steps = vec![Effect::Plugin {
2706            name: "missing".into(),
2707        }];
2708        match evaluate_effects(
2709            &steps,
2710            &mut bag,
2711            &(Arc::new(FakePdp {
2712                decision: Decision::Allow,
2713            }) as Arc<dyn PdpResolver>),
2714            &plugins,
2715            &noop_delegations(),
2716            crate::step::DispatchPhase::Pre,
2717            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2718        )
2719        .await
2720        .decision
2721        {
2722            Decision::Deny {
2723                reason,
2724                rule_source,
2725            } => {
2726                assert!(reason.unwrap().contains("missing"));
2727                assert!(rule_source.contains("missing"));
2728            },
2729            d => panic!("expected Deny, got {:?}", d),
2730        }
2731    }
2732
2733    #[tokio::test]
2734    async fn taint_step_always_continues_and_accumulates() {
2735        let mut bag = AttributeBag::new();
2736        let steps = vec![
2737            Effect::Taint {
2738                label: "PII".into(),
2739                scopes: vec![crate::pipeline::TaintScope::Session],
2740            },
2741            // A later rule should still fire — taint doesn't short-circuit.
2742            Effect::When {
2743                condition: Expression::Always,
2744                body: vec![Effect::Deny {
2745                    reason: Some("after taint".into()),
2746                    code: None,
2747                }],
2748                source: "p[1]".into(),
2749            },
2750        ];
2751        let eval = evaluate_effects(
2752            &steps,
2753            &mut bag,
2754            &(Arc::new(FakePdp {
2755                decision: Decision::Allow,
2756            }) as Arc<dyn PdpResolver>),
2757            &null_plugins(),
2758            &noop_delegations(),
2759            crate::step::DispatchPhase::Pre,
2760            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2761        )
2762        .await;
2763        match eval.decision {
2764            Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("after taint")),
2765            d => panic!("expected Deny from rule after Taint, got {:?}", d),
2766        }
2767        // Step::Taint should have been accumulated into the phase's taints
2768        // before the deny landed — audit needs to see what tainted before
2769        // the policy halted.
2770        assert_eq!(eval.taints.len(), 1);
2771        assert_eq!(eval.taints[0].label, "PII");
2772        assert_eq!(
2773            eval.taints[0].scopes,
2774            vec![crate::pipeline::TaintScope::Session]
2775        );
2776    }
2777
2778    // ----- E2: FieldOp end-to-end through evaluate_steps -----
2779
2780    #[tokio::test]
2781    async fn field_op_in_do_redacts_args_during_pre_phase() {
2782        // Sketches the demo case: when condition holds, redact args.ssn
2783        // — verifies the dispatcher walks effects, lifts the FieldOp
2784        // out, and rewrites the payload.
2785        let mut bag = AttributeBag::new();
2786        // Predicate is the rule's `when:`; here we make it always true.
2787        let stages = vec![Stage::Redact { condition: None }];
2788        let rule = Rule {
2789            condition: Expression::Always,
2790            effects: vec![Effect::FieldOp {
2791                path: "args.ssn".into(),
2792                stages,
2793            }],
2794            source: "demo.policy[0]".into(),
2795        };
2796        let steps = vec![Effect::from(rule)];
2797        let mut payload = crate::route::RoutePayload::new(json!({
2798            "ssn": "123-45-6789",
2799            "name": "Jane",
2800        }));
2801
2802        let eval = evaluate_effects(
2803            &steps,
2804            &mut bag,
2805            &(Arc::new(FakePdp {
2806                decision: Decision::Allow,
2807            }) as Arc<dyn PdpResolver>),
2808            &null_plugins(),
2809            &noop_delegations(),
2810            crate::step::DispatchPhase::Pre,
2811            &mut payload,
2812        )
2813        .await;
2814
2815        assert_eq!(eval.decision, Decision::Allow);
2816        assert!(eval.args_modified, "FieldOp should flag args_modified");
2817        // The ssn field should now read `[REDACTED]` (the stock value
2818        // the Stage::Redact applier writes when no when-clause is set).
2819        assert_eq!(
2820            payload.args.get("ssn").and_then(|v| v.as_str()),
2821            Some("[REDACTED]")
2822        );
2823        // Other fields untouched.
2824        assert_eq!(
2825            payload.args.get("name").and_then(|v| v.as_str()),
2826            Some("Jane")
2827        );
2828    }
2829
2830    #[tokio::test]
2831    async fn field_op_targeting_result_in_pre_phase_is_skipped() {
2832        // A `result.X | ...` op encountered during the Pre phase is a
2833        // no-op — the result hasn't been produced yet. Same rule body
2834        // can be reused across phases without branching.
2835        let mut bag = AttributeBag::new();
2836        let stages = vec![Stage::Redact { condition: None }];
2837        let rule = Rule {
2838            condition: Expression::Always,
2839            effects: vec![Effect::FieldOp {
2840                path: "result.ssn".into(),
2841                stages,
2842            }],
2843            source: "demo.policy[0]".into(),
2844        };
2845        let mut payload = crate::route::RoutePayload::new(json!({}));
2846        let eval = evaluate_effects(
2847            &vec![Effect::from(rule)],
2848            &mut bag,
2849            &(Arc::new(FakePdp {
2850                decision: Decision::Allow,
2851            }) as Arc<dyn PdpResolver>),
2852            &null_plugins(),
2853            &noop_delegations(),
2854            crate::step::DispatchPhase::Pre,
2855            &mut payload,
2856        )
2857        .await;
2858        assert_eq!(eval.decision, Decision::Allow);
2859        assert!(!eval.args_modified);
2860        assert!(!eval.result_modified);
2861    }
2862
2863    #[tokio::test]
2864    async fn field_op_with_invalid_path_denies() {
2865        // Path missing the `args.` / `result.` prefix is an author bug
2866        // — fail closed with a clear violation rather than silently
2867        // doing nothing.
2868        let mut bag = AttributeBag::new();
2869        let stages = vec![Stage::Redact { condition: None }];
2870        let rule = Rule {
2871            condition: Expression::Always,
2872            effects: vec![Effect::FieldOp {
2873                path: "ssn".into(), // missing prefix
2874                stages,
2875            }],
2876            source: "demo.policy[0]".into(),
2877        };
2878        let mut payload = crate::route::RoutePayload::new(json!({"ssn": "x"}));
2879        let eval = evaluate_effects(
2880            &vec![Effect::from(rule)],
2881            &mut bag,
2882            &(Arc::new(FakePdp {
2883                decision: Decision::Allow,
2884            }) as Arc<dyn PdpResolver>),
2885            &null_plugins(),
2886            &noop_delegations(),
2887            crate::step::DispatchPhase::Pre,
2888            &mut payload,
2889        )
2890        .await;
2891        match eval.decision {
2892            Decision::Deny {
2893                reason,
2894                rule_source,
2895            } => {
2896                assert!(reason.unwrap_or_default().contains("must start with"));
2897                assert_eq!(rule_source, "demo.policy[0]");
2898            },
2899            other => panic!("expected Deny, got {:?}", other),
2900        }
2901    }
2902
2903    // ----- E3: Sequential / Parallel orchestration -----
2904
2905    #[tokio::test]
2906    async fn sequential_runs_effects_in_order_until_deny() {
2907        // A Sequential block runs each effect in order. Allow-only
2908        // effects pass through; the first Deny halts the rest of the
2909        // sequential body AND the parent step.
2910        let mut bag = AttributeBag::new();
2911        let mut payload = crate::route::RoutePayload::new(json!({}));
2912
2913        let rule = Rule {
2914            condition: Expression::Always,
2915            effects: vec![Effect::Sequential(vec![
2916                Effect::Allow,
2917                Effect::Deny {
2918                    reason: Some("blocked by sequential".into()),
2919                    code: Some("seq.test".into()),
2920                },
2921                Effect::Allow, // unreachable
2922            ])],
2923            source: "test.policy[0]".into(),
2924        };
2925
2926        let eval = evaluate_effects(
2927            &vec![Effect::from(rule)],
2928            &mut bag,
2929            &(Arc::new(FakePdp {
2930                decision: Decision::Allow,
2931            }) as Arc<dyn PdpResolver>),
2932            &null_plugins(),
2933            &noop_delegations(),
2934            crate::step::DispatchPhase::Pre,
2935            &mut payload,
2936        )
2937        .await;
2938
2939        match eval.decision {
2940            Decision::Deny {
2941                reason,
2942                rule_source,
2943            } => {
2944                assert_eq!(reason.as_deref(), Some("blocked by sequential"));
2945                // The `code` override on the effect won — `seq.test`
2946                // rather than the rule's `test.policy[0]` source.
2947                assert_eq!(rule_source, "seq.test");
2948            },
2949            other => panic!("expected Deny, got {:?}", other),
2950        }
2951    }
2952
2953    #[tokio::test]
2954    async fn parallel_allows_when_no_branch_denies() {
2955        // Both branches are no-op Allow → overall Continue → route Allow.
2956        let mut bag = AttributeBag::new();
2957        let mut payload = crate::route::RoutePayload::new(json!({}));
2958
2959        let rule = Rule {
2960            condition: Expression::Always,
2961            effects: vec![Effect::Parallel(vec![
2962                Effect::Allow,
2963                Effect::Taint {
2964                    label: "audit_branch".into(),
2965                    scopes: vec![crate::pipeline::TaintScope::Session],
2966                },
2967            ])],
2968            source: "test.policy[0]".into(),
2969        };
2970
2971        let eval = evaluate_effects(
2972            &vec![Effect::from(rule)],
2973            &mut bag,
2974            &(Arc::new(FakePdp {
2975                decision: Decision::Allow,
2976            }) as Arc<dyn PdpResolver>),
2977            &null_plugins(),
2978            &noop_delegations(),
2979            crate::step::DispatchPhase::Pre,
2980            &mut payload,
2981        )
2982        .await;
2983
2984        assert_eq!(eval.decision, Decision::Allow);
2985        // Taints from parallel branches accumulate into the outer.
2986        assert_eq!(eval.taints.len(), 1);
2987        assert_eq!(eval.taints[0].label, "audit_branch");
2988    }
2989
2990    #[tokio::test]
2991    async fn parallel_denies_when_any_branch_denies() {
2992        // One Allow, one Deny — overall Deny.
2993        let mut bag = AttributeBag::new();
2994        let mut payload = crate::route::RoutePayload::new(json!({}));
2995
2996        let rule = Rule {
2997            condition: Expression::Always,
2998            effects: vec![Effect::Parallel(vec![
2999                Effect::Allow,
3000                Effect::Deny {
3001                    reason: Some("branch 1 denied".into()),
3002                    code: None,
3003                },
3004            ])],
3005            source: "test.policy[0]".into(),
3006        };
3007
3008        let eval = evaluate_effects(
3009            &vec![Effect::from(rule)],
3010            &mut bag,
3011            &(Arc::new(FakePdp {
3012                decision: Decision::Allow,
3013            }) as Arc<dyn PdpResolver>),
3014            &null_plugins(),
3015            &noop_delegations(),
3016            crate::step::DispatchPhase::Pre,
3017            &mut payload,
3018        )
3019        .await;
3020
3021        match eval.decision {
3022            Decision::Deny { reason, .. } => {
3023                assert_eq!(reason.as_deref(), Some("branch 1 denied"));
3024            },
3025            other => panic!("expected Deny, got {:?}", other),
3026        }
3027    }
3028
3029    #[tokio::test]
3030    async fn parallel_picks_first_index_halt_not_first_to_complete() {
3031        // When two branches both deny, the one with the lower index
3032        // in the effects list wins — not the one that physically
3033        // finishes first.
3034        let mut bag = AttributeBag::new();
3035        let mut payload = crate::route::RoutePayload::new(json!({}));
3036
3037        let rule = Rule {
3038            condition: Expression::Always,
3039            effects: vec![Effect::Parallel(vec![
3040                Effect::Deny {
3041                    reason: Some("idx-0".into()),
3042                    code: None,
3043                },
3044                Effect::Deny {
3045                    reason: Some("idx-1".into()),
3046                    code: None,
3047                },
3048            ])],
3049            source: "test.policy[0]".into(),
3050        };
3051
3052        let eval = evaluate_effects(
3053            &vec![Effect::from(rule)],
3054            &mut bag,
3055            &(Arc::new(FakePdp {
3056                decision: Decision::Allow,
3057            }) as Arc<dyn PdpResolver>),
3058            &null_plugins(),
3059            &noop_delegations(),
3060            crate::step::DispatchPhase::Pre,
3061            &mut payload,
3062        )
3063        .await;
3064
3065        match eval.decision {
3066            Decision::Deny { reason, .. } => {
3067                assert_eq!(reason.as_deref(), Some("idx-0"), "lower-index halt wins");
3068            },
3069            other => panic!("expected Deny, got {:?}", other),
3070        }
3071    }
3072}