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    // Coerce both operands to f64 for the order comparison. Numeric-looking
147    // strings are coerced — LLM tool arguments routinely arrive as strings
148    // (e.g. `"amount": "25000"`), and a policy author writing
149    // `args.amount > 10000` plainly means a numeric comparison. A string
150    // that doesn't parse as a number is genuinely non-numeric → false
151    // (order operators don't apply, spec §2.3).
152    let a = match coerce_f64_attr(attr) {
153        Some(a) => a,
154        None => return false,
155    };
156    let b = match coerce_f64_lit(lit) {
157        Some(b) => b,
158        None => return false,
159    };
160    match op {
161        CompareOp::Gt => a > b,
162        CompareOp::GtEq => a >= b,
163        CompareOp::Lt => a < b,
164        CompareOp::LtEq => a <= b,
165        _ => unreachable!("numeric_compare called with non-numeric op"),
166    }
167}
168
169/// Coerce a bag attribute to `f64` for an order comparison: numbers pass
170/// through; a string is parsed (numeric-looking strings only); anything
171/// else is non-numeric.
172fn coerce_f64_attr(attr: &AttributeValue) -> Option<f64> {
173    match attr {
174        AttributeValue::Int(a) => Some(*a as f64),
175        AttributeValue::Float(a) => Some(*a),
176        AttributeValue::String(s) => s.trim().parse::<f64>().ok(),
177        _ => None,
178    }
179}
180
181/// Coerce a literal to `f64` for an order comparison. Same rules as
182/// [`coerce_f64_attr`] so `args.x > "10"` works symmetrically.
183fn coerce_f64_lit(lit: &Literal) -> Option<f64> {
184    match lit {
185        Literal::Int(b) => Some(*b as f64),
186        Literal::Float(b) => Some(*b),
187        Literal::String(s) => s.trim().parse::<f64>().ok(),
188        _ => None,
189    }
190}
191
192/// Heuristic: does `s` look like a bag attribute reference (e.g.
193/// `claim.manager`, `user.sub`) rather than a literal identity (e.g.
194/// `alice@corp.com`, a bare username)? Used to decide whether an
195/// unresolved elicitation `from` should fail closed (an attribute that
196/// didn't resolve) or pass through as a literal.
197///
198/// A reference is a dotted path of identifier characters only — starts
199/// with a letter and contains only `[A-Za-z0-9_.]` with at least one dot.
200/// Literals like emails (contain `@`) or display names (contain spaces)
201/// are excluded, so they still pass through verbatim.
202fn looks_like_attribute_ref(s: &str) -> bool {
203    s.contains('.')
204        && s.starts_with(|c: char| c.is_ascii_alphabetic())
205        && s.chars()
206            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
207}
208
209// =====================================================================
210// Async effect evaluator (policy: / post_policy: walks Vec<Effect>)
211// =====================================================================
212
213/// Walk an Effect list against the bag, dispatching PDP calls via `pdp`
214/// and plugin invocations via `plugins`. Returns the phase's overall
215/// decision.
216///
217/// Semantics (DSL §3, §7.5):
218/// - `Effect::When` — evaluate the condition; if true, run the body in
219///   order with the same first-deny-wins logic.
220/// - `Effect::Pdp` — call resolver; on Allow run `on_allow` reactions and
221///   continue; on Deny run `on_deny` reactions and return the deny
222///   (reactions can override with their own deny, but cannot turn a deny
223///   into an allow).
224/// - `Effect::Plugin` — invoke; Allow continues, Deny returns.
225/// - `Effect::Delegate` — mint downstream credential; writes
226///   `delegation.granted.*` keys back into the bag; deny-on-failure unless
227///   the step's `on_error` overrides.
228/// - `Effect::Taint` — record the label; never halts.
229/// - `Effect::FieldOp` — apply a pipe chain to `args.X` / `result.X`;
230///   may set `args_modified` / `result_modified`.
231/// - `Effect::Sequential` — run children in order, halt on first Deny.
232/// - `Effect::Parallel` — run children concurrently, abort on first Deny.
233/// - `Effect::Allow` — explicit no-op; continues the phase.
234/// - `Effect::Deny` — halt with the supplied reason/code.
235///
236/// PDP / plugin errors map to a Deny with the error in the reason, per
237/// the design's fail-closed default (DSL §8.9). Pre-E4 `evaluate_steps`
238/// is preserved as a deprecated alias that forwards here.
239#[allow(clippy::too_many_arguments)]
240pub async fn evaluate_effects(
241    effects: &[Effect],
242    bag: &mut AttributeBag,
243    pdp: &Arc<dyn PdpResolver>,
244    plugins: &Arc<dyn PluginInvoker>,
245    delegations: &Arc<dyn crate::step::DelegationInvoker>,
246    elicitations: &Arc<dyn crate::step::ElicitationInvoker>,
247    phase: crate::step::DispatchPhase,
248    payload: &mut crate::route::RoutePayload,
249) -> StepsEvaluation {
250    let mut taints: Vec<crate::pipeline::TaintEvent> = Vec::new();
251    let mut args_modified = false;
252    let mut result_modified = false;
253    let mut pending: Option<crate::step::PendingElicitation> = None;
254    for effect in effects {
255        // Each top-level effect runs against the shared mutable state.
256        // `Effect::When` / `Effect::Pdp` handle their own internal
257        // walking via dispatch_effect's recursive call.
258        let fallback_source = match effect {
259            Effect::When { source, .. } => source.as_str(),
260            _ => "",
261        };
262        match Box::pin(dispatch_effect(
263            effect,
264            fallback_source,
265            bag,
266            pdp,
267            plugins,
268            delegations,
269            elicitations,
270            phase,
271            &mut taints,
272            &mut args_modified,
273            &mut result_modified,
274            payload,
275        ))
276        .await
277        {
278            EffectOutcome::Continue => {},
279            EffectOutcome::Halt(decision) => {
280                return StepsEvaluation::deny(decision, taints, args_modified, result_modified);
281            },
282            EffectOutcome::Pending(bundle) => {
283                // Suspend the phase: stop walking (sequential elicitation),
284                // carry the bundle out. Decision stays Allow — the host
285                // gates on `pending` being set, not on a deny.
286                pending = Some(bundle);
287                break;
288            },
289        }
290    }
291    StepsEvaluation {
292        decision: Decision::Allow,
293        taints,
294        args_modified,
295        result_modified,
296        pending,
297    }
298}
299
300/// Outcome of `evaluate_effects`: the phase's decision plus taints emitted
301/// by any plugin steps that ran. Taints are accumulated even when the
302/// phase ultimately denies — audit needs to see what the plugins
303/// reported before the deny landed. Empty `taints` is the common case
304/// (most steps are predicates / PDP calls, not label emitters).
305///
306/// `args_modified` / `result_modified` are set when an `Effect::FieldOp`
307/// inside a `do:` body successfully mutated the route payload — the
308/// orchestrator uses them to OR-into the route-level "did anything
309/// change" signals so the host knows to re-serialize the body.
310#[derive(Debug, Clone)]
311pub struct StepsEvaluation {
312    pub decision: Decision,
313    pub taints: Vec<crate::pipeline::TaintEvent>,
314    pub args_modified: bool,
315    pub result_modified: bool,
316    /// Set when a phase suspended on an unresolved elicitation. `Some`
317    /// means "do not forward — emit `-32120` with this bundle." `decision`
318    /// is `Allow` in that case (nothing denied); the host gates forwarding
319    /// on `pending.is_none()`. See [`crate::step::PendingElicitation`].
320    pub pending: Option<crate::step::PendingElicitation>,
321}
322
323impl StepsEvaluation {
324    fn deny(
325        d: Decision,
326        taints: Vec<crate::pipeline::TaintEvent>,
327        args_modified: bool,
328        result_modified: bool,
329    ) -> Self {
330        Self {
331            decision: d,
332            taints,
333            args_modified,
334            result_modified,
335            pending: None,
336        }
337    }
338}
339
340/// Outcome of dispatching one effect. Internal control-flow signal —
341/// never serialized, never exposed in the IR. Sits between the per-
342/// effect dispatch (When / Pdp / Plugin / Delegate / Taint / Allow /
343/// Deny / FieldOp / Sequential / Parallel) and the caller's "do I keep
344/// walking the effects list or halt?" loop.
345enum EffectOutcome {
346    /// Effect completed without producing a Deny — caller moves on to
347    /// the next effect in the surrounding list.
348    Continue,
349    /// Effect produced a Deny decision — caller halts the rest of the
350    /// surrounding list, the rest of the phase, and the route.
351    Halt(Decision),
352    /// Effect dispatched an elicitation that hasn't resolved yet — the
353    /// phase *suspends*. Like `Halt` it short-circuits the surrounding
354    /// list, but it is NOT a deny: the carried bundle propagates up to
355    /// the host, which emits `-32120` (retry) instead of forwarding. The
356    /// phase decision stays `Allow`; pending being non-empty is what
357    /// blocks the forward (see [`PendingElicitation`]).
358    Pending(crate::step::PendingElicitation),
359}
360
361/// Run a single effect against the evaluator's state. Called by both
362/// `evaluate_effects` (top-level walk of `policy:` / `post_policy:`)
363/// and by recursive arms (Sequential, Parallel, When body, Pdp
364/// reactions), so there's exactly one place that knows how each
365/// effect kind dispatches.
366///
367/// `fallback_source` is the rule-source-position string used as the
368/// `rule_source` field on a `Decision::Deny` when the effect itself
369/// doesn't carry an explicit code (i.e. `Effect::Deny { code: None }`,
370/// or a deny coming back from a plugin / delegator without overriding
371/// the default).
372#[allow(clippy::too_many_arguments)]
373async fn dispatch_effect(
374    effect: &Effect,
375    fallback_source: &str,
376    bag: &mut AttributeBag,
377    pdp: &Arc<dyn PdpResolver>,
378    plugins: &Arc<dyn PluginInvoker>,
379    delegations: &Arc<dyn crate::step::DelegationInvoker>,
380    elicitations: &Arc<dyn crate::step::ElicitationInvoker>,
381    phase: crate::step::DispatchPhase,
382    taints: &mut Vec<crate::pipeline::TaintEvent>,
383    args_modified: &mut bool,
384    result_modified: &mut bool,
385    payload: &mut crate::route::RoutePayload,
386) -> EffectOutcome {
387    match effect {
388        Effect::Allow => EffectOutcome::Continue,
389
390        Effect::Deny { reason, code } => {
391            // Author-supplied code overrides the auto-generated source
392            // position. Lets MCP clients dispatch on stable categories
393            // (`quota.exceeded`) rather than positional codes that
394            // shift with YAML edits.
395            let rule_source = code.clone().unwrap_or_else(|| fallback_source.to_string());
396            EffectOutcome::Halt(Decision::Deny {
397                reason: reason.clone(),
398                rule_source,
399            })
400        },
401
402        Effect::Plugin { name } => {
403            match plugins
404                .invoke(name, bag, PluginInvocation::Step { phase })
405                .await
406            {
407                Ok(outcome) => {
408                    // Plugins can emit taints regardless of decision —
409                    // collect first, then act on the decision.
410                    taints.extend(outcome.taints);
411                    match outcome.decision {
412                        Decision::Allow => EffectOutcome::Continue,
413                        deny @ Decision::Deny { .. } => EffectOutcome::Halt(deny),
414                    }
415                },
416                Err(e) => EffectOutcome::Halt(Decision::Deny {
417                    reason: Some(format!("plugin `{}` error: {}", name, e)),
418                    rule_source: format!("plugin:{}", name),
419                }),
420            }
421        },
422
423        Effect::Delegate(delegate_step) => {
424            match delegations.delegate(delegate_step).await {
425                Ok(outcome) => match &outcome.decision {
426                    Decision::Allow => {
427                        // Surface granted_* keys into the bag so
428                        // downstream rules in this same step list can
429                        // read them (`require(delegation.granted.permissions
430                        // contains "X")`, etc.).
431                        use crate::attributes::AttributeValue;
432                        use crate::step::delegation_bag_keys as bk;
433
434                        bag.set(bk::GRANTED, AttributeValue::Bool(true));
435                        if !outcome.granted_permissions.is_empty() {
436                            let set: std::collections::HashSet<String> =
437                                outcome.granted_permissions.iter().cloned().collect();
438                            bag.set(bk::GRANTED_PERMISSIONS, AttributeValue::StringSet(set));
439                        }
440                        if let Some(aud) = &outcome.granted_audience {
441                            bag.set(bk::GRANTED_AUDIENCE, aud.clone());
442                        }
443                        if let Some(exp) = &outcome.granted_expires_at {
444                            bag.set(bk::GRANTED_EXPIRES_AT, exp.clone());
445                        }
446                        EffectOutcome::Continue
447                    },
448                    Decision::Deny { .. } => {
449                        // Apply the step's on_error policy. Default
450                        // ("deny") halts; "continue" lets the pipeline
451                        // keep going so subsequent rules can branch on
452                        // the absent `delegation.granted` flag.
453                        let on_error = delegate_step
454                            .on_error
455                            .as_deref()
456                            .unwrap_or("deny")
457                            .to_ascii_lowercase();
458                        if on_error == "continue" {
459                            EffectOutcome::Continue
460                        } else {
461                            EffectOutcome::Halt(outcome.decision)
462                        }
463                    },
464                },
465                Err(e) => {
466                    // Transport / lookup failure. on_error treats this
467                    // the same way as a plugin-side deny.
468                    let on_error = delegate_step
469                        .on_error
470                        .as_deref()
471                        .unwrap_or("deny")
472                        .to_ascii_lowercase();
473                    if on_error == "continue" {
474                        EffectOutcome::Continue
475                    } else {
476                        EffectOutcome::Halt(Decision::Deny {
477                            reason: Some(format!(
478                                "delegate `{}` error: {}",
479                                delegate_step.plugin_name, e
480                            )),
481                            rule_source: delegate_step.source.clone(),
482                        })
483                    }
484                },
485            }
486        },
487
488        Effect::Elicit(elicit_step) => dispatch_elicitation(elicit_step, bag, elicitations).await,
489
490        Effect::Taint { label, scopes } => {
491            // Emit the taint into the phase's accumulator so it flows
492            // into `RouteDecision.taints`. Apl-cpex's invoker handles
493            // the session-store persistence side at request end — here
494            // we only record the event. Scopes come straight from the
495            // parser (`taint(label, session, message)` syntax).
496            taints.push(crate::pipeline::TaintEvent {
497                label: label.clone(),
498                scopes: scopes.clone(),
499            });
500            EffectOutcome::Continue
501        },
502
503        Effect::FieldOp { path, stages } => {
504            dispatch_field_op(
505                path,
506                stages,
507                fallback_source,
508                bag,
509                plugins,
510                phase,
511                taints,
512                args_modified,
513                result_modified,
514                payload,
515            )
516            .await
517        },
518
519        Effect::Sequential(effects) => {
520            // Semantically the same as inlining the list into the
521            // enclosing scope — walk in order, stop on first Halt.
522            // The variant exists for explicit grouping and to pair
523            // with `Parallel` in the IR.
524            for inner in effects {
525                match Box::pin(dispatch_effect(
526                    inner,
527                    fallback_source,
528                    bag,
529                    pdp,
530                    plugins,
531                    delegations,
532                    elicitations,
533                    phase,
534                    taints,
535                    args_modified,
536                    result_modified,
537                    payload,
538                ))
539                .await
540                {
541                    EffectOutcome::Continue => continue,
542                    // Halt (deny) and Pending (suspend) both propagate up.
543                    other => return other,
544                }
545            }
546            EffectOutcome::Continue
547        },
548
549        Effect::Parallel(effects) => {
550            // `dispatch_parallel` returns an explicit `BoxFuture<'_, _>`
551            // (Send by construction) so the recursive
552            // dispatch_effect → dispatch_parallel → dispatch_effect
553            // chain doesn't trip the compiler's Send-inference cycle.
554            dispatch_parallel(
555                effects,
556                fallback_source,
557                bag,
558                pdp,
559                plugins,
560                delegations,
561                elicitations,
562                phase,
563                taints,
564                payload,
565            )
566            .await
567        },
568
569        Effect::When {
570            condition,
571            body,
572            source,
573        } => {
574            // Predicate-gated body — replaces the historical
575            // `Step::Rule`. Skip silently when the condition is false;
576            // otherwise walk the body in order and halt on first Deny.
577            if !eval_expression(condition, bag) {
578                return EffectOutcome::Continue;
579            }
580            for inner in body {
581                match Box::pin(dispatch_effect(
582                    inner,
583                    source,
584                    bag,
585                    pdp,
586                    plugins,
587                    delegations,
588                    elicitations,
589                    phase,
590                    taints,
591                    args_modified,
592                    result_modified,
593                    payload,
594                ))
595                .await
596                {
597                    EffectOutcome::Continue => continue,
598                    // Halt (deny) and Pending (suspend) both propagate up.
599                    other => return other,
600                }
601            }
602            EffectOutcome::Continue
603        },
604
605        Effect::Pdp {
606            call,
607            on_allow,
608            on_deny,
609        } => {
610            // External PDP call — replaces `Step::Pdp`. Reactions run
611            // through the same dispatch_effect path (recursively).
612            match pdp.evaluate(call, bag).await {
613                Ok(pdp_result) => match pdp_result.decision {
614                    Decision::Allow => {
615                        // Walk on_allow; if it ends without a Halt the
616                        // PDP allow stands and we continue.
617                        for inner in on_allow {
618                            match Box::pin(dispatch_effect(
619                                inner,
620                                fallback_source,
621                                bag,
622                                pdp,
623                                plugins,
624                                delegations,
625                                elicitations,
626                                phase,
627                                taints,
628                                args_modified,
629                                result_modified,
630                                payload,
631                            ))
632                            .await
633                            {
634                                EffectOutcome::Continue => continue,
635                                // Halt (deny) and Pending (suspend) propagate.
636                                other => return other,
637                            }
638                        }
639                        EffectOutcome::Continue
640                    },
641                    deny @ Decision::Deny { .. } => {
642                        // Reactions can override the PDP's deny reason
643                        // (e.g. `on_deny: [deny "..."]`) but cannot
644                        // upgrade the deny to allow — if reactions
645                        // walked clean, the PDP's original deny stands.
646                        for inner in on_deny {
647                            match Box::pin(dispatch_effect(
648                                inner,
649                                fallback_source,
650                                bag,
651                                pdp,
652                                plugins,
653                                delegations,
654                                elicitations,
655                                phase,
656                                taints,
657                                args_modified,
658                                result_modified,
659                                payload,
660                            ))
661                            .await
662                            {
663                                EffectOutcome::Continue => {},
664                                // A reaction can override the deny reason;
665                                // a suspended reaction (Pending) surfaces.
666                                other => return other,
667                            }
668                        }
669                        EffectOutcome::Halt(deny)
670                    },
671                },
672                Err(e) => EffectOutcome::Halt(Decision::Deny {
673                    reason: Some(format!("PDP error: {}", e)),
674                    rule_source: format!("pdp:{:?}", call.dialect),
675                }),
676            }
677        },
678    }
679}
680
681/// Drive one [`Effect::Elicit`] step: dispatch on first arrival, check
682/// status on every pass, and on an approved-and-genuine response apply
683/// the runtime's `scope`-over-args sufficiency check before allowing the
684/// phase to continue. See `docs/apl-manager-approval-ciba-design.md`.
685///
686/// Failure handling follows the step's `on_error` (default `deny`,
687/// fail-closed). An explicit human *denial* always halts regardless of
688/// `on_error` — `on_error` governs channel/validation *failures*, not a
689/// valid "no" from the approver.
690///
691/// While the human hasn't answered, the phase *suspends*: the step yields
692/// an [`EffectOutcome::Pending`] carrying the bundle the host turns into a
693/// JSON-RPC `-32120` (retry) instead of forwarding. The bag also records
694/// `elicitation.status = pending` so the host/audit can see the in-flight
695/// state.
696async fn dispatch_elicitation(
697    step: &crate::step::ElicitStep,
698    bag: &mut AttributeBag,
699    elicitations: &Arc<dyn crate::step::ElicitationInvoker>,
700) -> EffectOutcome {
701    use crate::step::{elicitation_bag_keys as bk, ElicitationOutcome, ElicitationStatus};
702
703    // `on_error` applies to *failures* (channel error, invalid response,
704    // expiry, still-pending) — not to a genuine denial.
705    let on_error_continue = step
706        .on_error
707        .as_deref()
708        .unwrap_or("deny")
709        .eq_ignore_ascii_case("continue");
710    let fail = |reason: String| -> EffectOutcome {
711        if on_error_continue {
712            EffectOutcome::Continue
713        } else {
714            EffectOutcome::Halt(Decision::Deny {
715                reason: Some(reason),
716                rule_source: step.source.clone(),
717            })
718        }
719    };
720
721    // First arrival vs. retry: the agent echoes `elicitation.id` on
722    // retry. Absent → first arrival: dispatch and record the pending
723    // bundle in the bag.
724    //
725    // Resolve `from` against the bag (e.g. `claim.manager` → the
726    // manager's identity). A `from` written as a literal identity (e.g.
727    // `alice@corp.com`) that isn't a bag key falls through to the literal.
728    // But a `from` that *looks like* an unresolved attribute reference
729    // (e.g. `claim.manager` when the claim is absent) must NOT be sent to
730    // the channel verbatim — that would dispatch an approval to a bogus
731    // `login_hint`. This is an auth boundary: fail closed instead. The
732    // attribute vocabulary lives here in the runtime, so the invoker
733    // receives the resolved identity rather than re-deriving it.
734    let resolved_from = match bag.get_string(&step.from) {
735        Some(v) => v.to_string(),
736        None if looks_like_attribute_ref(&step.from) => {
737            return fail(format!(
738                "elicitation `from` attribute `{}` did not resolve to an identity",
739                step.from
740            ));
741        },
742        None => step.from.clone(),
743    };
744    let id = match bag.get_string(bk::ID) {
745        Some(existing) => existing.to_string(),
746        None => match elicitations.dispatch(step, &resolved_from).await {
747            Ok(d) => {
748                bag.set(bk::ID, d.id.clone());
749                bag.set(bk::STATUS, "pending".to_string());
750                // Optional audit label — not a routing key.
751                if let Some(channel) = &step.channel {
752                    bag.set(bk::CHANNEL, channel.clone());
753                }
754                if let Some(approver) = &d.approver {
755                    bag.set(bk::APPROVER, approver.clone());
756                }
757                if let Some(intent) = &d.intent_id {
758                    bag.set(bk::INTENT_ID, intent.clone());
759                }
760                if let Some(exp) = &d.expires_at {
761                    bag.set(bk::EXPIRES_AT, exp.clone());
762                }
763                d.id
764            },
765            Err(e) => return fail(format!("elicitation dispatch failed: {e}")),
766        },
767    };
768
769    // Status check — non-blocking read of the channel's current state.
770    let status = match elicitations.check(step, &id).await {
771        Ok(s) => s,
772        Err(e) => return fail(format!("elicitation check failed: {e}")),
773    };
774
775    match status {
776        ElicitationStatus::Pending => {
777            // Suspend, don't deny: hand the host a pending bundle so it
778            // emits `-32120` (retry) instead of forwarding. Built from the
779            // bag, which dispatch populated — works the same on first
780            // arrival and on a later still-pending retry.
781            bag.set(bk::STATUS, "pending".to_string());
782            let approver = bag.get_string(bk::APPROVER).map(str::to_string);
783            let intent_id = bag.get_string(bk::INTENT_ID).map(str::to_string);
784            let expires_at = bag.get_string(bk::EXPIRES_AT).map(str::to_string);
785            EffectOutcome::Pending(crate::step::PendingElicitation {
786                id,
787                plugin_name: step.plugin_name.clone(),
788                approver,
789                intent_id,
790                channel: step.channel.clone(),
791                expires_at,
792                source: step.source.clone(),
793            })
794        },
795        ElicitationStatus::Expired => {
796            bag.set(bk::STATUS, "expired".to_string());
797            fail("elicitation expired before a response".to_string())
798        },
799        ElicitationStatus::Resolved {
800            outcome: ElicitationOutcome::Denied,
801        } => {
802            bag.set(bk::STATUS, "resolved".to_string());
803            bag.set(bk::OUTCOME, "denied".to_string());
804            // A genuine "no" halts unconditionally — not subject to
805            // `on_error`.
806            EffectOutcome::Halt(Decision::Deny {
807                reason: Some("elicitation denied by approver".to_string()),
808                rule_source: step.source.clone(),
809            })
810        },
811        ElicitationStatus::Resolved {
812            outcome: ElicitationOutcome::Approved,
813        } => {
814            // Genuineness (plugin): signature / intent binding /
815            // responder identity.
816            let validation = match elicitations.validate(step, &id).await {
817                Ok(v) => v,
818                Err(e) => return fail(format!("elicitation validation failed: {e}")),
819            };
820            if !validation.valid {
821                let why = validation
822                    .reason
823                    .unwrap_or_else(|| "response failed validation".to_string());
824                return fail(format!("elicitation invalid: {why}"));
825            }
826
827            // Sufficiency (runtime): evaluate `scope` against the live
828            // args. Kept in APL because the channel (Keycloak CIBA) can't
829            // bind args — no RFC 9396 RAR.
830            if let Some(scope_src) = &step.scope {
831                match crate::parser::parse_predicate(scope_src) {
832                    Ok(expr) => {
833                        if !eval_expression(&expr, bag) {
834                            return fail(format!("elicitation scope not satisfied: `{scope_src}`"));
835                        }
836                    },
837                    Err(e) => {
838                        return fail(format!(
839                            "elicitation scope `{scope_src}` failed to parse: {e}"
840                        ));
841                    },
842                }
843            }
844
845            // Approved + genuine + sufficient. Record resolved facts for
846            // downstream rules / audit, then continue.
847            bag.set(bk::STATUS, "resolved".to_string());
848            bag.set(bk::OUTCOME, "approved".to_string());
849            if let Some(approver) = validation.approver {
850                bag.set(bk::APPROVER, approver);
851            }
852            if let Some(intent) = validation.intent_id {
853                bag.set(bk::INTENT_ID, intent);
854            }
855            EffectOutcome::Continue
856        },
857    }
858}
859
860/// Run a list of effects concurrently. Each branch gets its own
861/// cloned bag and payload — mutations inside a branch don't
862/// propagate back to the shared outer state. Taints from every
863/// branch are merged into the outer `taints` vec (taints are
864/// append-only event logs, safe to concatenate). First Halt by
865/// branch index wins; the remaining branches are aborted via
866/// `cpex_orchestration::run_branches`'s `short_circuit_on_deny`.
867///
868/// Config-load already rejected `FieldOp` / `Delegate` here via
869/// [`Effect::validate_parallel_purity`], so at runtime we trust the
870/// IR not to contain mutation effects.
871///
872/// # Concurrency model (E3.2)
873///
874/// Built on [`cpex_orchestration::run_branches`] — the same JoinSet
875/// + abort-on-deny primitive `cpex-core`'s executor uses for its
876/// concurrent phase. Each branch is `tokio::spawn`ed onto the
877/// runtime, so branches get true OS-thread parallelism (vs. the v1
878/// implementation's `join_all`, which only interleaved on one
879/// task). To meet the `'static + Send` bounds for spawning, the
880/// invoker references are `&Arc<dyn ...>` — we `Arc::clone` an
881/// owned reference into each branch closure.
882///
883/// Note: no per-branch timeout. The DSL doesn't expose one, and
884/// plugin-level timeouts upstream of this call (in cpex-core's
885/// executor) bound individual plugin invocations. If a route ever
886/// needs a per-branch budget the orchestration crate already
887/// supports `BranchConfig::timeout_per_branch` — wire it through a
888/// `Effect::Parallel` extension if/when needed.
889// Returns an explicit `BoxFuture` rather than `impl Future` so the
890// caller (`dispatch_effect`'s `Effect::Parallel` arm, which is itself
891// `async fn`) can break the Send-inference cycle this would otherwise
892// introduce: dispatch_effect's opaque return type would depend on
893// dispatch_parallel's, and dispatch_parallel spawns futures that
894// recursively re-enter dispatch_effect. A concrete `BoxFuture` is
895// `Pin<Box<dyn Future + Send + 'a>>` — already Send by construction,
896// no inference required.
897fn dispatch_parallel<'a>(
898    effects: &'a [Effect],
899    fallback_source: &'a str,
900    bag: &'a AttributeBag,
901    pdp: &'a Arc<dyn PdpResolver>,
902    plugins: &'a Arc<dyn PluginInvoker>,
903    delegations: &'a Arc<dyn crate::step::DelegationInvoker>,
904    elicitations: &'a Arc<dyn crate::step::ElicitationInvoker>,
905    phase: crate::step::DispatchPhase,
906    taints: &'a mut Vec<crate::pipeline::TaintEvent>,
907    payload: &'a crate::route::RoutePayload,
908) -> futures::future::BoxFuture<'a, EffectOutcome> {
909    Box::pin(async move {
910        use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch};
911
912        if effects.is_empty() {
913            return EffectOutcome::Continue;
914        }
915
916        // Build one spawn-ready branch future per effect. Each branch
917        // owns:
918        //   * a cloned bag and payload — branch mutations stay local;
919        //   * cloned Arcs to the invokers — `'static + Send`, ready for
920        //     `tokio::spawn`;
921        //   * an owned copy of the effect to evaluate (clone is cheap
922        //     for the variants `Parallel` can hold: Allow, Deny, Plugin,
923        //     Taint, Sequential, Parallel, When, Pdp).
924        let mut branches: Vec<ErasedBranch<(EffectOutcome, Vec<crate::pipeline::TaintEvent>)>> =
925            Vec::with_capacity(effects.len());
926        for effect in effects.iter() {
927            let effect = effect.clone();
928            let fallback = fallback_source.to_string();
929            let mut branch_bag = bag.clone();
930            let mut branch_payload = payload.clone();
931            let pdp = Arc::clone(pdp);
932            let plugins = Arc::clone(plugins);
933            let delegations = Arc::clone(delegations);
934            let elicitations = Arc::clone(elicitations);
935            branches.push(Box::pin(async move {
936                let mut branch_taints: Vec<crate::pipeline::TaintEvent> = Vec::new();
937                let mut branch_args_modified = false;
938                let mut branch_result_modified = false;
939                let outcome = Box::pin(dispatch_effect(
940                    &effect,
941                    &fallback,
942                    &mut branch_bag,
943                    &pdp,
944                    &plugins,
945                    &delegations,
946                    &elicitations,
947                    phase,
948                    &mut branch_taints,
949                    &mut branch_args_modified,
950                    &mut branch_result_modified,
951                    &mut branch_payload,
952                ))
953                .await;
954                (outcome, branch_taints)
955            }));
956        }
957
958        // `is_deny` short-circuits the moment any branch returns
959        // `EffectOutcome::Halt(_)`. The remaining branches get
960        // `BranchOutcome::Aborted` and we drop their (already-cancelled)
961        // futures. Taints from already-completed branches still land.
962        let cfg = BranchConfig {
963            timeout_per_branch: None,
964            short_circuit_on_deny: true,
965        };
966        let outcomes = run_branches(
967            branches,
968            cfg,
969            |v: &(EffectOutcome, Vec<crate::pipeline::TaintEvent>)| {
970                matches!(v.0, EffectOutcome::Halt(_))
971            },
972        )
973        .await;
974
975        // Aggregate in input order: append every branch's taints; pick
976        // the first Halt (by branch index, not wall-clock order) as the
977        // overall result. Aborted / panicked branches contribute no
978        // taints — they didn't run to completion. A panicked branch is
979        // *not* converted into a Halt; we log via `tracing::warn!` and
980        // continue. (A misbehaving plugin shouldn't take down the
981        // parallel block any more than it would the host process.)
982        let mut first_halt: Option<Decision> = None;
983        for (idx, outcome) in outcomes.into_iter().enumerate() {
984            match outcome {
985                BranchOutcome::Completed((effect_outcome, branch_taints)) => {
986                    taints.extend(branch_taints);
987                    if first_halt.is_none() {
988                        if let EffectOutcome::Halt(d) = effect_outcome {
989                            first_halt = Some(d);
990                        }
991                    }
992                },
993                BranchOutcome::Aborted => {
994                    // Short-circuit cancelled this branch — intentional,
995                    // no diagnostic needed.
996                },
997                BranchOutcome::TimedOut => {
998                    // Unreachable today (no per-branch timeout
999                    // configured). Treat as a no-op if it ever fires
1000                    // post-config-extension.
1001                },
1002                BranchOutcome::Panicked(msg) => {
1003                    // A panicking branch is a misbehaving plugin/effect;
1004                    // dropping its output (no Halt, no taints) keeps the
1005                    // parallel block's other branches intact rather than
1006                    // taking the whole block down. apl-core has no
1007                    // tracing dep — host integrations that care can
1008                    // surface the panic via cpex-core's plugin error
1009                    // path. `idx`/`msg` are eaten here.
1010                    let _ = (idx, msg);
1011                },
1012            }
1013        }
1014
1015        match first_halt {
1016            Some(d) => EffectOutcome::Halt(d),
1017            None => EffectOutcome::Continue,
1018        }
1019    })
1020}
1021
1022/// Apply a `FieldOp` effect — resolve the path in args/result, run
1023/// the pipeline stages, write the outcome back into the payload.
1024///
1025/// Out-of-phase ops are silent no-ops: a Pre-phase rule with
1026/// `result.X | redact` skips because the result hasn't been produced
1027/// yet; a Post-phase rule with `args.X | redact` skips because the
1028/// args were already sent on the wire. This is intentional so the
1029/// same `when:`/`do:` rule body can be reused across phases without
1030/// the author needing to branch on phase.
1031///
1032/// Missing fields skip silently too (same as the args:/result: phase
1033/// pipelines) — a pipeline can't transform what isn't there. If the
1034/// author needs presence semantics, that's a `require(exists(args.X))`
1035/// upstream of the `do:` body.
1036#[allow(clippy::too_many_arguments)]
1037async fn dispatch_field_op(
1038    path: &str,
1039    stages: &[crate::pipeline::Stage],
1040    fallback_source: &str,
1041    bag: &mut AttributeBag,
1042    plugins: &Arc<dyn PluginInvoker>,
1043    phase: crate::step::DispatchPhase,
1044    taints: &mut Vec<crate::pipeline::TaintEvent>,
1045    args_modified: &mut bool,
1046    result_modified: &mut bool,
1047    payload: &mut crate::route::RoutePayload,
1048) -> EffectOutcome {
1049    use crate::route::{get_dotted, remove_dotted, set_dotted};
1050    use crate::step::DispatchPhase;
1051
1052    // Pick the right side of the payload based on the path prefix.
1053    // Out-of-phase ops drop silently (see the doc comment).
1054    enum Side {
1055        Args,
1056        Result,
1057    }
1058    let (root, subpath, side) = if let Some(rest) = path.strip_prefix("args.") {
1059        if !matches!(phase, DispatchPhase::Pre) {
1060            return EffectOutcome::Continue;
1061        }
1062        (&mut payload.args, rest, Side::Args)
1063    } else if let Some(rest) = path.strip_prefix("result.") {
1064        if !matches!(phase, DispatchPhase::Post) {
1065            return EffectOutcome::Continue;
1066        }
1067        let Some(result) = payload.result.as_mut() else {
1068            return EffectOutcome::Continue;
1069        };
1070        (result, rest, Side::Result)
1071    } else {
1072        return EffectOutcome::Halt(Decision::Deny {
1073            reason: Some(format!(
1074                "FieldOp path `{}` must start with `args.` or `result.`",
1075                path
1076            )),
1077            rule_source: fallback_source.to_string(),
1078        });
1079    };
1080
1081    let Some(current) = get_dotted(root, subpath).cloned() else {
1082        return EffectOutcome::Continue; // missing field → silent no-op
1083    };
1084
1085    let pipeline = crate::pipeline::Pipeline {
1086        stages: stages.to_vec(),
1087    };
1088    let eval = evaluate_pipeline(&pipeline, &current, bag, plugins, path, phase).await;
1089    taints.extend(eval.taints);
1090    let mark_modified = |side: Side, args: &mut bool, result: &mut bool| match side {
1091        Side::Args => *args = true,
1092        Side::Result => *result = true,
1093    };
1094    match eval.outcome {
1095        FieldOutcome::Pass => EffectOutcome::Continue,
1096        FieldOutcome::Replace(new_val) => {
1097            if set_dotted(root, subpath, new_val) {
1098                mark_modified(side, args_modified, result_modified);
1099            }
1100            EffectOutcome::Continue
1101        },
1102        FieldOutcome::Omit => {
1103            if remove_dotted(root, subpath) {
1104                mark_modified(side, args_modified, result_modified);
1105            }
1106            EffectOutcome::Continue
1107        },
1108        FieldOutcome::Deny {
1109            reason,
1110            stage_index: _,
1111        } => EffectOutcome::Halt(Decision::Deny {
1112            reason: Some(reason),
1113            rule_source: fallback_source.to_string(),
1114        }),
1115    }
1116}
1117
1118// =====================================================================
1119// Pipe-chain evaluator (args: / result: field pipelines)
1120// =====================================================================
1121
1122/// Result of running a pipeline against one field's value.
1123///
1124/// `Pass`: every stage succeeded; the original value should be kept.
1125/// `Replace`: a transform produced a new value (also covers conditional
1126/// `redact` firing).
1127/// `Omit`: an `omit` stage fired; the field should be dropped from output.
1128/// `Deny`: a validator failed; pipeline halted; the route should deny.
1129#[derive(Debug, Clone, PartialEq)]
1130pub enum FieldOutcome {
1131    Pass,
1132    Replace(serde_json::Value),
1133    Omit,
1134    Deny { reason: String, stage_index: usize },
1135}
1136
1137/// Full result of a pipeline run: value-level outcome plus accumulated
1138/// taint side effects.
1139///
1140/// `taint(...)` stages, plugin invocations, and `scan(...)` stages can all
1141/// emit taints; the evaluator collects them here and hands them to the host
1142/// (apl-cpex) for SessionStore writes. Taints accumulate even on `Replace`
1143/// and `Omit` outcomes; they do not accumulate past a `Deny` (the pipeline
1144/// halts at the failing stage).
1145#[derive(Debug, Clone, PartialEq)]
1146pub struct PipelineEvaluation {
1147    pub outcome: FieldOutcome,
1148    pub taints: Vec<TaintEvent>,
1149}
1150
1151/// Walk a pipeline against `value` and the bag, applying stages left-to-right.
1152///
1153/// Async because pipe-chain `plugin(name)` stages dispatch through
1154/// `PluginInvoker`, which is async.
1155///
1156/// `field_name` is the field this pipeline is attached to (from the wrapping
1157/// `FieldRule`). It's threaded into `PluginInvocation::Field` when a
1158/// `Stage::Plugin` fires so the invoker knows which field is in focus.
1159/// Pass `""` for standalone pipeline runs that aren't part of a field rule.
1160///
1161/// `Stage::Validate { name }` is currently a no-op with a TODO — the named
1162/// validator registry lands in a later step.
1163pub async fn evaluate_pipeline(
1164    pipeline: &Pipeline,
1165    value: &serde_json::Value,
1166    bag: &AttributeBag,
1167    plugins: &Arc<dyn PluginInvoker>,
1168    field_name: &str,
1169    phase: crate::step::DispatchPhase,
1170) -> PipelineEvaluation {
1171    let mut current = value.clone();
1172    let mut replaced = false;
1173    let mut taints: Vec<TaintEvent> = Vec::new();
1174
1175    for (idx, stage) in pipeline.stages.iter().enumerate() {
1176        match stage {
1177            // ----- Validators -----
1178            Stage::Type(tc) => {
1179                if !type_check(tc, &current) {
1180                    return PipelineEvaluation {
1181                        outcome: FieldOutcome::Deny {
1182                            reason: format!("expected {:?}, got {}", tc, value_kind(&current)),
1183                            stage_index: idx,
1184                        },
1185                        taints,
1186                    };
1187                }
1188            },
1189            Stage::Length { min, max } => {
1190                let Some(s) = current.as_str() else {
1191                    return PipelineEvaluation {
1192                        outcome: FieldOutcome::Deny {
1193                            reason: format!(
1194                                "len(...) requires string value, got {}",
1195                                value_kind(&current)
1196                            ),
1197                            stage_index: idx,
1198                        },
1199                        taints,
1200                    };
1201                };
1202                let len = s.chars().count();
1203                if min.map_or(false, |m| len < m) || max.map_or(false, |m| len > m) {
1204                    return PipelineEvaluation {
1205                        outcome: FieldOutcome::Deny {
1206                            reason: format!("length {} outside [{:?}, {:?}]", len, min, max),
1207                            stage_index: idx,
1208                        },
1209                        taints,
1210                    };
1211                }
1212            },
1213            Stage::Range { min, max } => {
1214                let Some(n) = current.as_i64() else {
1215                    return PipelineEvaluation {
1216                        outcome: FieldOutcome::Deny {
1217                            reason: format!(
1218                                "range requires integer value, got {}",
1219                                value_kind(&current)
1220                            ),
1221                            stage_index: idx,
1222                        },
1223                        taints,
1224                    };
1225                };
1226                if min.map_or(false, |m| n < m) || max.map_or(false, |m| n > m) {
1227                    return PipelineEvaluation {
1228                        outcome: FieldOutcome::Deny {
1229                            reason: format!("value {} outside [{:?}, {:?}]", n, min, max),
1230                            stage_index: idx,
1231                        },
1232                        taints,
1233                    };
1234                }
1235            },
1236            Stage::Enum { values } => {
1237                let Some(s) = current.as_str() else {
1238                    return PipelineEvaluation {
1239                        outcome: FieldOutcome::Deny {
1240                            reason: format!(
1241                                "enum(...) requires string value, got {}",
1242                                value_kind(&current)
1243                            ),
1244                            stage_index: idx,
1245                        },
1246                        taints,
1247                    };
1248                };
1249                if !values.iter().any(|v| v == s) {
1250                    return PipelineEvaluation {
1251                        outcome: FieldOutcome::Deny {
1252                            reason: format!("value `{}` not in enum {:?}", s, values),
1253                            stage_index: idx,
1254                        },
1255                        taints,
1256                    };
1257                }
1258            },
1259            Stage::Regex { pattern } => {
1260                // Compile-at-eval for now. A future step can swap to a
1261                // route-level pre-compile cache keyed by pattern.
1262                let re = match regex::Regex::new(pattern) {
1263                    Ok(r) => r,
1264                    Err(e) => {
1265                        return PipelineEvaluation {
1266                            outcome: FieldOutcome::Deny {
1267                                reason: format!("invalid regex `{}`: {}", pattern, e),
1268                                stage_index: idx,
1269                            },
1270                            taints,
1271                        };
1272                    },
1273                };
1274                let Some(s) = current.as_str() else {
1275                    return PipelineEvaluation {
1276                        outcome: FieldOutcome::Deny {
1277                            reason: format!(
1278                                "regex requires string value, got {}",
1279                                value_kind(&current)
1280                            ),
1281                            stage_index: idx,
1282                        },
1283                        taints,
1284                    };
1285                };
1286                if !re.is_match(s) {
1287                    return PipelineEvaluation {
1288                        outcome: FieldOutcome::Deny {
1289                            reason: format!("value did not match regex `{}`", pattern),
1290                            stage_index: idx,
1291                        },
1292                        taints,
1293                    };
1294                }
1295            },
1296            Stage::Validate { name } => {
1297                // Named-validator dispatch is not implemented in this
1298                // build. The parser rejects `validate(...)` at compile
1299                // time (parser.rs); this branch covers IR built
1300                // programmatically bypassing the parser. Same shape
1301                // as the parser's diagnostic — operators reach for
1302                // `regex(...)` or `plugin(...)` instead.
1303                return PipelineEvaluation {
1304                    outcome: FieldOutcome::Deny {
1305                        reason: format!(
1306                            "`validate({})` is not implemented; use `regex(...)` \
1307                             or `plugin({})` instead",
1308                            name, name,
1309                        ),
1310                        stage_index: idx,
1311                    },
1312                    taints,
1313                };
1314            },
1315
1316            // ----- Transforms -----
1317            Stage::Mask { keep_last } => {
1318                let Some(s) = current.as_str() else {
1319                    return PipelineEvaluation {
1320                        outcome: FieldOutcome::Deny {
1321                            reason: format!(
1322                                "mask(...) requires string value, got {}",
1323                                value_kind(&current)
1324                            ),
1325                            stage_index: idx,
1326                        },
1327                        taints,
1328                    };
1329                };
1330                let chars: Vec<char> = s.chars().collect();
1331                let keep = (*keep_last).min(chars.len());
1332                let mask_count = chars.len() - keep;
1333                let masked: String = std::iter::repeat('*')
1334                    .take(mask_count)
1335                    .chain(chars.into_iter().skip(mask_count))
1336                    .collect();
1337                current = serde_json::Value::String(masked);
1338                replaced = true;
1339            },
1340            Stage::Redact { condition } => {
1341                let should_redact = match condition {
1342                    None => true,
1343                    Some(expr) => eval_expression(expr, bag),
1344                };
1345                if should_redact {
1346                    current = serde_json::Value::String("[REDACTED]".into());
1347                    replaced = true;
1348                }
1349            },
1350            Stage::Omit => {
1351                return PipelineEvaluation {
1352                    outcome: FieldOutcome::Omit,
1353                    taints,
1354                };
1355            },
1356            Stage::Hash => {
1357                // Simple deterministic digest — DefaultHasher is fine for
1358                // de-identification (not for cryptographic use).
1359                use std::hash::{Hash, Hasher};
1360                let mut h = std::collections::hash_map::DefaultHasher::new();
1361                value_for_hash(&current).hash(&mut h);
1362                current = serde_json::Value::String(format!("hash:{:016x}", h.finish()));
1363                replaced = true;
1364            },
1365
1366            // ----- Effects -----
1367            Stage::Taint { label, scopes } => {
1368                taints.push(TaintEvent {
1369                    label: label.clone(),
1370                    scopes: scopes.clone(),
1371                });
1372            },
1373            Stage::Plugin { name } => {
1374                let invocation = PluginInvocation::Field {
1375                    name: field_name,
1376                    value: &current,
1377                    phase,
1378                };
1379                match plugins.invoke(name, bag, invocation).await {
1380                    Ok(outcome) => {
1381                        // Plugins can emit taints regardless of decision.
1382                        taints.extend(outcome.taints);
1383                        match outcome.decision {
1384                            Decision::Allow => {
1385                                if let Some(new_value) = outcome.modified_value {
1386                                    current = new_value;
1387                                    replaced = true;
1388                                }
1389                            },
1390                            Decision::Deny {
1391                                reason,
1392                                rule_source: _,
1393                            } => {
1394                                return PipelineEvaluation {
1395                                    outcome: FieldOutcome::Deny {
1396                                        reason: reason
1397                                            .unwrap_or_else(|| format!("plugin `{}` denied", name)),
1398                                        stage_index: idx,
1399                                    },
1400                                    taints,
1401                                };
1402                            },
1403                        }
1404                    },
1405                    Err(e) => {
1406                        // Fail-closed: plugin dispatch failure halts the pipeline.
1407                        return PipelineEvaluation {
1408                            outcome: FieldOutcome::Deny {
1409                                reason: format!("plugin `{}` error: {}", name, e),
1410                                stage_index: idx,
1411                            },
1412                            taints,
1413                        };
1414                    },
1415                }
1416            },
1417            Stage::Scan { kind } => {
1418                // Spec mapping (apl-dsl-spec §4): scan stages are taint
1419                // emitters. The actual PII detection / injection signal
1420                // lives in plugin(...) variants of the same scanners; this
1421                // stage just records the label so downstream policies can
1422                // gate on it. `pii.redact` additionally rewrites the value.
1423                let (label, redact): (&str, bool) = match kind {
1424                    ScanKind::PiiDetect => ("PII", false),
1425                    ScanKind::PiiRedact => ("PII", true),
1426                    ScanKind::InjectionScan => ("injection", false),
1427                };
1428                taints.push(TaintEvent {
1429                    label: label.to_string(),
1430                    scopes: vec![TaintScope::Session],
1431                });
1432                if redact {
1433                    current = serde_json::Value::String("[REDACTED]".into());
1434                    replaced = true;
1435                }
1436            },
1437        }
1438    }
1439
1440    let outcome = if replaced {
1441        FieldOutcome::Replace(current)
1442    } else {
1443        FieldOutcome::Pass
1444    };
1445    PipelineEvaluation { outcome, taints }
1446}
1447
1448fn type_check(tc: &TypeCheck, v: &serde_json::Value) -> bool {
1449    match tc {
1450        TypeCheck::Str => v.is_string(),
1451        TypeCheck::Int => v.is_i64(),
1452        TypeCheck::Bool => v.is_boolean(),
1453        TypeCheck::Float => v.is_f64() || v.is_i64(),
1454        TypeCheck::Email => v
1455            .as_str()
1456            .map_or(false, |s| s.contains('@') && s.contains('.')),
1457        TypeCheck::Url => v.as_str().map_or(false, |s| {
1458            s.starts_with("http://") || s.starts_with("https://")
1459        }),
1460        TypeCheck::Uuid => v.as_str().map_or(false, is_uuid_shape),
1461    }
1462}
1463
1464fn is_uuid_shape(s: &str) -> bool {
1465    // 8-4-4-4-12 hex with `-` separators.
1466    let bytes = s.as_bytes();
1467    if bytes.len() != 36 {
1468        return false;
1469    }
1470    for (i, &b) in bytes.iter().enumerate() {
1471        match i {
1472            8 | 13 | 18 | 23 => {
1473                if b != b'-' {
1474                    return false;
1475                }
1476            },
1477            _ => {
1478                if !b.is_ascii_hexdigit() {
1479                    return false;
1480                }
1481            },
1482        }
1483    }
1484    true
1485}
1486
1487fn value_kind(v: &serde_json::Value) -> &'static str {
1488    match v {
1489        serde_json::Value::Null => "null",
1490        serde_json::Value::Bool(_) => "bool",
1491        serde_json::Value::Number(n) if n.is_i64() => "int",
1492        serde_json::Value::Number(_) => "float",
1493        serde_json::Value::String(_) => "string",
1494        serde_json::Value::Array(_) => "array",
1495        serde_json::Value::Object(_) => "object",
1496    }
1497}
1498
1499/// Stable byte representation of a value for hashing — serde_json's
1500/// `to_string` is canonical enough for our use.
1501fn value_for_hash(v: &serde_json::Value) -> String {
1502    serde_json::to_string(v).unwrap_or_default()
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507    use super::*;
1508    use crate::rules::{Condition, Expression, Rule};
1509    use crate::step::{DelegationInvoker, NoopDelegationInvoker};
1510    use std::collections::HashSet;
1511    use std::sync::Arc;
1512
1513    fn rule(condition: Expression, effect: Effect, source: &str) -> Rule {
1514        Rule::single(condition, effect, source)
1515    }
1516
1517    // Wrap stateless test invokers in `Arc<dyn ...>` once per call. The
1518    // public evaluator API takes `&Arc<dyn PluginInvoker>` so internal
1519    // dispatch (notably `Effect::Parallel`) can `Arc::clone` an owned,
1520    // 'static reference into each spawned branch (slice E3.2).
1521    fn null_pipe_plugins() -> Arc<dyn PluginInvoker> {
1522        Arc::new(NullPipelinePlugins)
1523    }
1524    fn null_plugins() -> Arc<dyn PluginInvoker> {
1525        Arc::new(NullPlugins)
1526    }
1527    fn noop_delegations() -> Arc<dyn DelegationInvoker> {
1528        Arc::new(NoopDelegationInvoker)
1529    }
1530    fn noop_elicitations() -> Arc<dyn crate::step::ElicitationInvoker> {
1531        Arc::new(crate::step::NoopElicitationInvoker)
1532    }
1533    fn auto_elicitations() -> Arc<dyn crate::step::ElicitationInvoker> {
1534        Arc::new(crate::step::AutoApprovingElicitor)
1535    }
1536
1537    fn deny(reason: &str) -> Effect {
1538        Effect::Deny {
1539            reason: Some(reason.into()),
1540            code: None,
1541        }
1542    }
1543
1544    /// Build an `Effect::Elicit` with the given scope / on_error for the
1545    /// elicitation-arm tests. Channel/kind are fixed — the arm doesn't
1546    /// branch on them (that's the plugin's job, which the fakes stub).
1547    fn elicit_effect(scope: Option<&str>, on_error: Option<&str>) -> Effect {
1548        Effect::Elicit(crate::step::ElicitStep {
1549            kind: crate::step::ElicitKind::Approval,
1550            plugin_name: "manager-approver".into(),
1551            channel: Some("ciba".into()),
1552            from: "user.manager".into(),
1553            purpose: Some("approve payroll adjustment".into()),
1554            scope: scope.map(|s| s.to_string()),
1555            timeout: None,
1556            config_override: None,
1557            on_error: on_error.map(|s| s.to_string()),
1558            source: "route.test.policy[0]".into(),
1559        })
1560    }
1561
1562    fn cond(c: Condition) -> Expression {
1563        Expression::Condition(c)
1564    }
1565
1566    // ----- Decision-level semantics -----
1567
1568    #[test]
1569    fn empty_rules_allow() {
1570        let mut bag = AttributeBag::new();
1571        assert_eq!(evaluate_rules(&[], &bag), Decision::Allow);
1572    }
1573
1574    #[test]
1575    fn first_deny_halts() {
1576        let mut bag = AttributeBag::new();
1577        bag.set("a", true);
1578        bag.set("b", true);
1579
1580        let rules = vec![
1581            rule(
1582                cond(Condition::IsTrue { key: "a".into() }),
1583                deny("first"),
1584                "r0",
1585            ),
1586            rule(
1587                cond(Condition::IsTrue { key: "b".into() }),
1588                deny("second"),
1589                "r1",
1590            ),
1591        ];
1592
1593        match evaluate_rules(&rules, &bag) {
1594            Decision::Deny {
1595                reason,
1596                rule_source,
1597            } => {
1598                assert_eq!(reason.as_deref(), Some("first"));
1599                assert_eq!(rule_source, "r0");
1600            },
1601            d => panic!("expected Deny, got {:?}", d),
1602        }
1603    }
1604
1605    #[test]
1606    fn allow_does_not_short_circuit() {
1607        // Spec §3: explicit allow continues evaluation. A later deny still fires.
1608        let mut bag = AttributeBag::new();
1609        bag.set("ok", true);
1610        bag.set("bad", true);
1611
1612        let rules = vec![
1613            rule(
1614                cond(Condition::IsTrue { key: "ok".into() }),
1615                Effect::Allow,
1616                "r0_allow",
1617            ),
1618            rule(
1619                cond(Condition::IsTrue { key: "bad".into() }),
1620                deny("later"),
1621                "r1_deny",
1622            ),
1623        ];
1624
1625        match evaluate_rules(&rules, &bag) {
1626            Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r1_deny"),
1627            d => panic!("allow short-circuited; expected later deny, got {:?}", d),
1628        }
1629    }
1630
1631    #[test]
1632    fn unmatched_rules_dont_fire() {
1633        let mut bag = AttributeBag::new(); // "denied" missing → false
1634        let rules = vec![rule(
1635            cond(Condition::IsTrue {
1636                key: "denied".into(),
1637            }),
1638            deny("shouldn't fire"),
1639            "r0",
1640        )];
1641        assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow);
1642    }
1643
1644    // ----- Predicate semantics -----
1645
1646    #[test]
1647    fn missing_key_is_false() {
1648        let mut bag = AttributeBag::new();
1649        assert!(!eval_condition(
1650            &Condition::IsTrue {
1651                key: "missing".into()
1652            },
1653            &bag
1654        ));
1655        assert!(eval_condition(
1656            &Condition::IsFalse {
1657                key: "missing".into()
1658            },
1659            &bag
1660        ));
1661        // Comparison on missing → false (spec §2.6).
1662        assert!(!eval_condition(
1663            &Condition::Comparison {
1664                key: "missing".into(),
1665                op: CompareOp::Eq,
1666                value: 1_i64.into(),
1667            },
1668            &bag,
1669        ));
1670    }
1671
1672    #[test]
1673    fn and_or_not_combinators() {
1674        let mut bag = AttributeBag::new();
1675        bag.set("a", true);
1676        bag.set("b", false);
1677
1678        let a = cond(Condition::IsTrue { key: "a".into() });
1679        let b = cond(Condition::IsTrue { key: "b".into() });
1680
1681        assert!(eval_expression(
1682            &Expression::And(vec![a.clone(), a.clone()]),
1683            &bag
1684        ));
1685        assert!(!eval_expression(
1686            &Expression::And(vec![a.clone(), b.clone()]),
1687            &bag
1688        ));
1689        assert!(eval_expression(
1690            &Expression::Or(vec![a.clone(), b.clone()]),
1691            &bag
1692        ));
1693        assert!(!eval_expression(
1694            &Expression::Or(vec![b.clone(), b.clone()]),
1695            &bag
1696        ));
1697        assert!(eval_expression(&Expression::Not(Box::new(b)), &bag));
1698    }
1699
1700    // ----- Comparison operators -----
1701
1702    #[test]
1703    fn int_comparisons() {
1704        let mut bag = AttributeBag::new();
1705        bag.set("delegation.depth", 3_i64);
1706
1707        let cmp = |op| Condition::Comparison {
1708            key: "delegation.depth".into(),
1709            op,
1710            value: 2_i64.into(),
1711        };
1712        assert!(eval_condition(&cmp(CompareOp::Gt), &bag));
1713        assert!(eval_condition(&cmp(CompareOp::GtEq), &bag));
1714        assert!(!eval_condition(&cmp(CompareOp::Lt), &bag));
1715        assert!(!eval_condition(&cmp(CompareOp::Eq), &bag));
1716        assert!(eval_condition(&cmp(CompareOp::NotEq), &bag));
1717    }
1718
1719    #[test]
1720    fn int_to_float_promotion_in_comparison() {
1721        let mut bag = AttributeBag::new();
1722        bag.set("delegation.depth", 2_i64);
1723        // `delegation.depth > 2.5` — int promotes to float for the compare.
1724        assert!(!eval_condition(
1725            &Condition::Comparison {
1726                key: "delegation.depth".into(),
1727                op: CompareOp::Gt,
1728                value: 2.5_f64.into(),
1729            },
1730            &bag,
1731        ));
1732        assert!(eval_condition(
1733            &Condition::Comparison {
1734                key: "delegation.depth".into(),
1735                op: CompareOp::Lt,
1736                value: 2.5_f64.into(),
1737            },
1738            &bag,
1739        ));
1740    }
1741
1742    #[test]
1743    fn numeric_string_args_coerce_for_order_comparison() {
1744        // LLMs routinely send numeric tool-args as strings, e.g.
1745        // `args.amount = "25000"`. An order comparison must still fire so a
1746        // gate like `when: args.amount > 10000` (and the elicitation
1747        // `scope: args.amount <= 25000`) work. Regression for the demo bug
1748        // where a string amount slipped past the approval threshold.
1749        let mut bag = AttributeBag::new();
1750        bag.set("args.amount", "25000");
1751        let cmp = |op, v: i64| Condition::Comparison {
1752            key: "args.amount".into(),
1753            op,
1754            value: v.into(),
1755        };
1756        assert!(
1757            eval_condition(&cmp(CompareOp::Gt, 10000), &bag),
1758            "\"25000\" > 10000"
1759        );
1760        assert!(
1761            eval_condition(&cmp(CompareOp::LtEq, 25000), &bag),
1762            "\"25000\" <= 25000"
1763        );
1764        assert!(
1765            !eval_condition(&cmp(CompareOp::Gt, 25000), &bag),
1766            "\"25000\" > 25000 is false"
1767        );
1768
1769        // A genuinely non-numeric string still doesn't order-compare.
1770        bag.set("args.amount", "lots");
1771        assert!(
1772            !eval_condition(&cmp(CompareOp::Gt, 10000), &bag),
1773            "\"lots\" > 10000 is false"
1774        );
1775    }
1776
1777    #[test]
1778    fn string_equality_no_ordering() {
1779        let mut bag = AttributeBag::new();
1780        bag.set("subject.id", "alice");
1781
1782        assert!(eval_condition(
1783            &Condition::Comparison {
1784                key: "subject.id".into(),
1785                op: CompareOp::Eq,
1786                value: "alice".into(),
1787            },
1788            &bag,
1789        ));
1790        // Order operators on strings → false (spec §2.3).
1791        assert!(!eval_condition(
1792            &Condition::Comparison {
1793                key: "subject.id".into(),
1794                op: CompareOp::Gt,
1795                value: "alice".into(),
1796            },
1797            &bag,
1798        ));
1799    }
1800
1801    #[test]
1802    fn contains_set_membership() {
1803        let mut bag = AttributeBag::new();
1804        bag.set(
1805            "session.labels",
1806            HashSet::from(["PII".to_string(), "financial".to_string()]),
1807        );
1808
1809        assert!(eval_condition(
1810            &Condition::Comparison {
1811                key: "session.labels".into(),
1812                op: CompareOp::Contains,
1813                value: "PII".into(),
1814            },
1815            &bag,
1816        ));
1817        assert!(!eval_condition(
1818            &Condition::Comparison {
1819                key: "session.labels".into(),
1820                op: CompareOp::Contains,
1821                value: "PHI".into(),
1822            },
1823            &bag,
1824        ));
1825        // Contains on a non-set attribute → false.
1826        bag.set("subject.id", "alice");
1827        assert!(!eval_condition(
1828            &Condition::Comparison {
1829                key: "subject.id".into(),
1830                op: CompareOp::Contains,
1831                value: "alice".into(),
1832            },
1833            &bag,
1834        ));
1835    }
1836
1837    // ----- Realistic end-to-end -----
1838
1839    #[test]
1840    fn hr_compensation_scenario() {
1841        // From the HR demo: alice (hr role + view_ssn perm) requests compensation
1842        // with delegation.depth = 1. Rules:
1843        //   1. require(authenticated)
1844        //   2. require(role.hr | role.finance)
1845        //   3. delegation.depth > 2 & include_ssn: deny
1846        //   4. !perm.view_ssn & include_ssn: deny
1847        let mut bag = AttributeBag::new();
1848        bag.set("authenticated", true);
1849        bag.set("role.hr", true);
1850        bag.set("perm.view_ssn", true);
1851        bag.set("delegation.depth", 1_i64);
1852        bag.set("include_ssn", true);
1853
1854        let rules = vec![
1855            // require(authenticated) → deny if !authenticated
1856            rule(
1857                Expression::Not(Box::new(cond(Condition::IsTrue {
1858                    key: "authenticated".into(),
1859                }))),
1860                deny("not authenticated"),
1861                "r0",
1862            ),
1863            // require(role.hr | role.finance) → deny if neither
1864            // Desugars to: when !(role.hr | role.finance) do deny
1865            //             = when (role.hr is false) AND (role.finance is false), deny
1866            rule(
1867                Expression::And(vec![
1868                    cond(Condition::IsFalse {
1869                        key: "role.hr".into(),
1870                    }),
1871                    cond(Condition::IsFalse {
1872                        key: "role.finance".into(),
1873                    }),
1874                ]),
1875                deny("not in hr/finance"),
1876                "r1",
1877            ),
1878            // delegation.depth > 2 & include_ssn: deny
1879            rule(
1880                Expression::And(vec![
1881                    cond(Condition::Comparison {
1882                        key: "delegation.depth".into(),
1883                        op: CompareOp::Gt,
1884                        value: 2_i64.into(),
1885                    }),
1886                    cond(Condition::IsTrue {
1887                        key: "include_ssn".into(),
1888                    }),
1889                ]),
1890                deny("delegation too deep for SSN"),
1891                "r2",
1892            ),
1893        ];
1894
1895        assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow);
1896
1897        // Now make Alice undelegated-but-deep — should still allow at depth=1.
1898        // Change to depth=3 and the SSN rule fires.
1899        bag.set("delegation.depth", 3_i64);
1900        match evaluate_rules(&rules, &bag) {
1901            Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r2"),
1902            d => panic!("expected r2 deny, got {:?}", d),
1903        }
1904    }
1905
1906    // ===================================================================
1907    // Pipe-chain evaluator tests
1908    // ===================================================================
1909
1910    use crate::pipeline::{Stage, TypeCheck};
1911    use serde_json::json;
1912
1913    fn make_pipeline(stages: Vec<Stage>) -> crate::pipeline::Pipeline {
1914        crate::pipeline::Pipeline { stages }
1915    }
1916
1917    // Helper: a plugin invoker that's never expected to fire (pipelines
1918    // without `plugin(...)` stages). Panics if called. Defined alongside
1919    // the other null fixtures further down in this module.
1920
1921    async fn run_pipeline(
1922        p: &crate::pipeline::Pipeline,
1923        v: &serde_json::Value,
1924        bag: &AttributeBag,
1925    ) -> FieldOutcome {
1926        evaluate_pipeline(
1927            p,
1928            v,
1929            bag,
1930            &null_pipe_plugins(),
1931            "test_field",
1932            crate::step::DispatchPhase::Pre,
1933        )
1934        .await
1935        .outcome
1936    }
1937
1938    /// Pipeline-test null invoker — distinct from the step-test `NullPlugins`
1939    /// so each test can panic with a clearer "wrong fixture" message if it
1940    /// ever does dispatch a plugin call by accident.
1941    struct NullPipelinePlugins;
1942    #[async_trait]
1943    impl PluginInvoker for NullPipelinePlugins {
1944        async fn invoke(
1945            &self,
1946            name: &str,
1947            _bag: &AttributeBag,
1948            _invocation: PluginInvocation<'_>,
1949        ) -> Result<PluginOutcome, PluginError> {
1950            panic!(
1951                "NullPipelinePlugins should not dispatch; got plugin({})",
1952                name
1953            );
1954        }
1955    }
1956
1957    #[tokio::test]
1958    async fn pipeline_empty_is_pass() {
1959        let mut bag = AttributeBag::new();
1960        let p = make_pipeline(vec![]);
1961        assert_eq!(
1962            run_pipeline(&p, &json!("anything"), &bag).await,
1963            FieldOutcome::Pass
1964        );
1965    }
1966
1967    #[tokio::test]
1968    async fn pipeline_type_check_passes_and_denies() {
1969        let mut bag = AttributeBag::new();
1970        let p = make_pipeline(vec![Stage::Type(TypeCheck::Str)]);
1971        assert_eq!(
1972            run_pipeline(&p, &json!("hello"), &bag).await,
1973            FieldOutcome::Pass
1974        );
1975        match run_pipeline(&p, &json!(42), &bag).await {
1976            FieldOutcome::Deny {
1977                reason,
1978                stage_index,
1979            } => {
1980                assert!(reason.contains("expected Str"));
1981                assert_eq!(stage_index, 0);
1982            },
1983            other => panic!("expected Deny, got {:?}", other),
1984        }
1985    }
1986
1987    #[tokio::test]
1988    async fn pipeline_mask_preserves_last_n() {
1989        let mut bag = AttributeBag::new();
1990        let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]);
1991        match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
1992            FieldOutcome::Replace(v) => assert_eq!(v, json!("*******6789")),
1993            other => panic!("expected Replace, got {:?}", other),
1994        }
1995    }
1996
1997    #[tokio::test]
1998    async fn pipeline_mask_handles_short_strings() {
1999        let mut bag = AttributeBag::new();
2000        let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]);
2001        // keep_last >= length → no mask chars; full string preserved.
2002        match run_pipeline(&p, &json!("ab"), &bag).await {
2003            FieldOutcome::Replace(v) => assert_eq!(v, json!("ab")),
2004            other => panic!("expected Replace, got {:?}", other),
2005        }
2006    }
2007
2008    #[tokio::test]
2009    async fn pipeline_unconditional_redact() {
2010        let mut bag = AttributeBag::new();
2011        let p = make_pipeline(vec![Stage::Redact { condition: None }]);
2012        match run_pipeline(&p, &json!("secret"), &bag).await {
2013            FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")),
2014            other => panic!("expected Replace, got {:?}", other),
2015        }
2016    }
2017
2018    #[tokio::test]
2019    async fn pipeline_conditional_redact_fires_when_condition_true() {
2020        // redact(!perm.view_ssn): condition is `!perm.view_ssn`. Missing key
2021        // → IsTrue is false → `!IsTrue` is true → redact fires.
2022        let mut bag = AttributeBag::new();
2023        let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue {
2024            key: "perm.view_ssn".into(),
2025        })));
2026        let p = make_pipeline(vec![Stage::Redact {
2027            condition: Some(cond),
2028        }]);
2029        match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
2030            FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")),
2031            other => panic!("expected Replace (redact fired), got {:?}", other),
2032        }
2033    }
2034
2035    #[tokio::test]
2036    async fn pipeline_conditional_redact_skips_when_condition_false() {
2037        let mut bag = AttributeBag::new();
2038        bag.set("perm.view_ssn", true);
2039        let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue {
2040            key: "perm.view_ssn".into(),
2041        })));
2042        let p = make_pipeline(vec![Stage::Redact {
2043            condition: Some(cond),
2044        }]);
2045        // perm.view_ssn=true → !true=false → redact skipped → Pass.
2046        assert_eq!(
2047            run_pipeline(&p, &json!("123-45-6789"), &bag).await,
2048            FieldOutcome::Pass,
2049        );
2050    }
2051
2052    #[tokio::test]
2053    async fn pipeline_omit_short_circuits() {
2054        let mut bag = AttributeBag::new();
2055        let p = make_pipeline(vec![
2056            Stage::Omit,
2057            // This stage should never run.
2058            Stage::Type(TypeCheck::Int),
2059        ]);
2060        assert_eq!(
2061            run_pipeline(&p, &json!("anything"), &bag).await,
2062            FieldOutcome::Omit
2063        );
2064    }
2065
2066    #[tokio::test]
2067    async fn pipeline_range_validator() {
2068        let mut bag = AttributeBag::new();
2069        let p = make_pipeline(vec![
2070            Stage::Type(TypeCheck::Int),
2071            Stage::Range {
2072                min: Some(0),
2073                max: Some(1_000_000),
2074            },
2075        ]);
2076        assert_eq!(
2077            run_pipeline(&p, &json!(500_000), &bag).await,
2078            FieldOutcome::Pass
2079        );
2080        // Above max → deny.
2081        match run_pipeline(&p, &json!(2_000_000), &bag).await {
2082            FieldOutcome::Deny {
2083                reason,
2084                stage_index,
2085            } => {
2086                assert!(reason.contains("outside"));
2087                assert_eq!(stage_index, 1);
2088            },
2089            other => panic!("expected Deny, got {:?}", other),
2090        }
2091    }
2092
2093    #[tokio::test]
2094    async fn pipeline_length_validator() {
2095        let mut bag = AttributeBag::new();
2096        let p = make_pipeline(vec![Stage::Length {
2097            min: None,
2098            max: Some(5),
2099        }]);
2100        assert_eq!(
2101            run_pipeline(&p, &json!("hi"), &bag).await,
2102            FieldOutcome::Pass
2103        );
2104        assert!(matches!(
2105            run_pipeline(&p, &json!("too long"), &bag).await,
2106            FieldOutcome::Deny { .. },
2107        ));
2108    }
2109
2110    #[tokio::test]
2111    async fn pipeline_enum_validator() {
2112        let mut bag = AttributeBag::new();
2113        let p = make_pipeline(vec![Stage::Enum {
2114            values: vec!["low".into(), "medium".into(), "high".into()],
2115        }]);
2116        assert_eq!(
2117            run_pipeline(&p, &json!("medium"), &bag).await,
2118            FieldOutcome::Pass
2119        );
2120        assert!(matches!(
2121            run_pipeline(&p, &json!("extreme"), &bag).await,
2122            FieldOutcome::Deny { .. },
2123        ));
2124    }
2125
2126    #[tokio::test]
2127    async fn pipeline_uuid_validator() {
2128        let mut bag = AttributeBag::new();
2129        let p = make_pipeline(vec![Stage::Type(TypeCheck::Uuid)]);
2130        assert_eq!(
2131            run_pipeline(&p, &json!("550e8400-e29b-41d4-a716-446655440000"), &bag).await,
2132            FieldOutcome::Pass,
2133        );
2134        assert!(matches!(
2135            run_pipeline(&p, &json!("not-a-uuid"), &bag).await,
2136            FieldOutcome::Deny { .. },
2137        ));
2138    }
2139
2140    #[tokio::test]
2141    async fn pipeline_hash_replaces_value() {
2142        let mut bag = AttributeBag::new();
2143        let p = make_pipeline(vec![Stage::Hash]);
2144        match run_pipeline(&p, &json!("secret"), &bag).await {
2145            FieldOutcome::Replace(v) => {
2146                let s = v.as_str().unwrap();
2147                assert!(s.starts_with("hash:"));
2148                assert_eq!(s.len(), "hash:".len() + 16);
2149            },
2150            other => panic!("expected Replace, got {:?}", other),
2151        }
2152    }
2153
2154    #[tokio::test]
2155    async fn pipeline_validate_named_denies_at_runtime() {
2156        // `validate(name)` is unimplemented in this build. The parser
2157        // rejects it at compile time; this test exercises the runtime
2158        // defense-in-depth path for IR built programmatically. The
2159        // deny message points operators at the working alternatives
2160        // (`regex(...)` / `plugin(...)`).
2161        let mut bag = AttributeBag::new();
2162        let p = make_pipeline(vec![
2163            Stage::Type(TypeCheck::Str),
2164            Stage::Validate {
2165                name: "ssn_format".into(),
2166            },
2167            Stage::Mask { keep_last: 4 },
2168        ]);
2169        match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
2170            FieldOutcome::Deny {
2171                reason,
2172                stage_index,
2173            } => {
2174                assert_eq!(stage_index, 1, "validate stage is at index 1");
2175                assert!(
2176                    reason.contains("not implemented"),
2177                    "deny reason should explain that validate is unimplemented: {reason}",
2178                );
2179                assert!(
2180                    reason.contains("regex") || reason.contains("plugin"),
2181                    "deny reason should point at alternatives: {reason}",
2182                );
2183            },
2184            other => panic!("expected Deny on validate(...) stage, got {:?}", other),
2185        }
2186    }
2187
2188    #[tokio::test]
2189    async fn pipeline_validator_short_circuits_before_transform() {
2190        // If the validator fails, the transform never runs.
2191        let mut bag = AttributeBag::new();
2192        let p = make_pipeline(vec![
2193            Stage::Type(TypeCheck::Int), // will fail on a string
2194            Stage::Mask { keep_last: 4 },
2195        ]);
2196        match run_pipeline(&p, &json!("hello"), &bag).await {
2197            FieldOutcome::Deny { stage_index, .. } => assert_eq!(stage_index, 0),
2198            other => panic!("expected Deny at stage 0, got {:?}", other),
2199        }
2200    }
2201
2202    // ----- Regex stage -----
2203
2204    #[tokio::test]
2205    async fn pipeline_regex_match_passes() {
2206        let mut bag = AttributeBag::new();
2207        let p = make_pipeline(vec![Stage::Regex {
2208            pattern: r"^\d{3}-\d{2}-\d{4}$".into(),
2209        }]);
2210        assert_eq!(
2211            run_pipeline(&p, &json!("123-45-6789"), &bag).await,
2212            FieldOutcome::Pass
2213        );
2214    }
2215
2216    #[tokio::test]
2217    async fn pipeline_regex_no_match_denies() {
2218        let mut bag = AttributeBag::new();
2219        let p = make_pipeline(vec![Stage::Regex {
2220            pattern: r"^\d{3}-\d{2}-\d{4}$".into(),
2221        }]);
2222        match run_pipeline(&p, &json!("not an ssn"), &bag).await {
2223            FieldOutcome::Deny {
2224                reason,
2225                stage_index,
2226            } => {
2227                assert!(reason.contains("did not match"));
2228                assert_eq!(stage_index, 0);
2229            },
2230            other => panic!("expected Deny, got {:?}", other),
2231        }
2232    }
2233
2234    #[tokio::test]
2235    async fn pipeline_regex_invalid_pattern_denies() {
2236        let mut bag = AttributeBag::new();
2237        let p = make_pipeline(vec![Stage::Regex {
2238            pattern: "(unclosed".into(),
2239        }]);
2240        match run_pipeline(&p, &json!("anything"), &bag).await {
2241            FieldOutcome::Deny { reason, .. } => {
2242                assert!(reason.contains("invalid regex"));
2243            },
2244            other => panic!("expected Deny, got {:?}", other),
2245        }
2246    }
2247
2248    #[tokio::test]
2249    async fn pipeline_regex_non_string_denies() {
2250        let mut bag = AttributeBag::new();
2251        let p = make_pipeline(vec![Stage::Regex {
2252            pattern: r"^\d+$".into(),
2253        }]);
2254        match run_pipeline(&p, &json!(42), &bag).await {
2255            FieldOutcome::Deny { reason, .. } => {
2256                assert!(reason.contains("requires string"));
2257            },
2258            other => panic!("expected Deny on non-string regex input, got {:?}", other),
2259        }
2260    }
2261
2262    // ----- Taint and Scan stages -----
2263
2264    #[tokio::test]
2265    async fn pipeline_taint_records_event() {
2266        let mut bag = AttributeBag::new();
2267        let p = make_pipeline(vec![
2268            Stage::Type(TypeCheck::Str),
2269            Stage::Taint {
2270                label: "PII".into(),
2271                scopes: vec![TaintScope::Session],
2272            },
2273            Stage::Mask { keep_last: 4 },
2274        ]);
2275        let result = evaluate_pipeline(
2276            &p,
2277            &json!("123-45-6789"),
2278            &bag,
2279            &null_pipe_plugins(),
2280            "test_field",
2281            crate::step::DispatchPhase::Pre,
2282        )
2283        .await;
2284        assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789")));
2285        assert_eq!(
2286            result.taints,
2287            vec![TaintEvent {
2288                label: "PII".into(),
2289                scopes: vec![TaintScope::Session],
2290            }]
2291        );
2292    }
2293
2294    #[tokio::test]
2295    async fn pipeline_scan_pii_detect_emits_taint() {
2296        let mut bag = AttributeBag::new();
2297        let p = make_pipeline(vec![Stage::Scan {
2298            kind: ScanKind::PiiDetect,
2299        }]);
2300        let result = evaluate_pipeline(
2301            &p,
2302            &json!("some text"),
2303            &bag,
2304            &null_pipe_plugins(),
2305            "test_field",
2306            crate::step::DispatchPhase::Pre,
2307        )
2308        .await;
2309        // PII detect: value unchanged, one taint event emitted.
2310        assert_eq!(result.outcome, FieldOutcome::Pass);
2311        assert_eq!(
2312            result.taints,
2313            vec![TaintEvent {
2314                label: "PII".into(),
2315                scopes: vec![TaintScope::Session],
2316            }]
2317        );
2318    }
2319
2320    #[tokio::test]
2321    async fn pipeline_scan_pii_redact_replaces_and_taints() {
2322        let mut bag = AttributeBag::new();
2323        let p = make_pipeline(vec![Stage::Scan {
2324            kind: ScanKind::PiiRedact,
2325        }]);
2326        let result = evaluate_pipeline(
2327            &p,
2328            &json!("123-45-6789"),
2329            &bag,
2330            &null_pipe_plugins(),
2331            "test_field",
2332            crate::step::DispatchPhase::Pre,
2333        )
2334        .await;
2335        assert_eq!(result.outcome, FieldOutcome::Replace(json!("[REDACTED]")));
2336        assert_eq!(result.taints.len(), 1);
2337        assert_eq!(result.taints[0].label, "PII");
2338    }
2339
2340    #[tokio::test]
2341    async fn pipeline_scan_injection_emits_injection_taint() {
2342        let mut bag = AttributeBag::new();
2343        let p = make_pipeline(vec![Stage::Scan {
2344            kind: ScanKind::InjectionScan,
2345        }]);
2346        let result = evaluate_pipeline(
2347            &p,
2348            &json!("user input"),
2349            &bag,
2350            &null_pipe_plugins(),
2351            "test_field",
2352            crate::step::DispatchPhase::Pre,
2353        )
2354        .await;
2355        assert_eq!(result.outcome, FieldOutcome::Pass);
2356        assert_eq!(result.taints[0].label, "injection");
2357    }
2358
2359    #[tokio::test]
2360    async fn pipeline_deny_does_not_accumulate_later_taints() {
2361        // Pipeline halts at the first failing validator; taints emitted
2362        // before the failure stick, taints after do not.
2363        let mut bag = AttributeBag::new();
2364        let p = make_pipeline(vec![
2365            Stage::Taint {
2366                label: "before".into(),
2367                scopes: vec![TaintScope::Session],
2368            },
2369            Stage::Type(TypeCheck::Int), // fails on string input
2370            Stage::Taint {
2371                label: "after".into(),
2372                scopes: vec![TaintScope::Session],
2373            },
2374        ]);
2375        let result = evaluate_pipeline(
2376            &p,
2377            &json!("hello"),
2378            &bag,
2379            &null_pipe_plugins(),
2380            "test_field",
2381            crate::step::DispatchPhase::Pre,
2382        )
2383        .await;
2384        assert!(matches!(result.outcome, FieldOutcome::Deny { .. }));
2385        assert_eq!(
2386            result.taints,
2387            vec![TaintEvent {
2388                label: "before".into(),
2389                scopes: vec![TaintScope::Session],
2390            }]
2391        );
2392    }
2393
2394    // ----- Plugin stage in pipe chain -----
2395
2396    /// Pipe-context plugin invoker that returns canned outcomes by name.
2397    struct PipePlugin {
2398        outcomes: std::collections::HashMap<String, PluginOutcome>,
2399    }
2400    #[async_trait]
2401    impl PluginInvoker for PipePlugin {
2402        async fn invoke(
2403            &self,
2404            name: &str,
2405            _bag: &AttributeBag,
2406            _invocation: PluginInvocation<'_>,
2407        ) -> Result<PluginOutcome, PluginError> {
2408            self.outcomes
2409                .get(name)
2410                .cloned()
2411                .ok_or_else(|| PluginError::NotFound(name.into()))
2412        }
2413    }
2414
2415    #[tokio::test]
2416    async fn pipeline_plugin_allow_continues() {
2417        let mut bag = AttributeBag::new();
2418        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2419            outcomes: std::collections::HashMap::from([(
2420                "noop".to_string(),
2421                PluginOutcome::allow(),
2422            )]),
2423        });
2424        let p = make_pipeline(vec![
2425            Stage::Type(TypeCheck::Str),
2426            Stage::Plugin {
2427                name: "noop".into(),
2428            },
2429            Stage::Mask { keep_last: 4 },
2430        ]);
2431        let result = evaluate_pipeline(
2432            &p,
2433            &json!("123-45-6789"),
2434            &bag,
2435            &plugins,
2436            "compensation",
2437            crate::step::DispatchPhase::Pre,
2438        )
2439        .await;
2440        assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789")));
2441        assert!(result.taints.is_empty());
2442    }
2443
2444    #[tokio::test]
2445    async fn pipeline_plugin_can_replace_value() {
2446        let mut bag = AttributeBag::new();
2447        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2448            outcomes: std::collections::HashMap::from([(
2449                "scrubber".to_string(),
2450                PluginOutcome {
2451                    decision: Decision::Allow,
2452                    taints: vec![TaintEvent {
2453                        label: "PII".to_string(),
2454                        scopes: vec![TaintScope::Session],
2455                    }],
2456                    modified_value: Some(json!("***scrubbed***")),
2457                },
2458            )]),
2459        });
2460        let p = make_pipeline(vec![Stage::Plugin {
2461            name: "scrubber".into(),
2462        }]);
2463        let result = evaluate_pipeline(
2464            &p,
2465            &json!("sensitive data"),
2466            &bag,
2467            &plugins,
2468            "notes",
2469            crate::step::DispatchPhase::Pre,
2470        )
2471        .await;
2472        assert_eq!(
2473            result.outcome,
2474            FieldOutcome::Replace(json!("***scrubbed***"))
2475        );
2476        assert_eq!(
2477            result.taints,
2478            vec![TaintEvent {
2479                label: "PII".into(),
2480                scopes: vec![TaintScope::Session],
2481            }]
2482        );
2483    }
2484
2485    #[tokio::test]
2486    async fn pipeline_plugin_deny_halts() {
2487        let mut bag = AttributeBag::new();
2488        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2489            outcomes: std::collections::HashMap::from([(
2490                "guard".to_string(),
2491                PluginOutcome {
2492                    decision: Decision::Deny {
2493                        reason: Some("policy violation".into()),
2494                        rule_source: "guard".into(),
2495                    },
2496                    taints: vec![],
2497                    modified_value: None,
2498                },
2499            )]),
2500        });
2501        let p = make_pipeline(vec![
2502            Stage::Plugin {
2503                name: "guard".into(),
2504            },
2505            // Should never run.
2506            Stage::Mask { keep_last: 4 },
2507        ]);
2508        let result = evaluate_pipeline(
2509            &p,
2510            &json!("data"),
2511            &bag,
2512            &plugins,
2513            "payload",
2514            crate::step::DispatchPhase::Pre,
2515        )
2516        .await;
2517        match result.outcome {
2518            FieldOutcome::Deny {
2519                reason,
2520                stage_index,
2521            } => {
2522                assert_eq!(reason, "policy violation");
2523                assert_eq!(stage_index, 0);
2524            },
2525            other => panic!("expected Deny, got {:?}", other),
2526        }
2527    }
2528
2529    #[tokio::test]
2530    async fn pipeline_plugin_missing_fails_closed() {
2531        let mut bag = AttributeBag::new();
2532        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2533            outcomes: Default::default(),
2534        });
2535        let p = make_pipeline(vec![Stage::Plugin {
2536            name: "missing".into(),
2537        }]);
2538        let result = evaluate_pipeline(
2539            &p,
2540            &json!("data"),
2541            &bag,
2542            &plugins,
2543            "payload",
2544            crate::step::DispatchPhase::Pre,
2545        )
2546        .await;
2547        match result.outcome {
2548            FieldOutcome::Deny { reason, .. } => assert!(reason.contains("missing")),
2549            other => panic!("expected Deny on missing plugin, got {:?}", other),
2550        }
2551    }
2552
2553    // ===================================================================
2554    // 5c additions: Exists, InSet, Always
2555    // ===================================================================
2556
2557    #[test]
2558    fn exists_distinguishes_missing_from_falsy() {
2559        let mut bag = AttributeBag::new();
2560        bag.set("args.flag", false);
2561        // Key is present with a falsy value — IsTrue says false, Exists says true.
2562        assert!(!eval_condition(
2563            &Condition::IsTrue {
2564                key: "args.flag".into()
2565            },
2566            &bag
2567        ));
2568        assert!(eval_condition(
2569            &Condition::Exists {
2570                key: "args.flag".into()
2571            },
2572            &bag
2573        ));
2574        // Missing key — Exists is false.
2575        assert!(!eval_condition(
2576            &Condition::Exists {
2577                key: "args.nonexistent".into()
2578            },
2579            &bag
2580        ));
2581    }
2582
2583    #[test]
2584    fn in_set_member_and_non_member() {
2585        let mut bag = AttributeBag::new();
2586        bag.set("subject.type", "user");
2587        bag.set(
2588            "allowed_types",
2589            std::collections::HashSet::from(["user".to_string(), "service".to_string()]),
2590        );
2591
2592        assert!(eval_condition(
2593            &Condition::InSet {
2594                value_key: "subject.type".into(),
2595                set_key: "allowed_types".into(),
2596                negate: false,
2597            },
2598            &bag
2599        ));
2600
2601        bag.set("subject.type", "agent");
2602        assert!(!eval_condition(
2603            &Condition::InSet {
2604                value_key: "subject.type".into(),
2605                set_key: "allowed_types".into(),
2606                negate: false,
2607            },
2608            &bag
2609        ));
2610    }
2611
2612    #[test]
2613    fn in_set_negate() {
2614        let mut bag = AttributeBag::new();
2615        bag.set("subject.type", "agent");
2616        bag.set(
2617            "blocked_types",
2618            std::collections::HashSet::from(["service".to_string()]),
2619        );
2620
2621        // agent is not in blocked_types → not in → true
2622        assert!(eval_condition(
2623            &Condition::InSet {
2624                value_key: "subject.type".into(),
2625                set_key: "blocked_types".into(),
2626                negate: true,
2627            },
2628            &bag
2629        ));
2630    }
2631
2632    #[test]
2633    fn in_set_missing_keys_resolve_to_false() {
2634        let mut bag = AttributeBag::new();
2635        // Both missing → in = false → not in = true (spec §2.6 missing→false
2636        // applies to the underlying `in` lookup; negate flips it).
2637        assert!(!eval_condition(
2638            &Condition::InSet {
2639                value_key: "x".into(),
2640                set_key: "y".into(),
2641                negate: false,
2642            },
2643            &bag
2644        ));
2645        assert!(eval_condition(
2646            &Condition::InSet {
2647                value_key: "x".into(),
2648                set_key: "y".into(),
2649                negate: true,
2650            },
2651            &bag
2652        ));
2653    }
2654
2655    #[test]
2656    fn always_evaluates_true() {
2657        let mut bag = AttributeBag::new();
2658        assert!(eval_expression(&Expression::Always, &bag));
2659    }
2660
2661    #[test]
2662    fn always_rule_unconditional_deny() {
2663        let mut bag = AttributeBag::new();
2664        let r = Rule {
2665            condition: Expression::Always,
2666            effects: vec![Effect::Deny {
2667                reason: Some("unconditional".into()),
2668                code: None,
2669            }],
2670            source: "test".into(),
2671        };
2672        match evaluate_rules(&[r], &bag) {
2673            Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("unconditional")),
2674            d => panic!("expected Deny, got {:?}", d),
2675        }
2676    }
2677
2678    // ===================================================================
2679    // 5c-v/vi: async step evaluator with mock resolvers
2680    // ===================================================================
2681
2682    use crate::step::{
2683        PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, PluginInvocation,
2684        PluginInvoker, PluginOutcome,
2685    };
2686    use async_trait::async_trait;
2687
2688    /// PDP resolver that returns the decision baked into it. Doesn't
2689    /// inspect call.args — tests assert on call.dialect / on the decision
2690    /// flow, not on Cedar/OPA-specific arg parsing.
2691    struct FakePdp {
2692        decision: Decision,
2693    }
2694    #[async_trait]
2695    impl PdpResolver for FakePdp {
2696        fn dialect(&self) -> PdpDialect {
2697            PdpDialect::Cedar
2698        }
2699        async fn evaluate(
2700            &self,
2701            _call: &PdpCall,
2702            _bag: &AttributeBag,
2703        ) -> Result<PdpDecision, PdpError> {
2704            Ok(PdpDecision {
2705                decision: self.decision.clone(),
2706                diagnostics: vec![],
2707            })
2708        }
2709    }
2710
2711    /// PDP resolver that returns an error — exercises fail-closed path.
2712    struct ErroringPdp;
2713    #[async_trait]
2714    impl PdpResolver for ErroringPdp {
2715        fn dialect(&self) -> PdpDialect {
2716            PdpDialect::Cedar
2717        }
2718        async fn evaluate(
2719            &self,
2720            _call: &PdpCall,
2721            _bag: &AttributeBag,
2722        ) -> Result<PdpDecision, PdpError> {
2723            Err(PdpError::Dispatch("simulated PDP outage".into()))
2724        }
2725    }
2726
2727    /// Elicitation invoker that dispatches fine but resolves to a human
2728    /// *denial* — exercises the "genuine no halts unconditionally" path.
2729    struct DenyingElicitor;
2730    #[async_trait]
2731    impl crate::step::ElicitationInvoker for DenyingElicitor {
2732        async fn dispatch(
2733            &self,
2734            _step: &crate::step::ElicitStep,
2735            resolved_from: &str,
2736        ) -> Result<crate::step::ElicitationDispatch, crate::step::ElicitationError> {
2737            Ok(crate::step::ElicitationDispatch {
2738                id: "deny-1".into(),
2739                approver: Some(resolved_from.to_string()),
2740                intent_id: None,
2741                expires_at: None,
2742            })
2743        }
2744        async fn check(
2745            &self,
2746            _step: &crate::step::ElicitStep,
2747            _id: &str,
2748        ) -> Result<crate::step::ElicitationStatus, crate::step::ElicitationError> {
2749            Ok(crate::step::ElicitationStatus::Resolved {
2750                outcome: crate::step::ElicitationOutcome::Denied,
2751            })
2752        }
2753        async fn validate(
2754            &self,
2755            _step: &crate::step::ElicitStep,
2756            _id: &str,
2757        ) -> Result<crate::step::ElicitationValidation, crate::step::ElicitationError> {
2758            // Never reached on a denial — the arm halts before validate.
2759            unreachable!("validate must not run on a denied elicitation")
2760        }
2761    }
2762
2763    /// Elicitation invoker that dispatches fine but never resolves —
2764    /// every `check` returns Pending. Exercises the suspend path.
2765    struct StillPendingElicitor;
2766    #[async_trait]
2767    impl crate::step::ElicitationInvoker for StillPendingElicitor {
2768        async fn dispatch(
2769            &self,
2770            _step: &crate::step::ElicitStep,
2771            resolved_from: &str,
2772        ) -> Result<crate::step::ElicitationDispatch, crate::step::ElicitationError> {
2773            Ok(crate::step::ElicitationDispatch {
2774                id: "pending-1".into(),
2775                approver: Some(resolved_from.to_string()),
2776                intent_id: Some("intent-xyz".into()),
2777                expires_at: Some("2026-12-31T00:00:00Z".into()),
2778            })
2779        }
2780        async fn check(
2781            &self,
2782            _step: &crate::step::ElicitStep,
2783            _id: &str,
2784        ) -> Result<crate::step::ElicitationStatus, crate::step::ElicitationError> {
2785            Ok(crate::step::ElicitationStatus::Pending)
2786        }
2787        async fn validate(
2788            &self,
2789            _step: &crate::step::ElicitStep,
2790            _id: &str,
2791        ) -> Result<crate::step::ElicitationValidation, crate::step::ElicitationError> {
2792            unreachable!("validate must not run while pending")
2793        }
2794    }
2795
2796    /// Plugin invoker keyed by name → outcome.
2797    struct FakePlugin {
2798        decisions: std::collections::HashMap<String, Decision>,
2799    }
2800    #[async_trait]
2801    impl PluginInvoker for FakePlugin {
2802        async fn invoke(
2803            &self,
2804            name: &str,
2805            _bag: &AttributeBag,
2806            _invocation: PluginInvocation<'_>,
2807        ) -> Result<PluginOutcome, PluginError> {
2808            match self.decisions.get(name) {
2809                Some(d) => Ok(PluginOutcome {
2810                    decision: d.clone(),
2811                    taints: vec![],
2812                    modified_value: None,
2813                }),
2814                None => Err(PluginError::NotFound(name.into())),
2815            }
2816        }
2817    }
2818
2819    /// Null invoker — fails any plugin call (for PDP-only tests).
2820    struct NullPlugins;
2821    #[async_trait]
2822    impl PluginInvoker for NullPlugins {
2823        async fn invoke(
2824            &self,
2825            name: &str,
2826            _bag: &AttributeBag,
2827            _invocation: PluginInvocation<'_>,
2828        ) -> Result<PluginOutcome, PluginError> {
2829            Err(PluginError::NotFound(name.into()))
2830        }
2831    }
2832
2833    fn pdp_step(decision_diagnostic_label: &str) -> Effect {
2834        Effect::Pdp {
2835            call: PdpCall {
2836                dialect: PdpDialect::Cedar,
2837                args: serde_yaml::Value::String(decision_diagnostic_label.into()),
2838            },
2839            on_deny: vec![],
2840            on_allow: vec![],
2841        }
2842    }
2843
2844    #[tokio::test]
2845    async fn steps_rule_only_path() {
2846        let mut bag = AttributeBag::new();
2847        let steps = vec![Effect::When {
2848            condition: Expression::Always,
2849            body: vec![Effect::Allow],
2850            source: "test".into(),
2851        }];
2852        let r = evaluate_effects(
2853            &steps,
2854            &mut bag,
2855            &(Arc::new(FakePdp {
2856                decision: Decision::Allow,
2857            }) as Arc<dyn PdpResolver>),
2858            &null_plugins(),
2859            &noop_delegations(),
2860            &noop_elicitations(),
2861            crate::step::DispatchPhase::Pre,
2862            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2863        )
2864        .await;
2865        assert_eq!(r.decision, Decision::Allow);
2866    }
2867
2868    #[tokio::test]
2869    async fn pdp_allow_continues() {
2870        let mut bag = AttributeBag::new();
2871        let steps = vec![pdp_step("dummy")];
2872        let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2873            decision: Decision::Allow,
2874        });
2875        assert_eq!(
2876            evaluate_effects(
2877                &steps,
2878                &mut bag,
2879                &pdp,
2880                &null_plugins(),
2881                &noop_delegations(),
2882                &noop_elicitations(),
2883                crate::step::DispatchPhase::Pre,
2884                &mut crate::route::RoutePayload::new(serde_json::Value::Null)
2885            )
2886            .await
2887            .decision,
2888            Decision::Allow,
2889        );
2890    }
2891
2892    #[tokio::test]
2893    async fn pdp_deny_returns_deny() {
2894        let mut bag = AttributeBag::new();
2895        let steps = vec![pdp_step("dummy")];
2896        let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2897            decision: Decision::Deny {
2898                reason: Some("forbidden".into()),
2899                rule_source: "pdp".into(),
2900            },
2901        });
2902        match evaluate_effects(
2903            &steps,
2904            &mut bag,
2905            &pdp,
2906            &null_plugins(),
2907            &noop_delegations(),
2908            &noop_elicitations(),
2909            crate::step::DispatchPhase::Pre,
2910            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2911        )
2912        .await
2913        .decision
2914        {
2915            Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("forbidden")),
2916            d => panic!("expected Deny, got {:?}", d),
2917        }
2918    }
2919
2920    #[tokio::test]
2921    async fn pdp_on_deny_reaction_can_override_reason() {
2922        // PDP denies, on_deny reaction includes a more specific deny rule that
2923        // fires before the PDP's deny is returned.
2924        let mut bag = AttributeBag::new();
2925        let steps = vec![Effect::Pdp {
2926            call: PdpCall {
2927                dialect: PdpDialect::Cedar,
2928                args: serde_yaml::Value::Null,
2929            },
2930            on_deny: vec![Effect::When {
2931                condition: Expression::Always,
2932                body: vec![Effect::Deny {
2933                    reason: Some("reaction took over".into()),
2934                    code: None,
2935                }],
2936                source: "on_deny[0]".into(),
2937            }],
2938            on_allow: vec![],
2939        }];
2940        let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2941            decision: Decision::Deny {
2942                reason: Some("pdp original".into()),
2943                rule_source: "p".into(),
2944            },
2945        });
2946        match evaluate_effects(
2947            &steps,
2948            &mut bag,
2949            &pdp,
2950            &null_plugins(),
2951            &noop_delegations(),
2952            &noop_elicitations(),
2953            crate::step::DispatchPhase::Pre,
2954            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2955        )
2956        .await
2957        .decision
2958        {
2959            Decision::Deny {
2960                reason,
2961                rule_source,
2962            } => {
2963                assert_eq!(reason.as_deref(), Some("reaction took over"));
2964                assert_eq!(rule_source, "on_deny[0]");
2965            },
2966            d => panic!("expected Deny, got {:?}", d),
2967        }
2968    }
2969
2970    #[tokio::test]
2971    async fn pdp_on_allow_can_deny() {
2972        // PDP allows, but an on_allow reaction can still deny (e.g., a
2973        // taint check that fails). Outcome: deny.
2974        let mut bag = AttributeBag::new();
2975        let steps = vec![Effect::Pdp {
2976            call: PdpCall {
2977                dialect: PdpDialect::Cedar,
2978                args: serde_yaml::Value::Null,
2979            },
2980            on_deny: vec![],
2981            on_allow: vec![Effect::When {
2982                condition: Expression::Always,
2983                body: vec![Effect::Deny {
2984                    reason: Some("reaction veto".into()),
2985                    code: None,
2986                }],
2987                source: "on_allow[0]".into(),
2988            }],
2989        }];
2990        let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2991            decision: Decision::Allow,
2992        });
2993        match evaluate_effects(
2994            &steps,
2995            &mut bag,
2996            &pdp,
2997            &null_plugins(),
2998            &noop_delegations(),
2999            &noop_elicitations(),
3000            crate::step::DispatchPhase::Pre,
3001            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3002        )
3003        .await
3004        .decision
3005        {
3006            Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("reaction veto")),
3007            d => panic!("expected Deny, got {:?}", d),
3008        }
3009    }
3010
3011    #[tokio::test]
3012    async fn pdp_error_is_fail_closed() {
3013        let mut bag = AttributeBag::new();
3014        let steps = vec![pdp_step("dummy")];
3015        match evaluate_effects(
3016            &steps,
3017            &mut bag,
3018            &(Arc::new(ErroringPdp) as Arc<dyn PdpResolver>),
3019            &null_plugins(),
3020            &noop_delegations(),
3021            &noop_elicitations(),
3022            crate::step::DispatchPhase::Pre,
3023            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3024        )
3025        .await
3026        .decision
3027        {
3028            Decision::Deny { reason, .. } => {
3029                assert!(reason.unwrap().contains("PDP error"));
3030            },
3031            d => panic!("expected Deny on PDP error, got {:?}", d),
3032        }
3033    }
3034
3035    #[tokio::test]
3036    async fn plugin_allow_continues_deny_halts() {
3037        let mut bag = AttributeBag::new();
3038        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(FakePlugin {
3039            decisions: std::collections::HashMap::from([
3040                ("ok_plugin".to_string(), Decision::Allow),
3041                (
3042                    "blocking_plugin".to_string(),
3043                    Decision::Deny {
3044                        reason: Some("rate limit hit".into()),
3045                        rule_source: "plugin".into(),
3046                    },
3047                ),
3048            ]),
3049        });
3050
3051        let allow_only = vec![Effect::Plugin {
3052            name: "ok_plugin".into(),
3053        }];
3054        assert_eq!(
3055            evaluate_effects(
3056                &allow_only,
3057                &mut bag,
3058                &(Arc::new(FakePdp {
3059                    decision: Decision::Allow
3060                }) as Arc<dyn PdpResolver>),
3061                &plugins,
3062                &noop_delegations(),
3063                &noop_elicitations(),
3064                crate::step::DispatchPhase::Pre,
3065                &mut crate::route::RoutePayload::new(serde_json::Value::Null)
3066            )
3067            .await
3068            .decision,
3069            Decision::Allow,
3070        );
3071
3072        let with_deny = vec![
3073            Effect::Plugin {
3074                name: "ok_plugin".into(),
3075            },
3076            Effect::Plugin {
3077                name: "blocking_plugin".into(),
3078            },
3079        ];
3080        match evaluate_effects(
3081            &with_deny,
3082            &mut bag,
3083            &(Arc::new(FakePdp {
3084                decision: Decision::Allow,
3085            }) as Arc<dyn PdpResolver>),
3086            &plugins,
3087            &noop_delegations(),
3088            &noop_elicitations(),
3089            crate::step::DispatchPhase::Pre,
3090            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3091        )
3092        .await
3093        .decision
3094        {
3095            Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("rate limit hit")),
3096            d => panic!("expected Deny from blocking_plugin, got {:?}", d),
3097        }
3098    }
3099
3100    #[tokio::test]
3101    async fn plugin_error_is_fail_closed() {
3102        let mut bag = AttributeBag::new();
3103        let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(FakePlugin {
3104            decisions: Default::default(),
3105        });
3106        let steps = vec![Effect::Plugin {
3107            name: "missing".into(),
3108        }];
3109        match evaluate_effects(
3110            &steps,
3111            &mut bag,
3112            &(Arc::new(FakePdp {
3113                decision: Decision::Allow,
3114            }) as Arc<dyn PdpResolver>),
3115            &plugins,
3116            &noop_delegations(),
3117            &noop_elicitations(),
3118            crate::step::DispatchPhase::Pre,
3119            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3120        )
3121        .await
3122        .decision
3123        {
3124            Decision::Deny {
3125                reason,
3126                rule_source,
3127            } => {
3128                assert!(reason.unwrap().contains("missing"));
3129                assert!(rule_source.contains("missing"));
3130            },
3131            d => panic!("expected Deny, got {:?}", d),
3132        }
3133    }
3134
3135    #[tokio::test]
3136    async fn taint_step_always_continues_and_accumulates() {
3137        let mut bag = AttributeBag::new();
3138        let steps = vec![
3139            Effect::Taint {
3140                label: "PII".into(),
3141                scopes: vec![crate::pipeline::TaintScope::Session],
3142            },
3143            // A later rule should still fire — taint doesn't short-circuit.
3144            Effect::When {
3145                condition: Expression::Always,
3146                body: vec![Effect::Deny {
3147                    reason: Some("after taint".into()),
3148                    code: None,
3149                }],
3150                source: "p[1]".into(),
3151            },
3152        ];
3153        let eval = evaluate_effects(
3154            &steps,
3155            &mut bag,
3156            &(Arc::new(FakePdp {
3157                decision: Decision::Allow,
3158            }) as Arc<dyn PdpResolver>),
3159            &null_plugins(),
3160            &noop_delegations(),
3161            &noop_elicitations(),
3162            crate::step::DispatchPhase::Pre,
3163            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3164        )
3165        .await;
3166        match eval.decision {
3167            Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("after taint")),
3168            d => panic!("expected Deny from rule after Taint, got {:?}", d),
3169        }
3170        // Step::Taint should have been accumulated into the phase's taints
3171        // before the deny landed — audit needs to see what tainted before
3172        // the policy halted.
3173        assert_eq!(eval.taints.len(), 1);
3174        assert_eq!(eval.taints[0].label, "PII");
3175        assert_eq!(
3176            eval.taints[0].scopes,
3177            vec![crate::pipeline::TaintScope::Session]
3178        );
3179    }
3180
3181    // ----- E2: FieldOp end-to-end through evaluate_steps -----
3182
3183    #[tokio::test]
3184    async fn field_op_in_do_redacts_args_during_pre_phase() {
3185        // Sketches the demo case: when condition holds, redact args.ssn
3186        // — verifies the dispatcher walks effects, lifts the FieldOp
3187        // out, and rewrites the payload.
3188        let mut bag = AttributeBag::new();
3189        // Predicate is the rule's `when:`; here we make it always true.
3190        let stages = vec![Stage::Redact { condition: None }];
3191        let rule = Rule {
3192            condition: Expression::Always,
3193            effects: vec![Effect::FieldOp {
3194                path: "args.ssn".into(),
3195                stages,
3196            }],
3197            source: "demo.policy[0]".into(),
3198        };
3199        let steps = vec![Effect::from(rule)];
3200        let mut payload = crate::route::RoutePayload::new(json!({
3201            "ssn": "123-45-6789",
3202            "name": "Jane",
3203        }));
3204
3205        let eval = evaluate_effects(
3206            &steps,
3207            &mut bag,
3208            &(Arc::new(FakePdp {
3209                decision: Decision::Allow,
3210            }) as Arc<dyn PdpResolver>),
3211            &null_plugins(),
3212            &noop_delegations(),
3213            &noop_elicitations(),
3214            crate::step::DispatchPhase::Pre,
3215            &mut payload,
3216        )
3217        .await;
3218
3219        assert_eq!(eval.decision, Decision::Allow);
3220        assert!(eval.args_modified, "FieldOp should flag args_modified");
3221        // The ssn field should now read `[REDACTED]` (the stock value
3222        // the Stage::Redact applier writes when no when-clause is set).
3223        assert_eq!(
3224            payload.args.get("ssn").and_then(|v| v.as_str()),
3225            Some("[REDACTED]")
3226        );
3227        // Other fields untouched.
3228        assert_eq!(
3229            payload.args.get("name").and_then(|v| v.as_str()),
3230            Some("Jane")
3231        );
3232    }
3233
3234    #[tokio::test]
3235    async fn field_op_targeting_result_in_pre_phase_is_skipped() {
3236        // A `result.X | ...` op encountered during the Pre phase is a
3237        // no-op — the result hasn't been produced yet. Same rule body
3238        // can be reused across phases without branching.
3239        let mut bag = AttributeBag::new();
3240        let stages = vec![Stage::Redact { condition: None }];
3241        let rule = Rule {
3242            condition: Expression::Always,
3243            effects: vec![Effect::FieldOp {
3244                path: "result.ssn".into(),
3245                stages,
3246            }],
3247            source: "demo.policy[0]".into(),
3248        };
3249        let mut payload = crate::route::RoutePayload::new(json!({}));
3250        let eval = evaluate_effects(
3251            &vec![Effect::from(rule)],
3252            &mut bag,
3253            &(Arc::new(FakePdp {
3254                decision: Decision::Allow,
3255            }) as Arc<dyn PdpResolver>),
3256            &null_plugins(),
3257            &noop_delegations(),
3258            &noop_elicitations(),
3259            crate::step::DispatchPhase::Pre,
3260            &mut payload,
3261        )
3262        .await;
3263        assert_eq!(eval.decision, Decision::Allow);
3264        assert!(!eval.args_modified);
3265        assert!(!eval.result_modified);
3266    }
3267
3268    #[tokio::test]
3269    async fn field_op_with_invalid_path_denies() {
3270        // Path missing the `args.` / `result.` prefix is an author bug
3271        // — fail closed with a clear violation rather than silently
3272        // doing nothing.
3273        let mut bag = AttributeBag::new();
3274        let stages = vec![Stage::Redact { condition: None }];
3275        let rule = Rule {
3276            condition: Expression::Always,
3277            effects: vec![Effect::FieldOp {
3278                path: "ssn".into(), // missing prefix
3279                stages,
3280            }],
3281            source: "demo.policy[0]".into(),
3282        };
3283        let mut payload = crate::route::RoutePayload::new(json!({"ssn": "x"}));
3284        let eval = evaluate_effects(
3285            &vec![Effect::from(rule)],
3286            &mut bag,
3287            &(Arc::new(FakePdp {
3288                decision: Decision::Allow,
3289            }) as Arc<dyn PdpResolver>),
3290            &null_plugins(),
3291            &noop_delegations(),
3292            &noop_elicitations(),
3293            crate::step::DispatchPhase::Pre,
3294            &mut payload,
3295        )
3296        .await;
3297        match eval.decision {
3298            Decision::Deny {
3299                reason,
3300                rule_source,
3301            } => {
3302                assert!(reason.unwrap_or_default().contains("must start with"));
3303                assert_eq!(rule_source, "demo.policy[0]");
3304            },
3305            other => panic!("expected Deny, got {:?}", other),
3306        }
3307    }
3308
3309    // ----- E3: Sequential / Parallel orchestration -----
3310
3311    #[tokio::test]
3312    async fn sequential_runs_effects_in_order_until_deny() {
3313        // A Sequential block runs each effect in order. Allow-only
3314        // effects pass through; the first Deny halts the rest of the
3315        // sequential body AND the parent step.
3316        let mut bag = AttributeBag::new();
3317        let mut payload = crate::route::RoutePayload::new(json!({}));
3318
3319        let rule = Rule {
3320            condition: Expression::Always,
3321            effects: vec![Effect::Sequential(vec![
3322                Effect::Allow,
3323                Effect::Deny {
3324                    reason: Some("blocked by sequential".into()),
3325                    code: Some("seq.test".into()),
3326                },
3327                Effect::Allow, // unreachable
3328            ])],
3329            source: "test.policy[0]".into(),
3330        };
3331
3332        let eval = evaluate_effects(
3333            &vec![Effect::from(rule)],
3334            &mut bag,
3335            &(Arc::new(FakePdp {
3336                decision: Decision::Allow,
3337            }) as Arc<dyn PdpResolver>),
3338            &null_plugins(),
3339            &noop_delegations(),
3340            &noop_elicitations(),
3341            crate::step::DispatchPhase::Pre,
3342            &mut payload,
3343        )
3344        .await;
3345
3346        match eval.decision {
3347            Decision::Deny {
3348                reason,
3349                rule_source,
3350            } => {
3351                assert_eq!(reason.as_deref(), Some("blocked by sequential"));
3352                // The `code` override on the effect won — `seq.test`
3353                // rather than the rule's `test.policy[0]` source.
3354                assert_eq!(rule_source, "seq.test");
3355            },
3356            other => panic!("expected Deny, got {:?}", other),
3357        }
3358    }
3359
3360    #[tokio::test]
3361    async fn parallel_allows_when_no_branch_denies() {
3362        // Both branches are no-op Allow → overall Continue → route Allow.
3363        let mut bag = AttributeBag::new();
3364        let mut payload = crate::route::RoutePayload::new(json!({}));
3365
3366        let rule = Rule {
3367            condition: Expression::Always,
3368            effects: vec![Effect::Parallel(vec![
3369                Effect::Allow,
3370                Effect::Taint {
3371                    label: "audit_branch".into(),
3372                    scopes: vec![crate::pipeline::TaintScope::Session],
3373                },
3374            ])],
3375            source: "test.policy[0]".into(),
3376        };
3377
3378        let eval = evaluate_effects(
3379            &vec![Effect::from(rule)],
3380            &mut bag,
3381            &(Arc::new(FakePdp {
3382                decision: Decision::Allow,
3383            }) as Arc<dyn PdpResolver>),
3384            &null_plugins(),
3385            &noop_delegations(),
3386            &noop_elicitations(),
3387            crate::step::DispatchPhase::Pre,
3388            &mut payload,
3389        )
3390        .await;
3391
3392        assert_eq!(eval.decision, Decision::Allow);
3393        // Taints from parallel branches accumulate into the outer.
3394        assert_eq!(eval.taints.len(), 1);
3395        assert_eq!(eval.taints[0].label, "audit_branch");
3396    }
3397
3398    #[tokio::test]
3399    async fn parallel_denies_when_any_branch_denies() {
3400        // One Allow, one Deny — overall Deny.
3401        let mut bag = AttributeBag::new();
3402        let mut payload = crate::route::RoutePayload::new(json!({}));
3403
3404        let rule = Rule {
3405            condition: Expression::Always,
3406            effects: vec![Effect::Parallel(vec![
3407                Effect::Allow,
3408                Effect::Deny {
3409                    reason: Some("branch 1 denied".into()),
3410                    code: None,
3411                },
3412            ])],
3413            source: "test.policy[0]".into(),
3414        };
3415
3416        let eval = evaluate_effects(
3417            &vec![Effect::from(rule)],
3418            &mut bag,
3419            &(Arc::new(FakePdp {
3420                decision: Decision::Allow,
3421            }) as Arc<dyn PdpResolver>),
3422            &null_plugins(),
3423            &noop_delegations(),
3424            &noop_elicitations(),
3425            crate::step::DispatchPhase::Pre,
3426            &mut payload,
3427        )
3428        .await;
3429
3430        match eval.decision {
3431            Decision::Deny { reason, .. } => {
3432                assert_eq!(reason.as_deref(), Some("branch 1 denied"));
3433            },
3434            other => panic!("expected Deny, got {:?}", other),
3435        }
3436    }
3437
3438    #[tokio::test]
3439    async fn parallel_picks_first_index_halt_not_first_to_complete() {
3440        // When two branches both deny, the one with the lower index
3441        // in the effects list wins — not the one that physically
3442        // finishes first.
3443        let mut bag = AttributeBag::new();
3444        let mut payload = crate::route::RoutePayload::new(json!({}));
3445
3446        let rule = Rule {
3447            condition: Expression::Always,
3448            effects: vec![Effect::Parallel(vec![
3449                Effect::Deny {
3450                    reason: Some("idx-0".into()),
3451                    code: None,
3452                },
3453                Effect::Deny {
3454                    reason: Some("idx-1".into()),
3455                    code: None,
3456                },
3457            ])],
3458            source: "test.policy[0]".into(),
3459        };
3460
3461        let eval = evaluate_effects(
3462            &vec![Effect::from(rule)],
3463            &mut bag,
3464            &(Arc::new(FakePdp {
3465                decision: Decision::Allow,
3466            }) as Arc<dyn PdpResolver>),
3467            &null_plugins(),
3468            &noop_delegations(),
3469            &noop_elicitations(),
3470            crate::step::DispatchPhase::Pre,
3471            &mut payload,
3472        )
3473        .await;
3474
3475        match eval.decision {
3476            Decision::Deny { reason, .. } => {
3477                assert_eq!(reason.as_deref(), Some("idx-0"), "lower-index halt wins");
3478            },
3479            other => panic!("expected Deny, got {:?}", other),
3480        }
3481    }
3482
3483    // ----- Effect::Elicit (human-in-the-loop) -----
3484
3485    /// Run a single `Effect::Elicit` against the given invoker + bag.
3486    /// Seeds the `user.manager` attribute (the `from` these tests use) so
3487    /// it resolves — an unresolved attribute `from` now fails closed, which
3488    /// its own dedicated test covers.
3489    async fn run_elicit(
3490        effect: Effect,
3491        elicitations: &Arc<dyn crate::step::ElicitationInvoker>,
3492        bag: &mut AttributeBag,
3493    ) -> StepsEvaluation {
3494        if bag.get_string("user.manager").is_none() {
3495            bag.set("user.manager", "manager@corp.com");
3496        }
3497        evaluate_effects(
3498            &[effect],
3499            bag,
3500            &(Arc::new(FakePdp {
3501                decision: Decision::Allow,
3502            }) as Arc<dyn PdpResolver>),
3503            &null_plugins(),
3504            &noop_delegations(),
3505            elicitations,
3506            crate::step::DispatchPhase::Pre,
3507            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3508        )
3509        .await
3510    }
3511
3512    #[tokio::test]
3513    async fn elicit_auto_approve_allows_and_records_facts() {
3514        let mut bag = AttributeBag::new();
3515        let eval = run_elicit(elicit_effect(None, None), &auto_elicitations(), &mut bag).await;
3516        assert_eq!(eval.decision, Decision::Allow);
3517        // Resolved facts land in the bag for downstream rules / audit.
3518        assert_eq!(bag.get_string("elicitation.status"), Some("resolved"));
3519        assert_eq!(bag.get_string("elicitation.outcome"), Some("approved"));
3520        assert_eq!(
3521            bag.get_string("elicitation.approver"),
3522            Some("manager@corp.com")
3523        );
3524        assert_eq!(bag.get_string("elicitation.intent_id"), Some("auto-intent"));
3525    }
3526
3527    #[tokio::test]
3528    async fn elicit_scope_satisfied_allows() {
3529        // Sufficiency check passes: amount within the approved bound.
3530        let mut bag = AttributeBag::new();
3531        bag.set("args.amount", 100_i64);
3532        let eval = run_elicit(
3533            elicit_effect(Some("args.amount <= 25000"), None),
3534            &auto_elicitations(),
3535            &mut bag,
3536        )
3537        .await;
3538        assert_eq!(eval.decision, Decision::Allow);
3539    }
3540
3541    #[tokio::test]
3542    async fn elicit_scope_unsatisfied_denies_even_when_approved() {
3543        // The human approved, but the live args exceed scope — the runtime
3544        // sufficiency check fails closed regardless of the genuine approval.
3545        let mut bag = AttributeBag::new();
3546        bag.set("args.amount", 50_000_i64);
3547        let eval = run_elicit(
3548            elicit_effect(Some("args.amount <= 25000"), None),
3549            &auto_elicitations(),
3550            &mut bag,
3551        )
3552        .await;
3553        match eval.decision {
3554            Decision::Deny { reason, .. } => {
3555                assert!(
3556                    reason
3557                        .as_deref()
3558                        .unwrap_or("")
3559                        .contains("scope not satisfied"),
3560                    "got: {:?}",
3561                    reason
3562                );
3563            },
3564            d => panic!("expected scope Deny, got {:?}", d),
3565        }
3566    }
3567
3568    #[tokio::test]
3569    async fn elicit_noop_dispatch_fails_closed() {
3570        // No channel wired → dispatch errors → fail closed (default deny).
3571        let mut bag = AttributeBag::new();
3572        let eval = run_elicit(elicit_effect(None, None), &noop_elicitations(), &mut bag).await;
3573        match eval.decision {
3574            Decision::Deny {
3575                reason,
3576                rule_source,
3577            } => {
3578                assert!(reason.as_deref().unwrap_or("").contains("dispatch failed"));
3579                assert_eq!(rule_source, "route.test.policy[0]");
3580            },
3581            d => panic!("expected fail-closed Deny, got {:?}", d),
3582        }
3583    }
3584
3585    #[tokio::test]
3586    async fn elicit_on_error_continue_allows_on_dispatch_failure() {
3587        // Same noop failure, but on_error=continue lets the pipeline pass.
3588        let mut bag = AttributeBag::new();
3589        let eval = run_elicit(
3590            elicit_effect(None, Some("continue")),
3591            &noop_elicitations(),
3592            &mut bag,
3593        )
3594        .await;
3595        assert_eq!(eval.decision, Decision::Allow);
3596    }
3597
3598    #[tokio::test]
3599    async fn elicit_denied_halts_regardless_of_on_error() {
3600        // A genuine human "no" halts even with on_error=continue — on_error
3601        // governs failures, not a valid denial.
3602        let denier: Arc<dyn crate::step::ElicitationInvoker> = Arc::new(DenyingElicitor);
3603        let mut bag = AttributeBag::new();
3604        let eval = run_elicit(elicit_effect(None, Some("continue")), &denier, &mut bag).await;
3605        match eval.decision {
3606            Decision::Deny { reason, .. } => {
3607                assert!(reason
3608                    .as_deref()
3609                    .unwrap_or("")
3610                    .contains("denied by approver"));
3611            },
3612            d => panic!("expected denial Deny, got {:?}", d),
3613        }
3614        assert_eq!(bag.get_string("elicitation.outcome"), Some("denied"));
3615    }
3616
3617    #[tokio::test]
3618    async fn elicit_pending_suspends_not_denies() {
3619        // Unresolved elicitation → the phase suspends: decision stays Allow
3620        // and a pending bundle is carried out (the host emits -32120). This
3621        // is the key difference from the fail-closed placeholder.
3622        let pending: Arc<dyn crate::step::ElicitationInvoker> = Arc::new(StillPendingElicitor);
3623        let mut bag = AttributeBag::new();
3624        let eval = run_elicit(elicit_effect(None, None), &pending, &mut bag).await;
3625
3626        assert_eq!(eval.decision, Decision::Allow, "pending is not a deny");
3627        let bundle = eval.pending.expect("pending bundle carried out");
3628        assert_eq!(bundle.id, "pending-1");
3629        assert_eq!(bundle.plugin_name, "manager-approver");
3630        assert_eq!(bundle.approver.as_deref(), Some("manager@corp.com"));
3631        assert_eq!(bundle.intent_id.as_deref(), Some("intent-xyz"));
3632        assert_eq!(bundle.channel.as_deref(), Some("ciba"));
3633        assert_eq!(bundle.expires_at.as_deref(), Some("2026-12-31T00:00:00Z"));
3634        assert_eq!(bundle.source, "route.test.policy[0]");
3635        // Bag reflects the in-flight state for audit.
3636        assert_eq!(bag.get_string("elicitation.status"), Some("pending"));
3637    }
3638
3639    #[tokio::test]
3640    async fn elicit_pending_even_with_on_error_continue() {
3641        // on_error governs *failures*; a genuine pending is not a failure,
3642        // so on_error=continue must NOT collapse it into a plain allow —
3643        // the bundle still surfaces so the host suspends.
3644        let pending: Arc<dyn crate::step::ElicitationInvoker> = Arc::new(StillPendingElicitor);
3645        let mut bag = AttributeBag::new();
3646        let eval = run_elicit(elicit_effect(None, Some("continue")), &pending, &mut bag).await;
3647        assert_eq!(eval.decision, Decision::Allow);
3648        assert!(
3649            eval.pending.is_some(),
3650            "pending must survive on_error=continue"
3651        );
3652    }
3653
3654    #[tokio::test]
3655    async fn elicit_pending_short_circuits_later_effects() {
3656        // A pending elicitation suspends the phase: effects after it in the
3657        // list do not run this pass. A trailing `deny` proves it — if the
3658        // phase kept walking, the decision would be Deny, not Allow+pending.
3659        let pending: Arc<dyn crate::step::ElicitationInvoker> = Arc::new(StillPendingElicitor);
3660        let mut bag = AttributeBag::new();
3661        bag.set("user.manager", "manager@corp.com");
3662        let effects = vec![elicit_effect(None, None), deny("should not run")];
3663        let eval = evaluate_effects(
3664            &effects,
3665            &mut bag,
3666            &(Arc::new(FakePdp {
3667                decision: Decision::Allow,
3668            }) as Arc<dyn PdpResolver>),
3669            &null_plugins(),
3670            &noop_delegations(),
3671            &pending,
3672            crate::step::DispatchPhase::Pre,
3673            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3674        )
3675        .await;
3676        assert_eq!(eval.decision, Decision::Allow, "trailing deny must not run");
3677        assert!(eval.pending.is_some());
3678    }
3679
3680    #[tokio::test]
3681    async fn elicit_unresolved_from_attribute_fails_closed() {
3682        // `from` is an attribute reference (`user.manager`) that isn't in
3683        // the bag. It must fail closed BEFORE dispatch — never send the
3684        // literal `"user.manager"` to the channel as a bogus login_hint.
3685        let mut bag = AttributeBag::new(); // note: no user.manager seeded
3686        let eval = evaluate_effects(
3687            &[elicit_effect(None, None)],
3688            &mut bag,
3689            &(Arc::new(FakePdp {
3690                decision: Decision::Allow,
3691            }) as Arc<dyn PdpResolver>),
3692            &null_plugins(),
3693            &noop_delegations(),
3694            &auto_elicitations(),
3695            crate::step::DispatchPhase::Pre,
3696            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3697        )
3698        .await;
3699        match eval.decision {
3700            Decision::Deny { reason, .. } => {
3701                let r = reason.as_deref().unwrap_or("");
3702                assert!(r.contains("did not resolve"), "got: {r}");
3703                assert!(r.contains("user.manager"), "got: {r}");
3704            },
3705            d => panic!(
3706                "expected fail-closed Deny on unresolved `from`, got {:?}",
3707                d
3708            ),
3709        }
3710        // Nothing was dispatched, so no elicitation id was recorded.
3711        assert!(bag.get_string("elicitation.id").is_none());
3712    }
3713
3714    #[tokio::test]
3715    async fn elicit_literal_from_passes_through_unresolved() {
3716        // A `from` that is a literal identity (an email — not an attribute
3717        // reference) is used verbatim even when it isn't a bag key.
3718        let mut bag = AttributeBag::new();
3719        let effect = Effect::Elicit(crate::step::ElicitStep {
3720            from: "alice@corp.com".into(),
3721            ..match elicit_effect(None, None) {
3722                Effect::Elicit(e) => e,
3723                _ => unreachable!(),
3724            }
3725        });
3726        let eval = evaluate_effects(
3727            &[effect],
3728            &mut bag,
3729            &(Arc::new(FakePdp {
3730                decision: Decision::Allow,
3731            }) as Arc<dyn PdpResolver>),
3732            &null_plugins(),
3733            &noop_delegations(),
3734            &auto_elicitations(),
3735            crate::step::DispatchPhase::Pre,
3736            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3737        )
3738        .await;
3739        assert_eq!(eval.decision, Decision::Allow);
3740        // The literal identity flowed through as the resolved approver.
3741        assert_eq!(
3742            bag.get_string("elicitation.approver"),
3743            Some("alice@corp.com")
3744        );
3745    }
3746
3747    #[test]
3748    fn looks_like_attribute_ref_classification() {
3749        // Attribute references: dotted identifier paths.
3750        assert!(looks_like_attribute_ref("user.manager"));
3751        assert!(looks_like_attribute_ref("claim.manager"));
3752        assert!(looks_like_attribute_ref("user.sub"));
3753        // Literals: emails, display names, bare tokens.
3754        assert!(!looks_like_attribute_ref("alice@corp.com"));
3755        assert!(!looks_like_attribute_ref("Alice Smith"));
3756        assert!(!looks_like_attribute_ref("alice"));
3757        assert!(!looks_like_attribute_ref(""));
3758    }
3759}