Skip to main content

apl_core/
parser.rs

1// Location: ./crates/apl-core/src/parser.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// APL parser — DSL string → IR, and YAML config → HashMap<route_key, CompiledRoute>.
7//
8// Runs once at config load. The IR it produces is what the evaluator walks
9// at request time; the parser is never on the hot path.
10//
11// Grammar anchored in apl-dsl-spec.md §2 (predicates) / §3 (rules) / §8 (EBNF).
12// YAML shape anchored in apl-design.md §5 (`routes:` as map keyed by route_key).
13//
14// Step 5a scope:
15//   ✓ Predicate grammar: identifiers, literals, comparisons, contains,
16//     & | ! parens, require(...)
17//   ✓ Actions: deny / allow / (default deny on missing)
18//   ✓ YAML top-level routes: keyed map, authorization.pre_invocation /
19//     post_invocation blocks (flat pre_invocation:/post_invocation: too)
20//   ✗ Steps (cedar:(), opa(), plugin(), taint()) — rejected with clear errors
21//   ✗ Pipe chains in args:/result: — fields parsed, values stashed as opaque
22//   ✗ `in` / `not in` / `exists()` — need IR variants first; rejected
23//   ✗ Multi-effect do: lists, sequential:/parallel: blocks — rejected
24
25use std::collections::HashMap;
26
27use serde::Deserialize;
28use thiserror::Error;
29
30use crate::pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintScope, TypeCheck};
31use crate::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry};
32use crate::rules::{CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Rule};
33use crate::step::{DelegateStep, PdpCall, PdpDialect, Step};
34
35// =====================================================================
36// Errors
37// =====================================================================
38
39#[derive(Debug, Error)]
40pub enum ParseError {
41    #[error("YAML parse error: {0}")]
42    Yaml(#[from] serde_yaml::Error),
43
44    #[error("rule '{rule}': {msg}")]
45    Rule { rule: String, msg: String },
46
47    #[error("unsupported step `{kind}` in rule '{rule}' — defer to step 5b")]
48    UnsupportedStep { rule: String, kind: String },
49
50    #[error("predicate '{predicate}': {msg}")]
51    Predicate { predicate: String, msg: String },
52
53    #[error("in `{location}`: config field `{old}` was renamed to `{new}` — update your config")]
54    RenamedField {
55        location: String,
56        old: String,
57        new: String,
58    },
59
60    #[error(
61        "in `{location}`: `{phase}` is declared both nested under `authorization:` and flat on \
62         the section — use one form, not both (declaring both runs the effects twice)"
63    )]
64    ConflictingAuthorizationForms { location: String, phase: String },
65}
66
67// =====================================================================
68// Lexer
69// =====================================================================
70
71#[derive(Debug, Clone, PartialEq)]
72enum Tok {
73    Ident(String), // dotted: subject.id, role.hr, authenticated
74    StringLit(String),
75    IntLit(i64),
76    FloatLit(f64),
77    BoolLit(bool),
78    Eq,    // ==
79    NotEq, // !=
80    Gt,    // >
81    GtEq,  // >=
82    Lt,    // <
83    LtEq,  // <=
84    And,   // &  (must have surrounding spaces — caller enforces)
85    Or,    // |
86    Not,   // !
87    LParen,
88    RParen,
89    Comma,
90    Contains, // keyword
91    Require,  // keyword
92    Exists,   // keyword
93    In,       // keyword — set membership operator
94}
95
96struct Lexer<'a> {
97    src: &'a str,
98    bytes: &'a [u8],
99    pos: usize,
100}
101
102impl<'a> Lexer<'a> {
103    fn new(src: &'a str) -> Self {
104        Self {
105            src,
106            bytes: src.as_bytes(),
107            pos: 0,
108        }
109    }
110
111    fn peek(&self) -> Option<u8> {
112        self.bytes.get(self.pos).copied()
113    }
114
115    fn bump(&mut self) -> Option<u8> {
116        let b = self.peek()?;
117        self.pos += 1;
118        Some(b)
119    }
120
121    fn skip_ws(&mut self) {
122        while let Some(b) = self.peek() {
123            if b.is_ascii_whitespace() {
124                self.pos += 1;
125            } else {
126                break;
127            }
128        }
129    }
130
131    fn tokenize_all(&mut self) -> Result<Vec<Tok>, ParseError> {
132        let mut out = Vec::new();
133        loop {
134            self.skip_ws();
135            let Some(b) = self.peek() else {
136                return Ok(out);
137            };
138
139            let tok = match b {
140                b'(' => {
141                    self.pos += 1;
142                    Tok::LParen
143                },
144                b')' => {
145                    self.pos += 1;
146                    Tok::RParen
147                },
148                b',' => {
149                    self.pos += 1;
150                    Tok::Comma
151                },
152                b'&' => {
153                    self.pos += 1;
154                    Tok::And
155                },
156                b'|' => {
157                    self.pos += 1;
158                    Tok::Or
159                },
160                b'=' => {
161                    self.pos += 1;
162                    if self.peek() == Some(b'=') {
163                        self.pos += 1;
164                        Tok::Eq
165                    } else {
166                        return Err(self.err("expected `==`, saw `=`"));
167                    }
168                },
169                b'!' => {
170                    self.pos += 1;
171                    if self.peek() == Some(b'=') {
172                        self.pos += 1;
173                        Tok::NotEq
174                    } else {
175                        Tok::Not
176                    }
177                },
178                b'>' => {
179                    self.pos += 1;
180                    if self.peek() == Some(b'=') {
181                        self.pos += 1;
182                        Tok::GtEq
183                    } else {
184                        Tok::Gt
185                    }
186                },
187                b'<' => {
188                    self.pos += 1;
189                    if self.peek() == Some(b'=') {
190                        self.pos += 1;
191                        Tok::LtEq
192                    } else {
193                        Tok::Lt
194                    }
195                },
196                b'"' | b'\'' => self.lex_string(b)?,
197                b'-' | b'0'..=b'9' => self.lex_number()?,
198                b if is_ident_start(b) => self.lex_ident_or_keyword(),
199                _ => return Err(self.err(&format!("unexpected char `{}`", b as char))),
200            };
201            out.push(tok);
202        }
203    }
204
205    fn lex_string(&mut self, quote: u8) -> Result<Tok, ParseError> {
206        self.bump(); // opening quote
207        let start = self.pos;
208        while let Some(b) = self.peek() {
209            if b == quote {
210                break;
211            }
212            self.pos += 1;
213        }
214        if self.peek() != Some(quote) {
215            return Err(self.err("unterminated string literal"));
216        }
217        let s = std::str::from_utf8(&self.bytes[start..self.pos])
218            .map_err(|_| self.err("non-utf8 in string literal"))?
219            .to_string();
220        self.bump(); // closing quote
221        Ok(Tok::StringLit(s))
222    }
223
224    fn lex_number(&mut self) -> Result<Tok, ParseError> {
225        let start = self.pos;
226        if self.peek() == Some(b'-') {
227            self.pos += 1;
228        }
229        while let Some(b) = self.peek() {
230            if b.is_ascii_digit() {
231                self.pos += 1;
232            } else {
233                break;
234            }
235        }
236        let mut is_float = false;
237        if self.peek() == Some(b'.') {
238            is_float = true;
239            self.pos += 1;
240            while let Some(b) = self.peek() {
241                if b.is_ascii_digit() {
242                    self.pos += 1;
243                } else {
244                    break;
245                }
246            }
247        }
248        let text = &self.src[start..self.pos];
249        if is_float {
250            text.parse::<f64>()
251                .map(Tok::FloatLit)
252                .map_err(|_| self.err(&format!("bad float `{}`", text)))
253        } else {
254            text.parse::<i64>()
255                .map(Tok::IntLit)
256                .map_err(|_| self.err(&format!("bad int `{}`", text)))
257        }
258    }
259
260    fn lex_ident_or_keyword(&mut self) -> Tok {
261        let start = self.pos;
262        while let Some(b) = self.peek() {
263            if is_ident_cont(b) {
264                self.pos += 1;
265            } else {
266                break;
267            }
268        }
269        let s = &self.src[start..self.pos];
270        match s {
271            "true" => Tok::BoolLit(true),
272            "false" => Tok::BoolLit(false),
273            "contains" => Tok::Contains,
274            "require" => Tok::Require,
275            "exists" => Tok::Exists,
276            "in" => Tok::In,
277            // "not" is NOT a keyword — it only appears in the `not in`
278            // phrase. The parser handles that as an Ident("not") + Tok::In
279            // sequence in parse_identifier_predicate.
280            _ => Tok::Ident(s.to_string()),
281        }
282    }
283
284    fn err(&self, msg: &str) -> ParseError {
285        ParseError::Predicate {
286            predicate: self.src.to_string(),
287            msg: format!("at byte {}: {}", self.pos, msg),
288        }
289    }
290}
291
292fn is_ident_start(b: u8) -> bool {
293    b.is_ascii_alphabetic() || b == b'_'
294}
295
296fn is_ident_cont(b: u8) -> bool {
297    b.is_ascii_alphanumeric() || b == b'_' || b == b'.'
298}
299
300// =====================================================================
301// Predicate parser (Pratt-style; precedence () > ! > & > |)
302// =====================================================================
303
304struct PredParser<'a> {
305    src: &'a str,
306    toks: Vec<Tok>,
307    pos: usize,
308}
309
310impl<'a> PredParser<'a> {
311    fn parse(src: &'a str) -> Result<Expression, ParseError> {
312        let toks = Lexer::new(src).tokenize_all()?;
313        let mut p = Self { src, toks, pos: 0 };
314        let expr = p.parse_or()?;
315        if p.pos < p.toks.len() {
316            return Err(p.err(&format!(
317                "trailing tokens after expression: {:?}",
318                &p.toks[p.pos..]
319            )));
320        }
321        Ok(expr)
322    }
323
324    fn peek(&self) -> Option<&Tok> {
325        self.toks.get(self.pos)
326    }
327    fn bump(&mut self) -> Option<Tok> {
328        let t = self.toks.get(self.pos).cloned()?;
329        self.pos += 1;
330        Some(t)
331    }
332    fn err(&self, msg: &str) -> ParseError {
333        ParseError::Predicate {
334            predicate: self.src.to_string(),
335            msg: msg.to_string(),
336        }
337    }
338
339    fn parse_or(&mut self) -> Result<Expression, ParseError> {
340        let mut parts = vec![self.parse_and()?];
341        while matches!(self.peek(), Some(Tok::Or)) {
342            self.bump();
343            parts.push(self.parse_and()?);
344        }
345        Ok(if parts.len() == 1 {
346            parts.pop().unwrap()
347        } else {
348            Expression::Or(parts)
349        })
350    }
351
352    fn parse_and(&mut self) -> Result<Expression, ParseError> {
353        let mut parts = vec![self.parse_unary()?];
354        while matches!(self.peek(), Some(Tok::And)) {
355            self.bump();
356            parts.push(self.parse_unary()?);
357        }
358        Ok(if parts.len() == 1 {
359            parts.pop().unwrap()
360        } else {
361            Expression::And(parts)
362        })
363    }
364
365    fn parse_unary(&mut self) -> Result<Expression, ParseError> {
366        if matches!(self.peek(), Some(Tok::Not)) {
367            self.bump();
368            let inner = self.parse_unary()?;
369            return Ok(Expression::Not(Box::new(inner)));
370        }
371        self.parse_atom()
372    }
373
374    fn parse_atom(&mut self) -> Result<Expression, ParseError> {
375        match self.peek() {
376            Some(Tok::LParen) => {
377                self.bump();
378                let inner = self.parse_or()?;
379                match self.bump() {
380                    Some(Tok::RParen) => Ok(inner),
381                    _ => Err(self.err("expected `)`")),
382                }
383            },
384            // `require(...)` is a rule-level shorthand per DSL §8 grammar
385            // (`rule = require_call | predicate ...`), not a sub-predicate.
386            // Trying to nest it inside `&` / `|` is a grammar error.
387            Some(Tok::Require) => Err(self.err(
388                "`require(...)` is a rule-level shorthand, not a sub-predicate \
389                 — use `&` / `|` / `!` over bare identifiers instead",
390            )),
391            Some(Tok::Exists) => self.parse_exists(),
392            Some(Tok::Ident(_)) => self.parse_identifier_predicate(),
393            other => Err(self.err(&format!("expected atom, got {:?}", other))),
394        }
395    }
396
397    /// `exists(<identifier>)` — DSL §2.2. Returns true if the key is present
398    /// in the AttributeBag, regardless of value (distinct from truthiness).
399    fn parse_exists(&mut self) -> Result<Expression, ParseError> {
400        self.bump(); // exists
401        match self.bump() {
402            Some(Tok::LParen) => {},
403            _ => return Err(self.err("expected `(` after `exists`")),
404        }
405        let key = match self.bump() {
406            Some(Tok::Ident(s)) => s,
407            other => {
408                return Err(self.err(&format!(
409                    "exists(...) expects an attribute key, got {:?}",
410                    other,
411                )))
412            },
413        };
414        match self.bump() {
415            Some(Tok::RParen) => {},
416            other => {
417                return Err(self.err(&format!(
418                    "expected `)` after exists() argument, got {:?}",
419                    other,
420                )))
421            },
422        }
423        Ok(Expression::Condition(Condition::Exists { key }))
424    }
425
426    /// Parse a predicate that begins with an identifier:
427    ///   - bare identifier:    `authenticated`  → IsTrue
428    ///   - comparison:         `delegation.depth > 2`
429    ///   - contains:           `session.labels contains "PII"`
430    ///   - set membership:     `subject.type in allowed_types`
431    ///   - set non-membership: `subject.type not in blocked_types`
432    fn parse_identifier_predicate(&mut self) -> Result<Expression, ParseError> {
433        let key = match self.bump() {
434            Some(Tok::Ident(s)) => s,
435            _ => unreachable!("parse_atom dispatched here"),
436        };
437
438        // `in` and `not in` — two-key set membership (DSL §2.4).
439        if matches!(self.peek(), Some(Tok::In)) {
440            self.bump();
441            return self.finish_in_set(key, false);
442        }
443        // `not in` shows up as Ident("not") + Tok::In. Treat that as a
444        // grammar phrase here; bare `not` outside this context is not a
445        // DSL keyword (use `!` for predicate negation).
446        if let Some(Tok::Ident(maybe_not)) = self.peek() {
447            if maybe_not == "not" {
448                let saved_pos = self.pos;
449                self.bump(); // consume "not"
450                if matches!(self.peek(), Some(Tok::In)) {
451                    self.bump();
452                    return self.finish_in_set(key, true);
453                }
454                // Not "not in" — rewind so the downstream error reports
455                // the trailing-ident properly.
456                self.pos = saved_pos;
457            }
458        }
459
460        let op = match self.peek() {
461            Some(Tok::Eq) => Some(CompareOp::Eq),
462            Some(Tok::NotEq) => Some(CompareOp::NotEq),
463            Some(Tok::Gt) => Some(CompareOp::Gt),
464            Some(Tok::GtEq) => Some(CompareOp::GtEq),
465            Some(Tok::Lt) => Some(CompareOp::Lt),
466            Some(Tok::LtEq) => Some(CompareOp::LtEq),
467            Some(Tok::Contains) => Some(CompareOp::Contains),
468            _ => None,
469        };
470
471        let Some(op) = op else {
472            // Bare identifier.
473            return Ok(Expression::Condition(Condition::IsTrue { key }));
474        };
475        self.bump();
476
477        let value = match self.bump() {
478            Some(Tok::StringLit(s)) => Literal::String(s),
479            Some(Tok::IntLit(i)) => Literal::Int(i),
480            Some(Tok::FloatLit(f)) => Literal::Float(f),
481            Some(Tok::BoolLit(b)) => Literal::Bool(b),
482            Some(Tok::Ident(_)) => {
483                return Err(self.err(
484                    "RHS-as-identifier on comparison operators not supported — \
485                     for set membership use `value_key in set_key`",
486                ));
487            },
488            other => return Err(self.err(&format!("expected literal RHS, got {:?}", other))),
489        };
490
491        Ok(Expression::Condition(Condition::Comparison {
492            key,
493            op,
494            value,
495        }))
496    }
497
498    fn finish_in_set(&mut self, value_key: String, negate: bool) -> Result<Expression, ParseError> {
499        let set_key = match self.bump() {
500            Some(Tok::Ident(s)) => s,
501            other => {
502                return Err(self.err(&format!(
503                    "expected set-attribute identifier after `{}in`, got {:?}",
504                    if negate { "not " } else { "" },
505                    other,
506                )))
507            },
508        };
509        Ok(Expression::Condition(Condition::InSet {
510            value_key,
511            set_key,
512            negate,
513        }))
514    }
515}
516
517/// Parse a predicate string into the IR. Public for tests + step-5b use.
518pub fn parse_predicate(src: &str) -> Result<Expression, ParseError> {
519    PredParser::parse(src.trim())
520}
521
522// =====================================================================
523// Rule parser
524// =====================================================================
525
526/// Parse a single rule line into a `Rule`.
527///
528/// Accepted forms (DSL §3.2):
529///   1. `"require(...)"`           →  rule-level shorthand, desugars to
530///                                    `when: <negated condition> do: deny`
531///                                    per DSL §8.1
532///   2. `"<predicate>: <action>"`  →  Rule { condition, action }
533///   3. `"<predicate>"`            →  Rule { condition, action: Deny } (default)
534///   4. `"<action>"` (action only) →  treated as form 3 (always-true predicate)
535///
536/// **Step kinds** (`plugin(...)`, `taint(...)`, `cedar:`, `opa(...)` etc.)
537/// are handled by `parse_step`, not here. This function specifically parses
538/// predicate-and-action rules; callers that don't know which they have
539/// should use `parse_step` instead.
540pub fn parse_rule(line: &str, source: &str) -> Result<Rule, ParseError> {
541    let trimmed = line.trim();
542
543    // require(...) shorthand — special-cased because it desugars to a
544    // negated predicate + Deny action, and the spec grammar (§8) puts it
545    // as a top-level rule alternative, not a sub-predicate.
546    if is_require_call(trimmed) {
547        let condition = parse_require_rule(trimmed)?;
548        return Ok(Rule::single(
549            condition,
550            Effect::Deny {
551                reason: None,
552                code: None,
553            },
554            source,
555        ));
556    }
557
558    // Step kinds shouldn't end up here. If they do, the caller used the
559    // wrong entry point — point them at parse_step.
560    if let Some(kind) = detect_step_kind(trimmed) {
561        return Err(ParseError::UnsupportedStep {
562            rule: trimmed.to_string(),
563            kind: format!("{} (use parse_step for step kinds)", kind),
564        });
565    }
566
567    let (predicate_str, effects) = match split_predicate_action(trimmed) {
568        Some((p, a)) => (p, parse_action(a, trimmed)?),
569        None => {
570            // No `:` — bare action (unconditional) or bare predicate (default deny).
571            if let Some(effects) = try_bare_action(trimmed) {
572                return Ok(Rule {
573                    condition: Expression::Always,
574                    effects,
575                    source: source.to_string(),
576                });
577            }
578            // Unconditional `deny('reason')` / `deny('reason', 'code')` —
579            // the call form of a bare deny. Lets reaction lists
580            // (`on_deny: [...]` / `on_allow: [...]`) and standalone rule
581            // lines attach a reason/code without a guard predicate. A
582            // malformed `deny(...)` surfaces its own error here rather
583            // than being misread as a predicate downstream.
584            if let Some(deny) = try_parse_deny_call(trimmed, trimmed)? {
585                return Ok(Rule {
586                    condition: Expression::Always,
587                    effects: vec![deny],
588                    source: source.to_string(),
589                });
590            }
591            // DSL §2 default: bare predicate denies.
592            (
593                trimmed,
594                vec![Effect::Deny {
595                    reason: None,
596                    code: None,
597                }],
598            )
599        },
600    };
601
602    let condition = parse_predicate(predicate_str).map_err(|e| ParseError::Rule {
603        rule: trimmed.to_string(),
604        msg: format!("{}", e),
605    })?;
606
607    Ok(Rule {
608        condition,
609        effects,
610        source: source.to_string(),
611    })
612}
613
614fn is_require_call(s: &str) -> bool {
615    s.trim_start().starts_with("require(")
616}
617
618/// Parse `require(a)` / `require(a, b, ...)` / `require(a | b | ...)` and
619/// return the desugared "when" expression per DSL §8.1:
620///
621///   require(X)             →  IsFalse(X)
622///   require(X, Y, ...)     →  Or([IsFalse(X), IsFalse(Y), ...])   (deny if any falsy)
623///   require(X | Y | ...)   →  And([IsFalse(X), IsFalse(Y), ...])  (deny if all falsy)
624///
625/// Caller wraps with `Effect::Deny`.
626fn parse_require_rule(line: &str) -> Result<Expression, ParseError> {
627    let toks = Lexer::new(line).tokenize_all()?;
628    let mut iter = toks.into_iter().peekable();
629
630    let bad = |msg: &str| ParseError::Rule {
631        rule: line.to_string(),
632        msg: msg.to_string(),
633    };
634
635    match iter.next() {
636        Some(Tok::Require) => {},
637        _ => return Err(bad("expected `require`")),
638    }
639    match iter.next() {
640        Some(Tok::LParen) => {},
641        _ => return Err(bad("expected `(` after `require`")),
642    }
643
644    let mut keys = Vec::new();
645    let mut sep: Option<Tok> = None;
646
647    match iter.next() {
648        Some(Tok::Ident(s)) => keys.push(s),
649        _ => return Err(bad("expected identifier inside `require(...)`")),
650    }
651
652    loop {
653        match iter.next() {
654            Some(Tok::RParen) => break,
655            Some(t @ Tok::Comma) | Some(t @ Tok::Or) => {
656                match &sep {
657                    None => sep = Some(t),
658                    Some(prev) if std::mem::discriminant(prev) == std::mem::discriminant(&t) => {},
659                    _ => {
660                        return Err(bad(
661                            "require(...) cannot mix `,` (AND) and `|` (OR) — use one or the other",
662                        ))
663                    },
664                }
665                match iter.next() {
666                    Some(Tok::Ident(s)) => keys.push(s),
667                    _ => return Err(bad("expected identifier after `,` or `|` in require(...)")),
668                }
669            },
670            Some(other) => {
671                return Err(bad(&format!(
672                    "expected `,`, `|`, or `)` in require(...), got {:?}",
673                    other,
674                )))
675            },
676            None => return Err(bad("unexpected end of require(...) — missing `)`")),
677        }
678    }
679
680    if iter.peek().is_some() {
681        return Err(bad(
682            "trailing tokens after `require(...)` — require is a complete rule",
683        ));
684    }
685
686    let falses: Vec<Expression> = keys
687        .into_iter()
688        .map(|k| Expression::Condition(Condition::IsFalse { key: k }))
689        .collect();
690    if falses.len() == 1 {
691        return Ok(falses.into_iter().next().unwrap());
692    }
693    Ok(match sep {
694        Some(Tok::Or) => Expression::And(falses), // require(X | Y) → !X & !Y
695        _ => Expression::Or(falses),              // require(X, Y)  → !X | !Y
696    })
697}
698
699/// Detect `taint(...)` / `plugin(...)` / `run(...)` / `cedar:` / `opa(` / `authzen(` / `nemo(` / `cel:`.
700fn detect_step_kind(s: &str) -> Option<&'static str> {
701    let s = s.trim_start();
702    for prefix in [
703        "taint(",
704        "plugin(",
705        "run(",
706        "cedar:",
707        "opa(",
708        "authzen(",
709        "nemo(",
710        "cel:",
711        "sequential:",
712        "parallel:",
713    ] {
714        if s.starts_with(prefix) {
715            return Some(prefix.trim_end_matches('(').trim_end_matches(':'));
716        }
717    }
718    None
719}
720
721/// Split on the *last* unescaped `:` that's outside quotes and parens — this
722/// is the predicate/action separator. The DSL doesn't escape colons, and `:`
723/// doesn't appear in our predicate grammar, but quotes and parens can contain
724/// arbitrary text.
725fn split_predicate_action(s: &str) -> Option<(&str, &str)> {
726    let bytes = s.as_bytes();
727    let mut depth: i32 = 0;
728    let mut in_quote: Option<u8> = None;
729    let mut last_colon: Option<usize> = None;
730    for (i, &b) in bytes.iter().enumerate() {
731        match (in_quote, b) {
732            (Some(q), c) if c == q => in_quote = None,
733            (Some(_), _) => {},
734            (None, b'"') | (None, b'\'') => in_quote = Some(b),
735            (None, b'(') => depth += 1,
736            (None, b')') => depth -= 1,
737            (None, b':') if depth == 0 => last_colon = Some(i),
738            _ => {},
739        }
740    }
741    last_colon.map(|i| (s[..i].trim(), s[i + 1..].trim()))
742}
743
744/// Parse the *right* side of a shorthand `predicate: action` rule into a
745/// single-element effects vec. Recognized forms (DSL §3 + the `code`
746/// extension we added in E1):
747///
748///   * `deny`                    → `vec![Effect::Deny { reason: None, code: None }]`
749///   * `deny('reason')`          → `vec![Effect::Deny { reason: Some, code: None }]`
750///   * `deny('reason', 'code')`  → `vec![Effect::Deny { reason: Some, code: Some }]`
751///   * `allow`                   → `vec![Effect::Allow]`
752///
753/// Anything else (plugin/delegate/taint) goes through `parse_step`, not
754/// here — those are sibling Steps in v0. Multi-effect `do:` lists use a
755/// separate parsing path that produces `Vec<Effect>` directly.
756fn parse_action(s: &str, rule: &str) -> Result<Vec<Effect>, ParseError> {
757    if let Some(effect) = try_bare_action(s) {
758        return Ok(effect);
759    }
760    if let Some(deny) = try_parse_deny_call(s.trim(), rule)? {
761        return Ok(vec![deny]);
762    }
763    Err(ParseError::Rule {
764        rule: rule.to_string(),
765        msg: format!(
766            "unsupported action `{}` — recognized: `deny`, `deny('reason')`, `deny('reason', 'code')`, `allow`",
767            s.trim()
768        ),
769    })
770}
771
772fn try_bare_action(s: &str) -> Option<Vec<Effect>> {
773    match s.trim() {
774        "deny" => Some(vec![Effect::Deny {
775            reason: None,
776            code: None,
777        }]),
778        "allow" => Some(vec![Effect::Allow]),
779        _ => None,
780    }
781}
782
783/// Parse `deny('reason')` or `deny('reason', 'code')`. Returns
784/// `Ok(None)` when `s` doesn't start with `deny(` so the caller can
785/// fall through to other action handlers.
786fn try_parse_deny_call(s: &str, rule: &str) -> Result<Option<Effect>, ParseError> {
787    if !s.starts_with("deny(") {
788        return Ok(None);
789    }
790    let inside = extract_call_args(s, "deny").ok_or_else(|| ParseError::Rule {
791        rule: rule.to_string(),
792        msg: "malformed `deny(...)`".into(),
793    })?;
794    // Two positional args max. Spec precedent: `deny('reason')` (1 arg);
795    // E1 extension: `deny('reason', 'code')` (2 args). Both quoted.
796    let parts = split_top_level_commas(&inside).map_err(|e| ParseError::Rule {
797        rule: rule.to_string(),
798        msg: format!("deny(...): {}", e),
799    })?;
800    let mut iter = parts.into_iter();
801    let reason = match iter.next() {
802        Some(p) => Some(strip_string_literal(p.trim(), rule)?),
803        None => None,
804    };
805    let code = match iter.next() {
806        Some(p) => Some(strip_string_literal(p.trim(), rule)?),
807        None => None,
808    };
809    if iter.next().is_some() {
810        return Err(ParseError::Rule {
811            rule: rule.to_string(),
812            msg: "deny(...) takes at most two args: deny('reason', 'code')".into(),
813        });
814    }
815    Ok(Some(Effect::Deny { reason, code }))
816}
817
818/// Strip surrounding single or double quotes from a literal. The DSL
819/// uses single quotes (`'reason'`) per the spec examples, but accept
820/// double quotes too so YAML escaping is forgiving.
821fn strip_string_literal(s: &str, rule: &str) -> Result<String, ParseError> {
822    let s = s.trim();
823    if (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
824        || (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
825    {
826        Ok(s[1..s.len() - 1].to_string())
827    } else {
828        Err(ParseError::Rule {
829            rule: rule.to_string(),
830            msg: format!("expected a quoted string, got `{}`", s),
831        })
832    }
833}
834
835// =====================================================================
836// Step parser (pre_invocation / post_invocation entries — steps + rules)
837// =====================================================================
838
839/// Parse a single YAML entry from a `pre_invocation` / `post_invocation` list.
840///
841/// Two YAML shapes (DSL §3.2 + §7):
842/// - **String entry** — a rule line, taint effect, or plugin call.
843///   - `"require(authenticated)"` → `Step::Rule`
844///   - `"delegation.depth > 2: deny"` → `Step::Rule`
845///   - `"plugin(rate_limiter)"` → `Step::Plugin` (`"run(rate_limiter)"` is an alias)
846///   - `"taint(PII, session)"` → `Step::Taint`
847/// - **Map entry** (single-key map) — PDP call with optional reactions.
848///   - `cedar: { action: read, resource: e, on_deny: [...] }` → `Step::Pdp`
849///   - `opa("path"): { on_deny: [...] }` → `Step::Pdp`
850pub fn parse_step(value: &serde_yaml::Value, source: &str) -> Result<Step, ParseError> {
851    match value {
852        serde_yaml::Value::String(s) => parse_step_string(s, source),
853        serde_yaml::Value::Mapping(m) => parse_step_map(m, source),
854        other => Err(ParseError::Rule {
855            rule: format!("{:?}", other),
856            msg: "step must be a string or a single-key map".into(),
857        }),
858    }
859}
860
861fn parse_step_string(line: &str, source: &str) -> Result<Step, ParseError> {
862    let trimmed = line.trim();
863
864    // taint(...) — emit as Step::Taint, reusing the pipeline parser's logic
865    // so the shape stays consistent with field-level taint.
866    if trimmed.starts_with("taint(") {
867        let inside = extract_call_args(trimmed, "taint").ok_or_else(|| ParseError::Rule {
868            rule: trimmed.to_string(),
869            msg: "malformed `taint(...)`".into(),
870        })?;
871        let taint_stage = parse_taint(&inside, trimmed)?;
872        // parse_taint produces Stage::Taint; lift to Step::Taint.
873        if let Stage::Taint { label, scopes } = taint_stage {
874            return Ok(Step::Taint { label, scopes });
875        }
876        unreachable!("parse_taint always returns Stage::Taint");
877    }
878
879    // plugin(name) / run(name) — invoke a named plugin. `run` is an
880    // alias for `plugin`; both emit Step::Plugin.
881    let plugin_verb = if trimmed.starts_with("plugin(") {
882        Some("plugin")
883    } else if trimmed.starts_with("run(") {
884        Some("run")
885    } else {
886        None
887    };
888    if let Some(verb) = plugin_verb {
889        let inside = extract_call_args(trimmed, verb).ok_or_else(|| ParseError::Rule {
890            rule: trimmed.to_string(),
891            msg: format!("malformed `{verb}(...)`"),
892        })?;
893        let name = inside.trim();
894        if name.is_empty() {
895            return Err(ParseError::Rule {
896                rule: trimmed.to_string(),
897                msg: format!("`{verb}(...)`: plugin name must not be empty"),
898            });
899        }
900        return Ok(Step::Plugin {
901            name: name.to_string(),
902        });
903    }
904
905    // delegate(name, key: value, key: [a, b], ...) — emit as Step::Delegate.
906    // Compact alternative to the map form (`- delegate: { plugin: ..., ... }`).
907    // First positional arg is the plugin name; subsequent `key: value`
908    // pairs become per-call config overrides (or `on_error` if the key
909    // is reserved). Use the map form for nested configs the kwarg
910    // parser doesn't handle.
911    if trimmed.starts_with("delegate(") {
912        let inside = extract_call_args(trimmed, "delegate").ok_or_else(|| ParseError::Rule {
913            rule: trimmed.to_string(),
914            msg: "malformed `delegate(...)`".into(),
915        })?;
916        let parsed = parse_delegate_call_args(&inside, source)?;
917        return Ok(Step::Delegate(DelegateStep {
918            plugin_name: parsed.plugin_name,
919            config_override: parsed.config_override,
920            on_error: parsed.on_error,
921            source: source.to_string(),
922        }));
923    }
924
925    // Otherwise fall through to the rule parser — predicate-and-action.
926    let rule = parse_rule(trimmed, source)?;
927    Ok(Step::Rule(rule))
928}
929
930/// Intermediate shape produced by [`parse_delegate_call_args`]. The
931/// string-form parser fills this; the caller wraps into `Step::Delegate`
932/// with the source path it has in scope.
933struct ParsedDelegateCall {
934    plugin_name: String,
935    config_override: Option<serde_yaml::Value>,
936    on_error: Option<String>,
937}
938
939/// Parse the inside-parens of `delegate(name, key: value, key: [a, b], ...)`.
940///
941/// Grammar (informal):
942/// ```text
943/// delegate_args := plugin_name [, kwarg [, kwarg]*]
944/// plugin_name   := bare_ident_or_string
945/// kwarg         := key ":" value
946/// value         := scalar | "[" value (, value)* "]"
947/// scalar        := bare_word | number | "true" | "false" | quoted_string
948/// ```
949///
950/// Reserved keys consumed before going into `config_override`:
951///   - `on_error` — pulled out as `DelegateStep.on_error`
952///
953/// Everything else lands in `config_override` as a yaml mapping. Use
954/// the map form (`- delegate: { plugin: ..., config: { ... }, ... }`)
955/// for nested config shapes the flat kwarg parser doesn't handle.
956fn parse_delegate_call_args(inside: &str, source: &str) -> Result<ParsedDelegateCall, ParseError> {
957    let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule {
958        rule: format!("delegate({inside})"),
959        msg: format!("{source}: {msg}"),
960    })?;
961    let mut parts_iter = parts.into_iter();
962
963    let plugin_name = parts_iter
964        .next()
965        .map(|s| s.trim().to_string())
966        .filter(|s| !s.is_empty())
967        .ok_or_else(|| ParseError::Rule {
968            rule: format!("delegate({inside})"),
969            msg: format!(
970                "{source}: `delegate(...)` requires a plugin name as the first \
971                 positional argument"
972            ),
973        })?;
974    // Strip wrapping quotes if the operator wrote `delegate("workday-oauth", ...)`.
975    let plugin_name = strip_wrapping_quotes(&plugin_name).to_string();
976    if plugin_name.is_empty() {
977        return Err(ParseError::Rule {
978            rule: format!("delegate({inside})"),
979            msg: format!("{source}: `delegate(...)` plugin name cannot be empty"),
980        });
981    }
982
983    let mut on_error: Option<String> = None;
984    let mut config_map = serde_yaml::Mapping::new();
985
986    for raw_kwarg in parts_iter {
987        let kwarg = raw_kwarg.trim();
988        if kwarg.is_empty() {
989            continue;
990        }
991        let (key, value_str) = kwarg.split_once(':').ok_or_else(|| ParseError::Rule {
992            rule: kwarg.to_string(),
993            msg: format!(
994                "{source}: `delegate(...)` kwarg `{kwarg}` must be `key: value` \
995                     (use the map form for richer config)"
996            ),
997        })?;
998        let key = key.trim();
999        let value_str = value_str.trim();
1000        if key.is_empty() {
1001            return Err(ParseError::Rule {
1002                rule: kwarg.to_string(),
1003                msg: format!("{source}: `delegate(...)` kwarg has empty key"),
1004            });
1005        }
1006        if key == "on_error" {
1007            let val = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
1008                rule: kwarg.to_string(),
1009                msg: format!("{source}: on_error: {msg}"),
1010            })?;
1011            on_error = Some(
1012                val.as_str()
1013                    .ok_or_else(|| ParseError::Rule {
1014                        rule: kwarg.to_string(),
1015                        msg: format!("{source}: `on_error` must be a string"),
1016                    })?
1017                    .to_string(),
1018            );
1019            continue;
1020        }
1021        // Reject `plugin:` as a kwarg — the plugin name is the positional
1022        // first argument; allowing both would be ambiguous.
1023        if key == "plugin" {
1024            return Err(ParseError::Rule {
1025                rule: kwarg.to_string(),
1026                msg: format!(
1027                    "{source}: `plugin` is set as the first positional argument \
1028                     of `delegate(...)`; don't pass it as a kwarg too"
1029                ),
1030            });
1031        }
1032        let value = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
1033            rule: kwarg.to_string(),
1034            msg: format!("{source}: `{key}`: {msg}"),
1035        })?;
1036        config_map.insert(serde_yaml::Value::String(key.to_string()), value);
1037    }
1038
1039    let config_override = if config_map.is_empty() {
1040        None
1041    } else {
1042        Some(serde_yaml::Value::Mapping(config_map))
1043    };
1044
1045    Ok(ParsedDelegateCall {
1046        plugin_name,
1047        config_override,
1048        on_error,
1049    })
1050}
1051
1052/// Split a `key: value, key: value` string on TOP-LEVEL commas only —
1053/// commas inside `[...]` or quoted strings are preserved as part of
1054/// the surrounding value. Returns the comma-separated pieces (trimmed
1055/// at boundaries; whitespace inside values preserved).
1056///
1057/// Errors on unmatched brackets / unterminated quotes — those produce
1058/// confusing downstream errors otherwise.
1059fn split_top_level_commas(input: &str) -> Result<Vec<String>, String> {
1060    let mut parts = Vec::new();
1061    let mut current = String::new();
1062    let mut bracket_depth: usize = 0;
1063    let mut quote: Option<char> = None;
1064    let mut escape = false;
1065
1066    for ch in input.chars() {
1067        if escape {
1068            current.push(ch);
1069            escape = false;
1070            continue;
1071        }
1072        if let Some(q) = quote {
1073            current.push(ch);
1074            if ch == '\\' {
1075                escape = true;
1076            } else if ch == q {
1077                quote = None;
1078            }
1079            continue;
1080        }
1081        match ch {
1082            '"' | '\'' => {
1083                quote = Some(ch);
1084                current.push(ch);
1085            },
1086            '[' | '(' | '{' => {
1087                bracket_depth += 1;
1088                current.push(ch);
1089            },
1090            ']' | ')' | '}' => {
1091                bracket_depth = bracket_depth
1092                    .checked_sub(1)
1093                    .ok_or_else(|| format!("unmatched `{ch}` in delegate(...) args"))?;
1094                current.push(ch);
1095            },
1096            ',' if bracket_depth == 0 => {
1097                parts.push(std::mem::take(&mut current));
1098            },
1099            _ => current.push(ch),
1100        }
1101    }
1102    if quote.is_some() {
1103        return Err("unterminated quoted string in delegate(...) args".to_string());
1104    }
1105    if bracket_depth != 0 {
1106        return Err("unbalanced brackets in delegate(...) args".to_string());
1107    }
1108    parts.push(current);
1109    Ok(parts)
1110}
1111
1112/// Parse a single value from the function-call form: a scalar
1113/// (string / number / bool) or a list literal `[a, b, c]`. Use the
1114/// map form for anything more complex.
1115fn parse_delegate_value(s: &str) -> Result<serde_yaml::Value, String> {
1116    let trimmed = s.trim();
1117    if trimmed.is_empty() {
1118        return Err("empty value".to_string());
1119    }
1120    // List literal — recursive scalar parse on each element.
1121    if let Some(stripped) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
1122        let items = split_top_level_commas(stripped)?;
1123        let mut out = Vec::with_capacity(items.len());
1124        for item in items {
1125            let item = item.trim();
1126            if item.is_empty() {
1127                continue;
1128            }
1129            out.push(parse_delegate_value(item)?);
1130        }
1131        return Ok(serde_yaml::Value::Sequence(out));
1132    }
1133    // Quoted string — strip the surrounding quotes.
1134    if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2)
1135        || (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2)
1136    {
1137        return Ok(serde_yaml::Value::String(
1138            trimmed[1..trimmed.len() - 1].to_string(),
1139        ));
1140    }
1141    // Bool literals.
1142    if trimmed == "true" {
1143        return Ok(serde_yaml::Value::Bool(true));
1144    }
1145    if trimmed == "false" {
1146        return Ok(serde_yaml::Value::Bool(false));
1147    }
1148    // Numeric literals — integer first, then float.
1149    if let Ok(n) = trimmed.parse::<i64>() {
1150        return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(n)));
1151    }
1152    if let Ok(f) = trimmed.parse::<f64>() {
1153        return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(f)));
1154    }
1155    // Fallback: treat as bare string (e.g. `target: workday-api` →
1156    // value is `workday-api`). Same convention as YAML scalars.
1157    Ok(serde_yaml::Value::String(trimmed.to_string()))
1158}
1159
1160/// Strip a single pair of wrapping `"`/`'` if present. No-op on
1161/// unquoted input. Used for the positional plugin name where the
1162/// operator may have quoted to escape a hyphen or similar (`delegate("workday-oauth")`).
1163fn strip_wrapping_quotes(s: &str) -> &str {
1164    let bytes = s.as_bytes();
1165    if bytes.len() >= 2 {
1166        let first = bytes[0];
1167        let last = bytes[bytes.len() - 1];
1168        if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1169            return &s[1..s.len() - 1];
1170        }
1171    }
1172    s
1173}
1174
1175fn parse_step_map(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
1176    // Canonical structured rule: `- when: X\n  do: Y` (DSL §3.2).
1177    // Detected by the presence of *both* `when` and `do` keys — order
1178    // doesn't matter, and the map can carry extra keys for future
1179    // extensions (e.g. `id:` for rule identifiers).
1180    if has_key(m, "when") && has_key(m, "do") {
1181        return parse_when_do_rule(m, source);
1182    }
1183
1184    if m.len() != 1 {
1185        return Err(ParseError::Rule {
1186            rule: format!("{:?}", m),
1187            msg: "step map must have exactly one key (PDP call signature, \
1188                   `when:`/`do:`, or a `predicate: [effects...]` shorthand)"
1189                .into(),
1190        });
1191    }
1192    let (key_val, body_val) = m.iter().next().unwrap();
1193    let key = key_val.as_str().ok_or_else(|| ParseError::Rule {
1194        rule: format!("{:?}", key_val),
1195        msg: "PDP step key must be a string".into(),
1196    })?;
1197
1198    // Shorthand multi-effect map: `- "predicate": [list]` (DSL §3.1
1199    // multi-effect from one predicate). Detected by a single-key map
1200    // whose value is a YAML sequence. Single-effect map shorthand
1201    // (`- "predicate": deny`) still goes through `parse_step_string`
1202    // via the colon-split, NOT here — by the time we land in this
1203    // function, single-string values have already been resolved by
1204    // the caller's `parse_step` dispatch.
1205    if let serde_yaml::Value::Sequence(items) = body_val {
1206        // Skip PDP keys — `cedar:` / `opa:` etc. have list bodies for
1207        // `on_deny:` / `on_allow:` and need the existing handling.
1208        // Also skip `sequential:` / `parallel:` orchestration keys
1209        // since they take a list body and would otherwise be parsed
1210        // as predicates. The shorthand recognises only predicate-
1211        // shaped keys.
1212        let trimmed = key.trim();
1213        if trimmed != "delegate"
1214            && trimmed != "sequential"
1215            && trimmed != "parallel"
1216            && !is_known_pdp_dialect(trimmed)
1217        {
1218            return parse_shorthand_multi_effect(trimmed, items, source);
1219        }
1220    }
1221
1222    // `delegate:` is a special non-PDP step shape — branch before the
1223    // dialect logic. See `parse_delegate_step` for the expected body.
1224    if key.trim() == "delegate" {
1225        return parse_delegate_step(body_val, source);
1226    }
1227
1228    // E3: top-level `sequential:` / `parallel:` orchestration —
1229    // wrap the resulting Effect into an unconditional Rule so the
1230    // top-level Vec<Step> stays uniform.
1231    match key.trim() {
1232        "sequential" => {
1233            let effect = parse_sequential_effect(body_val, source)?;
1234            return Ok(Step::Rule(Rule {
1235                condition: Expression::Always,
1236                effects: vec![effect],
1237                source: source.to_string(),
1238            }));
1239        },
1240        "parallel" => {
1241            let effect = parse_parallel_effect(body_val, source)?;
1242            return Ok(Step::Rule(Rule {
1243                condition: Expression::Always,
1244                effects: vec![effect],
1245                source: source.to_string(),
1246            }));
1247        },
1248        _ => {},
1249    }
1250
1251    // Split the key into "dialect" + optional "(args)" portion.
1252    let (dialect_str, paren_args) = if let Some(open) = key.find('(') {
1253        let close = key.rfind(')').ok_or_else(|| ParseError::Rule {
1254            rule: key.to_string(),
1255            msg: "missing `)` in PDP call signature".into(),
1256        })?;
1257        let inside = key[open + 1..close].trim().to_string();
1258        (key[..open].trim(), Some(inside))
1259    } else {
1260        (key.trim(), None)
1261    };
1262
1263    let dialect = PdpDialect::from_key(dialect_str);
1264
1265    // Extract args + on_deny/on_allow.
1266    // Cedar: body map carries args fields directly + on_deny/on_allow.
1267    // Others: paren_args carries the call signature; body map is reactions only.
1268    let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
1269        rule: format!("{:?}", body_val),
1270        msg: format!(
1271            "`{}:` body must be a map (with on_deny / on_allow / args)",
1272            key
1273        ),
1274    })?;
1275
1276    let (args, on_deny, on_allow) = extract_pdp_body(body, paren_args.as_deref(), source)?;
1277
1278    Ok(Step::Pdp {
1279        call: PdpCall { dialect, args },
1280        on_deny,
1281        on_allow,
1282    })
1283}
1284
1285/// Parse a `delegate:` step body into a `Step::Delegate`. Accepted
1286/// YAML shape:
1287///
1288/// ```yaml
1289/// - delegate:
1290///     plugin: workday-oauth          # required — TokenDelegateHook plugin name
1291///     config:                         # optional — per-call config override
1292///       target: workday-api
1293///       permissions: [read_compensation]
1294///     on_error: deny                  # optional — deny | continue (default deny)
1295/// ```
1296///
1297// =====================================================================
1298// Effect / when-do parsing (E1)
1299// =====================================================================
1300
1301/// Lookup helper — `serde_yaml::Mapping::contains_key` only matches when
1302/// the search key is a `Value`, so we wrap the string conversion.
1303fn has_key(m: &serde_yaml::Mapping, key: &str) -> bool {
1304    m.contains_key(serde_yaml::Value::String(key.to_string()))
1305}
1306
1307/// Whether a top-level map key is a recognized PDP dialect. Used by
1308/// the shorthand-list detector to avoid mis-parsing a `cedar: [...]`
1309/// reaction list as a predicate-with-effects map.
1310fn is_known_pdp_dialect(key: &str) -> bool {
1311    let base = key.find('(').map(|i| &key[..i]).unwrap_or(key);
1312    matches!(base.trim(), "cedar" | "opa" | "authzen" | "nemo" | "cel")
1313}
1314
1315/// Parse the canonical `- when: X` `do: Y` rule form (DSL §3.2). `Y`
1316/// may be a single effect string (`do: deny`) or a list of effect
1317/// entries (`do: [plugin(audit), taint(X), deny('msg')]`). Map-form
1318/// effects (like a nested `delegate:` block) are allowed inside `do:`
1319/// via the same dispatch as top-level steps.
1320fn parse_when_do_rule(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
1321    // Validate keys — surface a useful error if there's stray content
1322    // beyond `when:` / `do:` (e.g. typo'd `whens:`). `id:` is reserved
1323    // for a future rule-identifier extension; tolerate it as a
1324    // pass-through for now.
1325    for (k, _) in m.iter() {
1326        let key = k.as_str().unwrap_or("");
1327        if !matches!(key, "when" | "do" | "id") {
1328            return Err(ParseError::Rule {
1329                rule: format!("{:?}", m),
1330                msg: format!(
1331                    "unexpected key `{}` in when/do rule (allowed: `when`, `do`, `id`)",
1332                    key
1333                ),
1334            });
1335        }
1336    }
1337
1338    let when_val = m
1339        .get(serde_yaml::Value::String("when".into()))
1340        .expect("has_key verified above");
1341    let predicate = when_val.as_str().ok_or_else(|| ParseError::Rule {
1342        rule: format!("{:?}", when_val),
1343        msg: "`when:` must be a predicate string".into(),
1344    })?;
1345    let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
1346        rule: format!("when: {}", predicate),
1347        msg: format!("{}", e),
1348    })?;
1349
1350    let do_val = m
1351        .get(serde_yaml::Value::String("do".into()))
1352        .expect("has_key verified above");
1353    let effects = parse_do_body(do_val, source)?;
1354    if effects.is_empty() {
1355        return Err(ParseError::Rule {
1356            rule: format!("{:?}", m),
1357            msg: "`do:` produced no effects".into(),
1358        });
1359    }
1360
1361    Ok(Step::Rule(Rule {
1362        condition,
1363        effects,
1364        source: source.to_string(),
1365    }))
1366}
1367
1368/// Parse the shorthand multi-effect map form: `- "predicate": [list]`
1369/// (DSL §3 example at line 386). Equivalent to the canonical
1370/// `when: predicate` `do: [list]` shape, just terser.
1371fn parse_shorthand_multi_effect(
1372    predicate: &str,
1373    effect_list: &[serde_yaml::Value],
1374    source: &str,
1375) -> Result<Step, ParseError> {
1376    let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
1377        rule: predicate.to_string(),
1378        msg: format!("{}", e),
1379    })?;
1380
1381    let mut effects = Vec::with_capacity(effect_list.len());
1382    for item in effect_list {
1383        effects.push(parse_effect_value(item, source)?);
1384    }
1385    if effects.is_empty() {
1386        return Err(ParseError::Rule {
1387            rule: predicate.to_string(),
1388            msg: "shorthand multi-effect map produced no effects".into(),
1389        });
1390    }
1391    Ok(Step::Rule(Rule {
1392        condition,
1393        effects,
1394        source: source.to_string(),
1395    }))
1396}
1397
1398/// Parse a `do:` body — single effect string, list of effects, or a
1399/// single map-shaped effect (`do: { parallel: [...] }`,
1400/// `do: { delegate: {...} }`, etc.).
1401fn parse_do_body(val: &serde_yaml::Value, source: &str) -> Result<Vec<Effect>, ParseError> {
1402    match val {
1403        serde_yaml::Value::String(s) => Ok(vec![parse_effect_string(s, source)?]),
1404        serde_yaml::Value::Sequence(items) => items
1405            .iter()
1406            .map(|item| parse_effect_value(item, source))
1407            .collect(),
1408        serde_yaml::Value::Mapping(_) => {
1409            // Single map-form effect — delegate, sequential, parallel.
1410            // Route through parse_effect_value which dispatches by key.
1411            Ok(vec![parse_effect_value(val, source)?])
1412        },
1413        other => Err(ParseError::Rule {
1414            rule: format!("{:?}", other),
1415            msg: "`do:` value must be a string, a list of effects, or an effect map".into(),
1416        }),
1417    }
1418}
1419
1420/// Parse one effect entry from a YAML value — string form or map form
1421/// (the latter for `delegate:` configs nested inside `do:`,
1422/// `sequential:`, and `parallel:`).
1423fn parse_effect_value(val: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1424    match val {
1425        serde_yaml::Value::String(s) => parse_effect_string(s, source),
1426        serde_yaml::Value::Mapping(m) => {
1427            // E3: `sequential:` / `parallel:` map forms — a single-key
1428            // map whose key is `sequential` / `parallel` and whose
1429            // value is a list of effects.
1430            if m.len() == 1 {
1431                let (k, v) = m.iter().next().unwrap();
1432                if let Some(key_str) = k.as_str() {
1433                    match key_str.trim() {
1434                        "sequential" => return parse_sequential_effect(v, source),
1435                        "parallel" => return parse_parallel_effect(v, source),
1436                        _ => {},
1437                    }
1438                }
1439            }
1440            // Otherwise reuse the existing step-map parser for
1441            // `delegate:`, `cedar:` etc. and collapse the Step.
1442            let step = parse_step(val, source)?;
1443            step_to_effect(step, source)
1444        },
1445        other => Err(ParseError::Rule {
1446            rule: format!("{:?}", other),
1447            msg: "effect entry must be a string or a map".into(),
1448        }),
1449    }
1450}
1451
1452/// Parse a `sequential: [list]` effect value. The body MUST be a list
1453/// (a single effect would defeat the purpose of explicit grouping).
1454fn parse_sequential_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1455    let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
1456        rule: format!("{:?}", body),
1457        msg: "`sequential:` body must be a list of effects".into(),
1458    })?;
1459    if items.is_empty() {
1460        return Err(ParseError::Rule {
1461            rule: format!("{:?}", body),
1462            msg: "`sequential:` body is empty".into(),
1463        });
1464    }
1465    let mut effects = Vec::with_capacity(items.len());
1466    for item in items {
1467        effects.push(parse_effect_value(item, source)?);
1468    }
1469    Ok(Effect::Sequential(effects))
1470}
1471
1472/// Parse a `parallel: [list]` effect value. The body MUST be a list,
1473/// and the parsed Effect is validated for parallel-purity (rejects
1474/// `FieldOp` / `Delegate` nested anywhere underneath).
1475fn parse_parallel_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1476    let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
1477        rule: format!("{:?}", body),
1478        msg: "`parallel:` body must be a list of effects".into(),
1479    })?;
1480    if items.is_empty() {
1481        return Err(ParseError::Rule {
1482            rule: format!("{:?}", body),
1483            msg: "`parallel:` body is empty".into(),
1484        });
1485    }
1486    let mut effects = Vec::with_capacity(items.len());
1487    for item in items {
1488        effects.push(parse_effect_value(item, source)?);
1489    }
1490    let parallel = Effect::Parallel(effects);
1491    parallel
1492        .validate_parallel_purity()
1493        .map_err(|msg| ParseError::Rule {
1494            rule: source.to_string(),
1495            msg,
1496        })?;
1497    Ok(parallel)
1498}
1499
1500/// Parse one effect string. Reuses [`parse_step_string`] for forms
1501/// shared with top-level steps (`plugin(...)`, `taint(...)`,
1502/// `delegate(...)`, predicate-action rules), then collapses the
1503/// resulting Step into an Effect.
1504fn parse_effect_string(s: &str, source: &str) -> Result<Effect, ParseError> {
1505    // Bare `allow` / `deny` / `deny('reason')` / `deny('reason', 'code')`
1506    // are accepted directly — they map to control effects with no
1507    // associated condition. Same parsing as the right-hand side of a
1508    // shorthand `predicate: action` rule.
1509    let trimmed = s.trim();
1510    if let Some(mut effects) = try_bare_action(trimmed) {
1511        if effects.len() == 1 {
1512            return Ok(effects.pop().unwrap());
1513        }
1514    }
1515    if let Some(effect) = try_parse_deny_call(trimmed, s)? {
1516        return Ok(effect);
1517    }
1518    // Content effect — `result.salary | redact`, `args.ssn | mask(4)`,
1519    // etc. Detected by a top-level `|` that splits a dotted path from
1520    // a pipe chain. The pipe is at top level (depth 0); commas /
1521    // parens inside the chain don't get confused.
1522    if let Some(field_op) = try_parse_field_op(trimmed, s)? {
1523        return Ok(field_op);
1524    }
1525    // Everything else (plugin/delegate/taint/rule) routes through the
1526    // step parser; collapse the result.
1527    let step = parse_step_string(s, source)?;
1528    step_to_effect(step, source)
1529}
1530
1531/// Parse `<path> | <stage> [| <stage>...]` into an `Effect::FieldOp`.
1532/// Returns `Ok(None)` when no top-level `|` is found so the caller can
1533/// fall through to other effect handlers.
1534fn try_parse_field_op(s: &str, rule: &str) -> Result<Option<Effect>, ParseError> {
1535    let Some(pipe_idx) = find_top_level_pipe(s) else {
1536        return Ok(None);
1537    };
1538    let path = s[..pipe_idx].trim();
1539    let chain = s[pipe_idx + 1..].trim();
1540    if path.is_empty() || chain.is_empty() {
1541        return Ok(None);
1542    }
1543    // The path must look like a dotted field reference. Anything else
1544    // (e.g. `role.hr | role.security` — though that wouldn't get here
1545    // because predicates don't appear in effect position) is a sign
1546    // the author meant something other than a field op.
1547    if !is_valid_field_path(path) {
1548        return Ok(None);
1549    }
1550    let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
1551        rule: rule.to_string(),
1552        msg: format!("field op `{}`: {}", path, e),
1553    })?;
1554    if pipeline.stages.is_empty() {
1555        return Err(ParseError::Rule {
1556            rule: rule.to_string(),
1557            msg: format!("field op `{}` has no stages", path),
1558        });
1559    }
1560    Ok(Some(Effect::FieldOp {
1561        path: path.to_string(),
1562        stages: pipeline.stages,
1563    }))
1564}
1565
1566/// Find the byte index of the first top-level `|` that isn't part of
1567/// `||` (logical-or inside a predicate). Depth-aware: skips `|` inside
1568/// `(...)` / `[...]` and inside single- or double-quoted strings.
1569fn find_top_level_pipe(s: &str) -> Option<usize> {
1570    let bytes = s.as_bytes();
1571    let mut depth: i32 = 0;
1572    let mut quote: Option<u8> = None;
1573    let mut i = 0;
1574    while i < bytes.len() {
1575        let b = bytes[i];
1576        if let Some(q) = quote {
1577            if b == b'\\' {
1578                i += 2;
1579                continue;
1580            }
1581            if b == q {
1582                quote = None;
1583            }
1584            i += 1;
1585            continue;
1586        }
1587        match b {
1588            b'\'' | b'"' => quote = Some(b),
1589            b'(' | b'[' => depth += 1,
1590            b')' | b']' => depth -= 1,
1591            b'|' if depth == 0 => {
1592                // Skip `||` — never appears in effect strings today
1593                // but defend against it anyway.
1594                if bytes.get(i + 1) == Some(&b'|') {
1595                    i += 2;
1596                    continue;
1597                }
1598                return Some(i);
1599            },
1600            _ => {},
1601        }
1602        i += 1;
1603    }
1604    None
1605}
1606
1607/// A field path is a dotted identifier sequence rooted at `args.` or
1608/// `result.`. Reject anything else early so a stray `role.hr | …` in
1609/// effect position fails fast.
1610fn is_valid_field_path(s: &str) -> bool {
1611    let Some(rest) = s
1612        .strip_prefix("args.")
1613        .or_else(|| s.strip_prefix("result."))
1614    else {
1615        return false;
1616    };
1617    !rest.is_empty()
1618        && rest
1619            .split('.')
1620            .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_alphanumeric() || c == '_'))
1621}
1622
1623/// Collapse a `Step` produced by the legacy step parser into an
1624/// `Effect`. The legitimate inputs are `Plugin`, `Delegate`, `Taint`,
1625/// and `Rule` (when a control action like `deny`/`allow` was parsed).
1626/// Anything else (`Pdp`) is rejected — nested PDP calls inside `do:`
1627/// are out of scope for E1.
1628/// Recursively map a top-level `Step` (as produced by `parse_step`) into
1629/// an `Effect`. Used at compile_apl_blocks during E4 — keeps `parse_step`'s
1630/// internal shape for the moment while the public IR collapses to Effect.
1631/// All five Step variants map cleanly: Rule → When, Pdp → Pdp (recursive
1632/// on reactions), Plugin/Delegate/Taint pass-through.
1633pub(crate) fn step_to_top_level_effect(step: Step) -> Result<Effect, ParseError> {
1634    match step {
1635        Step::Rule(rule) => Ok(Effect::When {
1636            condition: rule.condition,
1637            body: rule.effects,
1638            source: rule.source,
1639        }),
1640        Step::Pdp {
1641            call,
1642            on_allow,
1643            on_deny,
1644        } => {
1645            let on_allow = on_allow
1646                .into_iter()
1647                .map(step_to_top_level_effect)
1648                .collect::<Result<Vec<_>, _>>()?;
1649            let on_deny = on_deny
1650                .into_iter()
1651                .map(step_to_top_level_effect)
1652                .collect::<Result<Vec<_>, _>>()?;
1653            Ok(Effect::Pdp {
1654                call,
1655                on_allow,
1656                on_deny,
1657            })
1658        },
1659        Step::Plugin { name } => Ok(Effect::Plugin { name }),
1660        Step::Delegate(d) => Ok(Effect::Delegate(d)),
1661        Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
1662    }
1663}
1664
1665fn step_to_effect(step: Step, source: &str) -> Result<Effect, ParseError> {
1666    match step {
1667        Step::Plugin { name } => Ok(Effect::Plugin { name }),
1668        Step::Delegate(d) => Ok(Effect::Delegate(d)),
1669        Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
1670        Step::Rule(rule) => {
1671            // Nested when/do inside a do: list isn't supported in E1
1672            // — only control effects (allow/deny) flatten cleanly.
1673            if !matches!(rule.condition, Expression::Always) {
1674                return Err(ParseError::Rule {
1675                    rule: source.to_string(),
1676                    msg: "conditional rules nested inside `do:` are not supported in E1 \
1677                          (use a sibling `when:`/`do:` rule instead)"
1678                        .into(),
1679                });
1680            }
1681            if rule.effects.len() != 1 {
1682                return Err(ParseError::Rule {
1683                    rule: source.to_string(),
1684                    msg: format!(
1685                        "unconditional rule inside `do:` must produce exactly one \
1686                         effect, got {}",
1687                        rule.effects.len()
1688                    ),
1689                });
1690            }
1691            Ok(rule.effects.into_iter().next().unwrap())
1692        },
1693        Step::Pdp { .. } => Err(ParseError::Rule {
1694            rule: source.to_string(),
1695            msg: "PDP calls inside `do:` are not supported in E1 (use a sibling \
1696                  step instead)"
1697                .into(),
1698        }),
1699    }
1700}
1701
1702/// `config:` is opaque — the framework hands it to the named plugin
1703/// via the existing per-call config-override pathway. The plugin
1704/// owns the typed schema (target / audience / permissions / mode /
1705/// attenuation are conventions, not parser-enforced).
1706fn parse_delegate_step(body_val: &serde_yaml::Value, source: &str) -> Result<Step, ParseError> {
1707    let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
1708        rule: source.to_string(),
1709        msg: "`delegate:` body must be a map with `plugin:` and optional \
1710              `config:` / `on_error:`"
1711            .to_string(),
1712    })?;
1713
1714    let plugin = body
1715        .get(serde_yaml::Value::String("plugin".to_string()))
1716        .ok_or_else(|| ParseError::Rule {
1717            rule: source.to_string(),
1718            msg: "`delegate:` requires `plugin: <name>` referencing a \
1719                  top-level plugin registered under `token.delegate`"
1720                .to_string(),
1721        })?;
1722    let plugin_name = plugin
1723        .as_str()
1724        .ok_or_else(|| ParseError::Rule {
1725            rule: source.to_string(),
1726            msg: "`delegate.plugin` must be a string".to_string(),
1727        })?
1728        .to_string();
1729    if plugin_name.is_empty() {
1730        return Err(ParseError::Rule {
1731            rule: source.to_string(),
1732            msg: "`delegate.plugin` cannot be empty".to_string(),
1733        });
1734    }
1735
1736    let config_override = body
1737        .get(serde_yaml::Value::String("config".to_string()))
1738        .cloned();
1739
1740    let on_error = match body.get(serde_yaml::Value::String("on_error".to_string())) {
1741        Some(v) => Some(
1742            v.as_str()
1743                .ok_or_else(|| ParseError::Rule {
1744                    rule: source.to_string(),
1745                    msg: "`delegate.on_error` must be a string (e.g. `deny`, \
1746                          `continue`)"
1747                        .to_string(),
1748                })?
1749                .to_string(),
1750        ),
1751        None => None,
1752    };
1753
1754    Ok(Step::Delegate(DelegateStep {
1755        plugin_name,
1756        config_override,
1757        on_error,
1758        source: source.to_string(),
1759    }))
1760}
1761
1762/// Split a PDP body into (args, on_deny, on_allow).
1763///
1764/// If `paren_args` is `Some`, the call's args are the string inside the
1765/// parens (OPA-style) and the body map only carries reactions. If `None`,
1766/// the body map carries both args and reactions (Cedar-style); we strip
1767/// the reaction keys and treat what's left as args.
1768fn extract_pdp_body(
1769    body: &serde_yaml::Mapping,
1770    paren_args: Option<&str>,
1771    source: &str,
1772) -> Result<(serde_yaml::Value, Vec<Step>, Vec<Step>), ParseError> {
1773    let mut on_deny = Vec::new();
1774    let mut on_allow = Vec::new();
1775    let mut args_map = serde_yaml::Mapping::new();
1776
1777    for (k, v) in body {
1778        match k.as_str() {
1779            Some("on_deny") => {
1780                on_deny = parse_reaction_list(v, source, "on_deny")?;
1781            },
1782            Some("on_allow") => {
1783                on_allow = parse_reaction_list(v, source, "on_allow")?;
1784            },
1785            _ => {
1786                // Non-reaction key — part of args (Cedar-style).
1787                args_map.insert(k.clone(), v.clone());
1788            },
1789        }
1790    }
1791
1792    let args = match paren_args {
1793        Some(s) => serde_yaml::Value::String(s.to_string()),
1794        None => serde_yaml::Value::Mapping(args_map),
1795    };
1796
1797    Ok((args, on_deny, on_allow))
1798}
1799
1800fn parse_reaction_list(
1801    v: &serde_yaml::Value,
1802    source: &str,
1803    which: &str,
1804) -> Result<Vec<Step>, ParseError> {
1805    let list = v.as_sequence().ok_or_else(|| ParseError::Rule {
1806        rule: format!("{:?}", v),
1807        msg: format!("`{}:` must be a list of steps", which),
1808    })?;
1809    list.iter()
1810        .enumerate()
1811        .map(|(i, entry)| parse_step(entry, &format!("{}.{}[{}]", source, which, i)))
1812        .collect()
1813}
1814
1815/// Extract the args inside a call like `taint(X, Y)` or `plugin(foo)`.
1816/// Returns the substring between the outermost matching parens.
1817fn extract_call_args(line: &str, name: &str) -> Option<String> {
1818    let line = line.trim();
1819    if !line.starts_with(name) {
1820        return None;
1821    }
1822    let after = &line[name.len()..];
1823    if !after.starts_with('(') {
1824        return None;
1825    }
1826    // Find the matching close paren.
1827    let bytes = after.as_bytes();
1828    let mut depth = 0;
1829    for (i, &b) in bytes.iter().enumerate() {
1830        match b {
1831            b'(' => depth += 1,
1832            b')' => {
1833                depth -= 1;
1834                if depth == 0 {
1835                    // Anything after the close paren is invalid.
1836                    if after[i + 1..].trim().is_empty() {
1837                        return Some(after[1..i].to_string());
1838                    }
1839                    return None;
1840                }
1841            },
1842            _ => {},
1843        }
1844    }
1845    None
1846}
1847
1848// =====================================================================
1849// Pipe-chain parser (args: / result: field pipelines)
1850// =====================================================================
1851
1852/// Parse a pipe-chain string into a `Pipeline`.
1853///
1854/// Splits on `|` (outside parens/quotes), trims each stage, parses each.
1855/// Empty pipelines (empty string or whitespace) are valid — they produce
1856/// `Pipeline { stages: vec![] }`.
1857pub fn parse_pipeline(src: &str) -> Result<Pipeline, ParseError> {
1858    let mut pipeline = Pipeline::new();
1859    for seg in split_top_level(src.trim(), b'|') {
1860        let seg = seg.trim();
1861        if seg.is_empty() {
1862            continue;
1863        }
1864        pipeline.push(parse_stage(seg)?);
1865    }
1866    Ok(pipeline)
1867}
1868
1869/// Split `s` on `delim` at depth 0 — respects parens and quotes.
1870fn split_top_level(s: &str, delim: u8) -> Vec<&str> {
1871    let bytes = s.as_bytes();
1872    let mut out = Vec::new();
1873    let mut depth: i32 = 0;
1874    let mut in_quote: Option<u8> = None;
1875    let mut start = 0;
1876    for (i, &b) in bytes.iter().enumerate() {
1877        match (in_quote, b) {
1878            (Some(q), c) if c == q => in_quote = None,
1879            (Some(_), _) => {},
1880            (None, b'"') | (None, b'\'') => in_quote = Some(b),
1881            (None, b'(') | (None, b'[') => depth += 1,
1882            (None, b')') | (None, b']') => depth -= 1,
1883            (None, c) if c == delim && depth == 0 => {
1884                out.push(&s[start..i]);
1885                start = i + 1;
1886            },
1887            _ => {},
1888        }
1889    }
1890    out.push(&s[start..]);
1891    out
1892}
1893
1894fn parse_stage(src: &str) -> Result<Stage, ParseError> {
1895    let s = src.trim();
1896    let bad = |msg: &str| ParseError::Predicate {
1897        predicate: src.to_string(),
1898        msg: msg.to_string(),
1899    };
1900
1901    // Bare range literal: starts with `-`, digit, or `..`.
1902    if let Some(stage) = try_parse_range(s) {
1903        return Ok(stage);
1904    }
1905
1906    // Otherwise the stage starts with an identifier (keyword) optionally
1907    // followed by `(args)`.
1908    let (head, args) = split_head_args(s).ok_or_else(|| bad("expected stage identifier"))?;
1909
1910    match (head, args.as_deref()) {
1911        // ----- Bare validators / transforms / effects -----
1912        ("str", None) => Ok(Stage::Type(TypeCheck::Str)),
1913        ("int", None) => Ok(Stage::Type(TypeCheck::Int)),
1914        ("bool", None) => Ok(Stage::Type(TypeCheck::Bool)),
1915        ("float", None) => Ok(Stage::Type(TypeCheck::Float)),
1916        ("email", None) => Ok(Stage::Type(TypeCheck::Email)),
1917        ("url", None) => Ok(Stage::Type(TypeCheck::Url)),
1918        ("uuid", None) => Ok(Stage::Type(TypeCheck::Uuid)),
1919        ("redact", None) => Ok(Stage::Redact { condition: None }),
1920        ("omit", None) => Ok(Stage::Omit),
1921        ("hash", None) => Ok(Stage::Hash),
1922        // Scan placeholders parse as bare identifiers (DSL §4.5).
1923        ("pii.redact", None) => Ok(Stage::Scan {
1924            kind: ScanKind::PiiRedact,
1925        }),
1926        ("pii.detect", None) => Ok(Stage::Scan {
1927            kind: ScanKind::PiiDetect,
1928        }),
1929        ("injection.scan", None) => Ok(Stage::Scan {
1930            kind: ScanKind::InjectionScan,
1931        }),
1932
1933        // ----- Parameterized -----
1934        ("mask", Some(a)) => {
1935            let n: usize = a
1936                .trim()
1937                .parse()
1938                .map_err(|_| bad(&format!("mask(N) expects integer, got `{}`", a)))?;
1939            Ok(Stage::Mask { keep_last: n })
1940        },
1941        ("redact", Some(a)) => {
1942            // redact(!perm.view_ssn) — argument is a predicate expression.
1943            let cond = parse_predicate(a).map_err(|e| ParseError::Predicate {
1944                predicate: src.to_string(),
1945                msg: format!("invalid redact() condition: {}", e),
1946            })?;
1947            Ok(Stage::Redact {
1948                condition: Some(cond),
1949            })
1950        },
1951        ("hash", Some(_)) => Err(bad("hash takes no arguments")),
1952        ("omit", Some(_)) => Err(bad(
1953            "omit takes no arguments — for conditional omit, use a policy rule predicate",
1954        )),
1955        ("len", Some(a)) => {
1956            let (min, max) = parse_range_inner(a)
1957                .ok_or_else(|| bad(&format!("len(...) expects N..M range, got `{}`", a)))?;
1958            let to_usize = |v: i64| -> Result<usize, ParseError> {
1959                if v < 0 {
1960                    Err(bad("len bounds must be non-negative"))
1961                } else {
1962                    Ok(v as usize)
1963                }
1964            };
1965            Ok(Stage::Length {
1966                min: min.map(to_usize).transpose()?,
1967                max: max.map(to_usize).transpose()?,
1968            })
1969        },
1970        ("enum", Some(a)) => {
1971            let values = split_top_level(a, b',')
1972                .into_iter()
1973                .map(|v| {
1974                    let t = v.trim();
1975                    // Allow either bare identifier or quoted string.
1976                    if (t.starts_with('"') && t.ends_with('"'))
1977                        || (t.starts_with('\'') && t.ends_with('\''))
1978                    {
1979                        t[1..t.len() - 1].to_string()
1980                    } else {
1981                        t.to_string()
1982                    }
1983                })
1984                .filter(|s| !s.is_empty())
1985                .collect::<Vec<_>>();
1986            if values.is_empty() {
1987                return Err(bad("enum() requires at least one value"));
1988            }
1989            Ok(Stage::Enum { values })
1990        },
1991        ("regex", Some(a)) => {
1992            let pattern = a.trim();
1993            let pat = if (pattern.starts_with('"') && pattern.ends_with('"'))
1994                || (pattern.starts_with('\'') && pattern.ends_with('\''))
1995            {
1996                pattern[1..pattern.len() - 1].to_string()
1997            } else {
1998                pattern.to_string()
1999            };
2000            Ok(Stage::Regex { pattern: pat })
2001        },
2002        ("validate", Some(a)) => {
2003            // Named-validator dispatch (`validate(name)`) is in the
2004            // spec (DSL §4.2) but not implemented in this build —
2005            // the evaluator's no-op stub would silently let invalid
2006            // values through. Reject at compile time so operators
2007            // notice immediately and reach for one of the working
2008            // alternatives:
2009            //
2010            //   * `regex("pattern")` — inline named-regex equivalent
2011            //   * `plugin(name)` — full plugin dispatch for rich
2012            //     validation (Luhn, format-with-context, etc.)
2013            //
2014            // When the ValidatorRegistry slice lands, this arm flips
2015            // back to returning `Stage::Validate { name }`.
2016            Err(bad(&format!(
2017                "`validate({})` — named-validator dispatch is not implemented \
2018                 in this build. Use `regex(\"pattern\")` for a named-regex \
2019                 equivalent, or `plugin({})` for richer validation logic.",
2020                a.trim(),
2021                a.trim(),
2022            )))
2023        },
2024        // `run` is an alias for `plugin` (mirrors the policy-step alias).
2025        ("plugin" | "run", Some(a)) => {
2026            let name = a.trim();
2027            if name.is_empty() {
2028                // Mirror the empty-name guard in `parse_step_string` so
2029                // both the policy-step and field-stage paths reject a
2030                // nameless `plugin()` / `run()` with the same diagnostic.
2031                return Err(bad(&format!(
2032                    "`{head}(...)`: plugin name must not be empty"
2033                )));
2034            }
2035            Ok(Stage::Plugin {
2036                name: name.to_string(),
2037            })
2038        },
2039        ("taint", Some(a)) => parse_taint(a, src),
2040
2041        (other, _) => Err(bad(&format!("unknown stage `{}`", other))),
2042    }
2043}
2044
2045/// Try to parse `s` as a bare range literal: `0..100`, `..500`, `0..`, `0..1M`.
2046fn try_parse_range(s: &str) -> Option<Stage> {
2047    if !s.contains("..") {
2048        return None;
2049    }
2050    // Quick reject: must not start with a letter (would be a keyword).
2051    let first = s.as_bytes().first().copied()?;
2052    if first.is_ascii_alphabetic() || first == b'_' {
2053        return None;
2054    }
2055    let (min, max) = parse_range_inner(s)?;
2056    Some(Stage::Range { min, max })
2057}
2058
2059/// Parse the inside of a range expression: `N..M`, `..M`, `N..`.
2060/// Returns `Some((min, max))` if shape is valid; `None` if it's not a range.
2061fn parse_range_inner(s: &str) -> Option<(Option<i64>, Option<i64>)> {
2062    let dotdot = s.find("..")?;
2063    let left = s[..dotdot].trim();
2064    let right = s[dotdot + 2..].trim();
2065    let min = if left.is_empty() {
2066        None
2067    } else {
2068        Some(parse_numeric_with_suffix(left)?)
2069    };
2070    let max = if right.is_empty() {
2071        None
2072    } else {
2073        Some(parse_numeric_with_suffix(right)?)
2074    };
2075    if min.is_none() && max.is_none() {
2076        return None; // `..` alone isn't a useful range
2077    }
2078    Some((min, max))
2079}
2080
2081/// Parse a number with optional `k/K` (×1000) or `m/M` (×1_000_000) suffix.
2082fn parse_numeric_with_suffix(s: &str) -> Option<i64> {
2083    let s = s.trim();
2084    if s.is_empty() {
2085        return None;
2086    }
2087    let (num_part, mult) = match s.as_bytes().last().copied()? {
2088        b'k' | b'K' => (&s[..s.len() - 1], 1_000_i64),
2089        b'm' | b'M' => (&s[..s.len() - 1], 1_000_000_i64),
2090        _ => (s, 1_i64),
2091    };
2092    let n: i64 = num_part.parse().ok()?;
2093    n.checked_mul(mult)
2094}
2095
2096/// Split `s` (a stage form like `mask(4)`) into `(head, Some(args_inside_parens))`
2097/// or `(head, None)` if there are no parens.
2098fn split_head_args(s: &str) -> Option<(&str, Option<String>)> {
2099    if let Some(open) = s.find('(') {
2100        // Match the corresponding closing paren at depth 0.
2101        let bytes = s.as_bytes();
2102        let mut depth = 0;
2103        let mut close = None;
2104        for (i, &b) in bytes.iter().enumerate().skip(open) {
2105            match b {
2106                b'(' => depth += 1,
2107                b')' => {
2108                    depth -= 1;
2109                    if depth == 0 {
2110                        close = Some(i);
2111                        break;
2112                    }
2113                },
2114                _ => {},
2115            }
2116        }
2117        let close = close?;
2118        let head = s[..open].trim();
2119        if head.is_empty() {
2120            return None;
2121        }
2122        let args = s[open + 1..close].to_string();
2123        // Reject trailing garbage after the closing paren.
2124        if s[close + 1..].trim().is_empty() {
2125            Some((head, Some(args)))
2126        } else {
2127            None
2128        }
2129    } else {
2130        let head = s.trim();
2131        if head.is_empty() {
2132            None
2133        } else {
2134            Some((head, None))
2135        }
2136    }
2137}
2138
2139fn parse_taint(args: &str, src: &str) -> Result<Stage, ParseError> {
2140    // taint(label) | taint(label, session) | taint(label, [session, message])
2141    let parts = split_top_level(args, b',');
2142    if parts.is_empty() {
2143        return Err(ParseError::Predicate {
2144            predicate: src.to_string(),
2145            msg: "taint() requires at least a label".into(),
2146        });
2147    }
2148    let label = parts[0].trim().to_string();
2149    if label.is_empty() {
2150        return Err(ParseError::Predicate {
2151            predicate: src.to_string(),
2152            msg: "taint label must not be empty".into(),
2153        });
2154    }
2155
2156    let scopes = if parts.len() == 1 {
2157        vec![TaintScope::Session] // default per DSL §4.6
2158    } else {
2159        let scope_arg = parts[1..].join(",");
2160        let scope_arg = scope_arg.trim();
2161        if scope_arg.starts_with('[') && scope_arg.ends_with(']') {
2162            split_top_level(&scope_arg[1..scope_arg.len() - 1], b',')
2163                .into_iter()
2164                .map(|s| parse_taint_scope(s.trim(), src))
2165                .collect::<Result<Vec<_>, _>>()?
2166        } else {
2167            vec![parse_taint_scope(scope_arg, src)?]
2168        }
2169    };
2170
2171    Ok(Stage::Taint { label, scopes })
2172}
2173
2174fn parse_taint_scope(s: &str, src: &str) -> Result<TaintScope, ParseError> {
2175    match s {
2176        "session" => Ok(TaintScope::Session),
2177        "message" => Ok(TaintScope::Message),
2178        other => Err(ParseError::Predicate {
2179            predicate: src.to_string(),
2180            msg: format!(
2181                "unknown taint scope `{}` (expected `session` or `message`)",
2182                other
2183            ),
2184        }),
2185    }
2186}
2187
2188// =====================================================================
2189// YAML config
2190// =====================================================================
2191
2192/// Top-level config — only the bits step 5a understands.
2193///
2194/// `policy_evaluator:`, `imports:`, `global:`, `defaults:`, `tags:`,
2195/// `plugin_dirs:`, `plugin_settings:`, `version:` are all accepted and
2196/// stored opaquely; this struct deserializes leniently.
2197///
2198/// `plugins:` (the root block) is parsed into [`PluginDeclaration`]s so
2199/// the runtime can look up hook names + capabilities per plugin without
2200/// going back to the raw YAML.
2201#[derive(Debug, Default, Deserialize)]
2202pub struct ConfigYaml {
2203    #[serde(default)]
2204    pub routes: HashMap<String, RouteYaml>,
2205
2206    /// Root `plugins:` block — full declarations.
2207    #[serde(default)]
2208    pub plugins: Vec<PluginDeclaration>,
2209
2210    /// Anything else top-level goes here — picked up by later steps.
2211    #[serde(flatten)]
2212    pub other: HashMap<String, serde_yaml::Value>,
2213}
2214
2215#[derive(Debug, Default, Deserialize)]
2216pub struct RouteYaml {
2217    /// Flat pre-invocation authorization effects (was `policy:`). Each
2218    /// entry is either a string (rule / plugin / taint) or a single-key
2219    /// map (PDP call with reactions). See `parse_step`. Merged with any
2220    /// `authorization.pre_invocation` entries.
2221    #[serde(default)]
2222    pub pre_invocation: Vec<serde_yaml::Value>,
2223
2224    /// Flat post-invocation authorization effects (was `post_policy:`).
2225    /// Merged with any `authorization.post_invocation` entries.
2226    #[serde(default)]
2227    pub post_invocation: Vec<serde_yaml::Value>,
2228
2229    /// Nested `authorization:` block — `{ pre_invocation, post_invocation }`.
2230    /// Equivalent to the flat forms; entries from both are concatenated.
2231    #[serde(default)]
2232    pub authorization: Option<AuthorizationYaml>,
2233
2234    /// `args:` field → pipe-chain string. Compiled to per-field pipelines.
2235    #[serde(default)]
2236    pub args: HashMap<String, String>,
2237
2238    /// `result:` field → pipe-chain string. Compiled to per-field pipelines.
2239    #[serde(default)]
2240    pub result: HashMap<String, String>,
2241
2242    /// Per-route plugin overrides — only the spec-overridable keys
2243    /// (config / capabilities / on_error). Merged on top of the root
2244    /// `plugins:` declaration at dispatch time.
2245    #[serde(default)]
2246    pub plugins: HashMap<String, PluginOverride>,
2247
2248    /// Anything else on the route (meta, taint, when) — stashed. Also
2249    /// where renamed legacy keys land; `reject_legacy_keys` fails loudly
2250    /// on them so a dropped authz block never fails open.
2251    #[serde(flatten)]
2252    pub other: HashMap<String, serde_yaml::Value>,
2253}
2254
2255/// Nested `authorization:` block. Both sub-lists are optional and default
2256/// to empty; each is compiled the same way as the flat `pre_invocation:` /
2257/// `post_invocation:` forms.
2258///
2259/// `deny_unknown_fields` is load-bearing: without it a legacy key nested
2260/// under the wrapper (`authorization: { policy: [...] }`) would be silently
2261/// dropped by serde — both lists empty, no error, no authorization enforced
2262/// (a fail-open). The top-level `reject_legacy_keys` can't catch it because
2263/// the key is consumed as part of the `authorization` value and never lands
2264/// in `RouteYaml.other`. Denying unknown fields turns that into a load error
2265/// and also catches typos like `pre_invocaton:`. Safe here because the struct
2266/// has no `#[serde(flatten)]`.
2267#[derive(Debug, Default, Deserialize)]
2268#[serde(deny_unknown_fields)]
2269pub struct AuthorizationYaml {
2270    #[serde(default)]
2271    pub pre_invocation: Vec<serde_yaml::Value>,
2272
2273    #[serde(default)]
2274    pub post_invocation: Vec<serde_yaml::Value>,
2275}
2276
2277/// Output of [`compile_config`] — the routes that have APL blocks plus
2278/// the registry of plugin declarations from the root `plugins:` block.
2279///
2280/// The two travel together because the evaluator needs both: the route
2281/// gives it the steps to run, and the registry gives the dispatcher the
2282/// hook name / kind for each plugin name referenced by those steps.
2283#[derive(Debug, Default)]
2284pub struct CompiledConfig {
2285    pub routes: HashMap<String, CompiledRoute>,
2286    pub plugins: PluginRegistry,
2287}
2288
2289/// Compile a YAML config into a [`CompiledConfig`] (routes + plugin
2290/// registry).
2291///
2292/// Routes with no APL fields populated (no `authorization:` /
2293/// `pre_invocation:` / `post_invocation:` / `args:` / `result:`) are
2294/// **omitted from `routes`**, per apl-design §5
2295/// "Routes without APL blocks fall back to legacy plugin-chain execution."
2296/// A route-level `plugins:` override block alone is not enough — overrides
2297/// only have meaning when the route actually dispatches plugins via APL
2298/// steps, so an override-only route is treated as legacy.
2299pub fn compile_config(yaml: &str) -> Result<CompiledConfig, ParseError> {
2300    let cfg: ConfigYaml = serde_yaml::from_str(yaml)?;
2301    let mut routes = HashMap::with_capacity(cfg.routes.len());
2302    for (route_key, raw) in cfg.routes {
2303        if let Some(route) = compile_route(&route_key, raw)? {
2304            routes.insert(route_key, route);
2305        }
2306    }
2307    let mut plugins = PluginRegistry::with_capacity(cfg.plugins.len());
2308    for decl in cfg.plugins {
2309        // Duplicate plugin names: last-one-wins for v0. The spec doesn't
2310        // currently prescribe an error here; flag if real configs hit it.
2311        plugins.insert(decl.name.clone(), decl);
2312    }
2313    Ok(CompiledConfig { routes, plugins })
2314}
2315
2316/// Legacy field names, mapped to their replacements. Because unknown keys
2317/// land in `RouteYaml.other` via `#[serde(flatten)]`, a config still using
2318/// an old name would otherwise be *silently dropped* — dropping a `policy:`
2319/// block fails open (no authorization enforced). We reject them loudly
2320/// instead. `identity` is renamed in cpex-core config, not here.
2321const RENAMED_FIELDS: [(&str, &str); 2] = [
2322    (
2323        "policy",
2324        "authorization.pre_invocation (or flat pre_invocation)",
2325    ),
2326    (
2327        "post_policy",
2328        "authorization.post_invocation (or flat post_invocation)",
2329    ),
2330];
2331
2332/// Fail loudly if a stashed key is a renamed legacy field, so a dropped
2333/// authz block never fails open. Run before the has-APL gate.
2334fn reject_legacy_keys(
2335    location: &str,
2336    other: &HashMap<String, serde_yaml::Value>,
2337) -> Result<(), ParseError> {
2338    for (old, new) in RENAMED_FIELDS {
2339        if other.contains_key(old) {
2340            return Err(ParseError::RenamedField {
2341                location: location.to_string(),
2342                old: old.to_string(),
2343                new: new.to_string(),
2344            });
2345        }
2346    }
2347    Ok(())
2348}
2349
2350fn compile_route(route_key: &str, raw: RouteYaml) -> Result<Option<CompiledRoute>, ParseError> {
2351    // Reject legacy keys *before* the gate: a legacy-only route would
2352    // otherwise look empty and be silently omitted (fail open).
2353    reject_legacy_keys(route_key, &raw.other)?;
2354    let has_authz = raw
2355        .authorization
2356        .as_ref()
2357        .is_some_and(|a| !a.pre_invocation.is_empty() || !a.post_invocation.is_empty());
2358    let has_apl = !raw.pre_invocation.is_empty()
2359        || !raw.post_invocation.is_empty()
2360        || has_authz
2361        || !raw.args.is_empty()
2362        || !raw.result.is_empty();
2363    if !has_apl {
2364        return Ok(None);
2365    }
2366    Ok(Some(compile_apl_blocks(route_key, raw)?))
2367}
2368
2369/// Compile the APL bodies (authorization/args/result/plugins) of a
2370/// single block into a `CompiledRoute`. Doesn't gate on "has any APL
2371/// fields" — callers that need the gate (compile_config) check first.
2372/// `source` is the path prefix baked into rule/pipeline diagnostics
2373/// (e.g. `"global.policy.all"`, `"route.get_compensation"`).
2374///
2375/// The nested `authorization:` block and the flat `pre_invocation:` /
2376/// `post_invocation:` forms are equivalent alternatives. Declaring the same
2377/// phase in both forms on one section is rejected (it would run the effects
2378/// twice); only one form may carry a given phase per section.
2379fn compile_apl_blocks(source: &str, raw: RouteYaml) -> Result<CompiledRoute, ParseError> {
2380    reject_legacy_keys(source, &raw.other)?;
2381    let mut route = CompiledRoute::new(source);
2382    let (auth_pre, auth_post) = raw
2383        .authorization
2384        .map(|a| (a.pre_invocation, a.post_invocation))
2385        .unwrap_or_default();
2386    // Reject declaring the same phase both nested and flat on one section:
2387    // the two forms are alternatives, not additive, so merging them would
2388    // run each effect twice (a `run(...)` / `delegate(...)` double-fire).
2389    // Stacking across *different* scopes (e.g. global nested + route flat)
2390    // is fine — those are separate `compile_apl_blocks` calls.
2391    if !auth_pre.is_empty() && !raw.pre_invocation.is_empty() {
2392        return Err(ParseError::ConflictingAuthorizationForms {
2393            location: source.to_string(),
2394            phase: "pre_invocation".to_string(),
2395        });
2396    }
2397    if !auth_post.is_empty() && !raw.post_invocation.is_empty() {
2398        return Err(ParseError::ConflictingAuthorizationForms {
2399            location: source.to_string(),
2400            phase: "post_invocation".to_string(),
2401        });
2402    }
2403    for (i, entry) in auth_pre.iter().chain(raw.pre_invocation.iter()).enumerate() {
2404        let step = parse_step(entry, &format!("{}.pre_invocation[{}]", source, i))?;
2405        route.policy.push(step_to_top_level_effect(step)?);
2406    }
2407    for (i, entry) in auth_post
2408        .iter()
2409        .chain(raw.post_invocation.iter())
2410        .enumerate()
2411    {
2412        let step = parse_step(entry, &format!("{}.post_invocation[{}]", source, i))?;
2413        route.post_policy.push(step_to_top_level_effect(step)?);
2414    }
2415    for (field, chain) in &raw.args {
2416        let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
2417            rule: format!("args.{}: {:?}", field, chain),
2418            msg: format!("{}", e),
2419        })?;
2420        route.args.push(FieldRule {
2421            field: field.clone(),
2422            pipeline,
2423            source: format!("{}.args.{}", source, field),
2424        });
2425    }
2426    for (field, chain) in &raw.result {
2427        let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
2428            rule: format!("result.{}: {:?}", field, chain),
2429            msg: format!("{}", e),
2430        })?;
2431        route.result.push(FieldRule {
2432            field: field.clone(),
2433            pipeline,
2434            source: format!("{}.result.{}", source, field),
2435        });
2436    }
2437    route.plugin_overrides = raw.plugins;
2438    Ok(route)
2439}
2440
2441/// Compile a single APL policy block from a `serde_yaml::Value` whose
2442/// shape is the body of a route's `apl:` block:
2443///
2444/// ```yaml
2445/// args:
2446///   employee_id: "str"
2447/// authorization:
2448///   pre_invocation:
2449///     - "require(authenticated)"
2450///   post_invocation:
2451///     - "taint(forward)"
2452/// result:
2453///   ssn: "redact(!perm.view_ssn)"
2454/// ```
2455///
2456/// Used by external orchestrators (apl-cpex's `AplConfigVisitor`) that
2457/// have already located an APL block inside a larger unified-config
2458/// YAML. `source` is woven into per-rule / per-pipeline diagnostic paths.
2459/// Returns an empty `CompiledRoute` when the value is null or contains
2460/// no APL fields — callers that want a "is this empty?" gate can check
2461/// `declared_phases().is_empty()` on the result.
2462pub fn compile_policy_block_value(
2463    source: &str,
2464    block: &serde_yaml::Value,
2465) -> Result<CompiledRoute, ParseError> {
2466    if block.is_null() {
2467        return Ok(CompiledRoute::new(source));
2468    }
2469    let raw: RouteYaml = serde_yaml::from_value(block.clone())?;
2470    compile_apl_blocks(source, raw)
2471}
2472
2473// =====================================================================
2474// Tests
2475// =====================================================================
2476
2477#[cfg(test)]
2478mod tests {
2479    use super::*;
2480    use crate::attributes::AttributeBag;
2481    use crate::evaluator::Decision;
2482
2483    // ----- Lexer -----
2484
2485    #[test]
2486    fn lex_basic() {
2487        let toks = Lexer::new("delegation.depth > 2").tokenize_all().unwrap();
2488        assert_eq!(
2489            toks,
2490            vec![
2491                Tok::Ident("delegation.depth".into()),
2492                Tok::Gt,
2493                Tok::IntLit(2),
2494            ]
2495        );
2496    }
2497
2498    #[test]
2499    fn lex_strings_both_quotes() {
2500        let a = Lexer::new(r#""double""#).tokenize_all().unwrap();
2501        let b = Lexer::new(r#"'single'"#).tokenize_all().unwrap();
2502        assert_eq!(a, vec![Tok::StringLit("double".into())]);
2503        assert_eq!(b, vec![Tok::StringLit("single".into())]);
2504    }
2505
2506    #[test]
2507    fn lex_keywords_vs_idents() {
2508        let toks = Lexer::new("require(role.hr) & authenticated")
2509            .tokenize_all()
2510            .unwrap();
2511        assert_eq!(
2512            toks,
2513            vec![
2514                Tok::Require,
2515                Tok::LParen,
2516                Tok::Ident("role.hr".into()),
2517                Tok::RParen,
2518                Tok::And,
2519                Tok::Ident("authenticated".into()),
2520            ]
2521        );
2522    }
2523
2524    #[test]
2525    fn lex_rejects_single_equals() {
2526        let err = Lexer::new("a = 1").tokenize_all().unwrap_err();
2527        assert!(format!("{}", err).contains("expected `==`"));
2528    }
2529
2530    // ----- Predicate parser -----
2531
2532    #[test]
2533    fn pred_bare_identifier() {
2534        let e = parse_predicate("authenticated").unwrap();
2535        assert_eq!(
2536            e,
2537            Expression::Condition(Condition::IsTrue {
2538                key: "authenticated".into()
2539            })
2540        );
2541    }
2542
2543    #[test]
2544    fn pred_comparison() {
2545        let e = parse_predicate("delegation.depth > 2").unwrap();
2546        assert_eq!(
2547            e,
2548            Expression::Condition(Condition::Comparison {
2549                key: "delegation.depth".into(),
2550                op: CompareOp::Gt,
2551                value: Literal::Int(2),
2552            })
2553        );
2554    }
2555
2556    #[test]
2557    fn pred_contains() {
2558        let e = parse_predicate(r#"session.labels contains "PII""#).unwrap();
2559        assert_eq!(
2560            e,
2561            Expression::Condition(Condition::Comparison {
2562                key: "session.labels".into(),
2563                op: CompareOp::Contains,
2564                value: Literal::String("PII".into()),
2565            })
2566        );
2567    }
2568
2569    #[test]
2570    fn pred_precedence_or_lowest_and_middle_not_highest() {
2571        // `!a & b | c` should parse as `(!a & b) | c`.
2572        let e = parse_predicate("!a & b | c").unwrap();
2573        match e {
2574            Expression::Or(parts) => {
2575                assert_eq!(parts.len(), 2);
2576                match &parts[0] {
2577                    Expression::And(_) => {},
2578                    other => panic!("first OR branch should be AND, got {:?}", other),
2579                }
2580            },
2581            other => panic!("top-level should be OR, got {:?}", other),
2582        }
2583    }
2584
2585    #[test]
2586    fn pred_parens_override_precedence() {
2587        // `(role.finance | role.admin) & !delegated` from DSL §2.5.
2588        let e = parse_predicate("(role.finance | role.admin) & !delegated").unwrap();
2589        match e {
2590            Expression::And(parts) => {
2591                assert_eq!(parts.len(), 2);
2592                matches!(parts[0], Expression::Or(_));
2593                matches!(parts[1], Expression::Not(_));
2594            },
2595            other => panic!("expected top-level AND, got {:?}", other),
2596        }
2597    }
2598
2599    #[test]
2600    fn pred_require_rejected_as_predicate() {
2601        // require() is a rule-level shorthand per DSL §8, not a sub-predicate.
2602        // Trying to use it inside a predicate expression must fail clearly.
2603        let err = parse_predicate("require(authenticated)").unwrap_err();
2604        assert!(format!("{}", err).contains("rule-level shorthand"));
2605    }
2606
2607    #[test]
2608    fn rule_require_single_arg_desugars_to_isfalse_and_deny() {
2609        // require(X)  →  Rule { condition: IsFalse(X), action: Deny }   (DSL §8.1)
2610        let r = parse_rule("require(authenticated)", "test").unwrap();
2611        assert!(matches!(
2612            r.effects.as_slice(),
2613            [Effect::Deny {
2614                reason: None,
2615                code: None
2616            }]
2617        ));
2618        assert_eq!(
2619            r.condition,
2620            Expression::Condition(Condition::IsFalse {
2621                key: "authenticated".into()
2622            }),
2623        );
2624    }
2625
2626    #[test]
2627    fn rule_require_comma_is_and_desugars_to_or_of_isfalse() {
2628        // require(X, Y)  →  Or([IsFalse(X), IsFalse(Y)]) + Deny   (DSL §8.1)
2629        // i.e., "deny if any are falsy" = "any are falsy → deny"
2630        let r = parse_rule("require(role.hr, perm.view_ssn)", "test").unwrap();
2631        assert_eq!(
2632            r.condition,
2633            Expression::Or(vec![
2634                Expression::Condition(Condition::IsFalse {
2635                    key: "role.hr".into()
2636                }),
2637                Expression::Condition(Condition::IsFalse {
2638                    key: "perm.view_ssn".into()
2639                }),
2640            ]),
2641        );
2642    }
2643
2644    #[test]
2645    fn rule_require_pipe_is_or_desugars_to_and_of_isfalse() {
2646        // require(X | Y)  →  And([IsFalse(X), IsFalse(Y)]) + Deny   (DSL §8.1)
2647        // i.e., "deny only if all are falsy" = "all are falsy → deny"
2648        let r = parse_rule("require(role.finance | role.admin)", "test").unwrap();
2649        assert_eq!(
2650            r.condition,
2651            Expression::And(vec![
2652                Expression::Condition(Condition::IsFalse {
2653                    key: "role.finance".into()
2654                }),
2655                Expression::Condition(Condition::IsFalse {
2656                    key: "role.admin".into()
2657                }),
2658            ]),
2659        );
2660    }
2661
2662    #[test]
2663    fn rule_require_mixed_rejected() {
2664        let err = parse_rule("require(a, b | c)", "test").unwrap_err();
2665        assert!(format!("{}", err).contains("cannot mix"));
2666    }
2667
2668    #[test]
2669    fn pred_eq_with_ident_rhs_rejected_with_in_hint() {
2670        // `subject.type == allowed_types` — `==` doesn't take an ident RHS,
2671        // and the error should hint at `in` for set membership.
2672        let err = parse_predicate("subject.type == allowed_types").unwrap_err();
2673        let msg = format!("{}", err);
2674        assert!(msg.contains("RHS-as-identifier"));
2675        assert!(msg.contains("set membership use"));
2676    }
2677
2678    #[test]
2679    fn pred_in_set_basic() {
2680        let e = parse_predicate("subject.type in allowed_types").unwrap();
2681        assert_eq!(
2682            e,
2683            Expression::Condition(Condition::InSet {
2684                value_key: "subject.type".into(),
2685                set_key: "allowed_types".into(),
2686                negate: false,
2687            }),
2688        );
2689    }
2690
2691    #[test]
2692    fn pred_not_in_set() {
2693        let e = parse_predicate("subject.type not in blocked_types").unwrap();
2694        assert_eq!(
2695            e,
2696            Expression::Condition(Condition::InSet {
2697                value_key: "subject.type".into(),
2698                set_key: "blocked_types".into(),
2699                negate: true,
2700            }),
2701        );
2702    }
2703
2704    #[test]
2705    fn pred_exists_basic() {
2706        let e = parse_predicate("exists(args.amount)").unwrap();
2707        assert_eq!(
2708            e,
2709            Expression::Condition(Condition::Exists {
2710                key: "args.amount".into()
2711            }),
2712        );
2713    }
2714
2715    #[test]
2716    fn pred_exists_inside_compound() {
2717        // exists() is a sub-predicate (unlike require) — can nest in & / |.
2718        let e = parse_predicate("exists(args.amount) & args.amount > 0").unwrap();
2719        match e {
2720            Expression::And(parts) => {
2721                assert_eq!(parts.len(), 2);
2722                assert_eq!(
2723                    parts[0],
2724                    Expression::Condition(Condition::Exists {
2725                        key: "args.amount".into()
2726                    }),
2727                );
2728            },
2729            other => panic!("expected And, got {:?}", other),
2730        }
2731    }
2732
2733    #[test]
2734    fn pred_exists_requires_paren_and_ident() {
2735        assert!(parse_predicate("exists").is_err());
2736        assert!(parse_predicate("exists()").is_err());
2737        assert!(parse_predicate("exists(authenticated").is_err());
2738    }
2739
2740    #[test]
2741    fn pred_trailing_tokens_rejected() {
2742        let err = parse_predicate("a b").unwrap_err();
2743        assert!(format!("{}", err).contains("trailing"));
2744    }
2745
2746    // ----- Rule parser -----
2747
2748    #[test]
2749    fn rule_predicate_action_form() {
2750        let r = parse_rule("delegation.depth > 2: deny", "test").unwrap();
2751        match r.effects.as_slice() {
2752            [Effect::Deny { .. }] => {},
2753            other => panic!("expected [Deny], got {:?}", other),
2754        }
2755        match r.condition {
2756            Expression::Condition(Condition::Comparison { .. }) => {},
2757            other => panic!("expected Comparison, got {:?}", other),
2758        }
2759    }
2760
2761    #[test]
2762    fn rule_predicate_only_defaults_to_deny() {
2763        // DSL §2: missing action defaults to deny.
2764        let r = parse_rule("!authenticated", "test").unwrap();
2765        assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
2766    }
2767
2768    #[test]
2769    fn rule_explicit_allow() {
2770        let r = parse_rule("role.admin: allow", "test").unwrap();
2771        assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
2772    }
2773
2774    #[test]
2775    fn rule_bare_action_unconditional() {
2776        // Bare `- deny` and `- allow` are unconditional rules with
2777        // Expression::Always as the predicate (DSL §3.1).
2778        let r = parse_rule("deny", "test").unwrap();
2779        assert_eq!(r.condition, Expression::Always);
2780        assert!(matches!(
2781            r.effects.as_slice(),
2782            [Effect::Deny {
2783                reason: None,
2784                code: None
2785            }]
2786        ));
2787
2788        let r = parse_rule("allow", "test").unwrap();
2789        assert_eq!(r.condition, Expression::Always);
2790        assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
2791    }
2792
2793    #[test]
2794    fn rule_bare_deny_call_carries_reason_and_code() {
2795        // Unconditional `deny('reason')` / `deny('reason', 'code')` parse
2796        // to an Always-guarded Deny, so they're usable as bare rule lines
2797        // and as `on_deny:` / `on_allow:` reactions.
2798        let r = parse_rule("deny('nope')", "test").unwrap();
2799        assert_eq!(r.condition, Expression::Always);
2800        match r.effects.as_slice() {
2801            [Effect::Deny {
2802                reason: Some(reason),
2803                code: None,
2804            }] => assert_eq!(reason, "nope"),
2805            other => panic!(
2806                "expected [Deny{{reason: Some, code: None}}], got {:?}",
2807                other
2808            ),
2809        }
2810
2811        let r = parse_rule("deny('nope', 'cel.policy')", "test").unwrap();
2812        assert_eq!(r.condition, Expression::Always);
2813        match r.effects.as_slice() {
2814            [Effect::Deny {
2815                reason: Some(reason),
2816                code: Some(code),
2817            }] => {
2818                assert_eq!(reason, "nope");
2819                assert_eq!(code, "cel.policy");
2820            },
2821            other => panic!("expected [Deny{{reason, code}}], got {:?}", other),
2822        }
2823    }
2824
2825    #[test]
2826    fn rule_malformed_bare_deny_call_errors() {
2827        // A malformed `deny(...)` must surface its own error rather than
2828        // falling through to the predicate parser.
2829        let err = parse_rule("deny(unquoted)", "test").unwrap_err();
2830        assert!(
2831            matches!(err, ParseError::Rule { .. }),
2832            "expected ParseError::Rule, got {:?}",
2833            err
2834        );
2835    }
2836
2837    #[test]
2838    fn rule_step_kinds_rejected_clearly() {
2839        for s in [
2840            "plugin(rate_limiter)",
2841            "cedar:(action: read)",
2842            "opa(path)",
2843            "taint(audit)",
2844        ] {
2845            let err = parse_rule(s, "test").unwrap_err();
2846            assert!(
2847                matches!(err, ParseError::UnsupportedStep { .. }),
2848                "expected UnsupportedStep for `{}`, got {:?}",
2849                s,
2850                err
2851            );
2852        }
2853    }
2854
2855    #[test]
2856    fn rule_deny_with_unquoted_arg_rejected() {
2857        // `deny "reason"` (space-separated, no parens) is not a valid
2858        // form. The supported reason-carrying shape is
2859        // `deny('reason')` / `deny('reason', 'code')` per DSL §3 and
2860        // the E1 `code` extension.
2861        let err = parse_rule(r#"authenticated: deny "go away""#, "test").unwrap_err();
2862        assert!(format!("{}", err).contains("unsupported action"));
2863    }
2864
2865    #[test]
2866    fn rule_deny_with_quoted_reason_accepted() {
2867        // `deny('reason')` — single-arg form. Reason landing on the
2868        // effect; code defaulting to None.
2869        let r = parse_rule(r#"delegation.depth > 2: deny('too deep')"#, "test").unwrap();
2870        assert!(matches!(
2871            r.effects.as_slice(),
2872            [Effect::Deny { reason: Some(s), code: None }] if s == "too deep"
2873        ));
2874    }
2875
2876    #[test]
2877    fn rule_deny_with_reason_and_code_accepted() {
2878        // `deny('reason', 'code')` — E1 extension. Both reason and
2879        // author-supplied code surface in the violation.
2880        let r = parse_rule(
2881            r#"delegation.depth > 2: deny('too deep', 'delegation.depth_exceeded')"#,
2882            "test",
2883        )
2884        .unwrap();
2885        match r.effects.as_slice() {
2886            [Effect::Deny {
2887                reason: Some(reason),
2888                code: Some(code),
2889            }] => {
2890                assert_eq!(reason, "too deep");
2891                assert_eq!(code, "delegation.depth_exceeded");
2892            },
2893            other => panic!("expected Deny with reason+code, got {:?}", other),
2894        }
2895    }
2896
2897    #[test]
2898    fn rule_deny_with_too_many_args_rejected() {
2899        // Cap on positional args — `deny(reason, code)` is the limit.
2900        let err = parse_rule(r#"x: deny('a', 'b', 'c')"#, "test").unwrap_err();
2901        assert!(format!("{}", err).contains("at most two args"));
2902    }
2903
2904    #[test]
2905    fn rule_deny_with_unquoted_args_in_call_rejected() {
2906        // The args MUST be quoted; bare identifiers aren't legal.
2907        let err = parse_rule(r#"x: deny(bare, identifier)"#, "test").unwrap_err();
2908        assert!(format!("{}", err).contains("expected a quoted string"));
2909    }
2910
2911    // ----- E1: when/do canonical form -----
2912
2913    fn parse_step_yaml(yaml: &str) -> Result<Step, ParseError> {
2914        let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2915        parse_step(&v, "test")
2916    }
2917
2918    #[test]
2919    fn when_do_single_effect_deny() {
2920        // do: deny  — single string value, no list.
2921        let step = parse_step_yaml("when: delegation.depth > 2\ndo: deny").unwrap();
2922        match step {
2923            Step::Rule(rule) => {
2924                assert!(matches!(
2925                    rule.condition,
2926                    Expression::Condition(Condition::Comparison { .. })
2927                ));
2928                assert!(matches!(
2929                    rule.effects.as_slice(),
2930                    [Effect::Deny {
2931                        reason: None,
2932                        code: None
2933                    }]
2934                ));
2935            },
2936            other => panic!("expected Step::Rule, got {:?}", other),
2937        }
2938    }
2939
2940    #[test]
2941    fn when_do_single_effect_deny_with_reason_and_code() {
2942        // The E1 `deny('reason', 'code')` extension works inside `do:` too.
2943        let step = parse_step_yaml(
2944            "when: delegation.depth > 2\ndo: deny('too deep', 'delegation.depth_exceeded')",
2945        )
2946        .unwrap();
2947        let Step::Rule(rule) = step else {
2948            panic!("expected Step::Rule");
2949        };
2950        match rule.effects.as_slice() {
2951            [Effect::Deny {
2952                reason: Some(r),
2953                code: Some(c),
2954            }] => {
2955                assert_eq!(r, "too deep");
2956                assert_eq!(c, "delegation.depth_exceeded");
2957            },
2958            other => panic!("expected Deny+reason+code, got {:?}", other),
2959        }
2960    }
2961
2962    #[test]
2963    fn when_do_multi_effect_list() {
2964        // The headline demo case: fan-out from one predicate.
2965        // do: [plugin(audit_logger), taint(unauth), deny('refused')]
2966        let yaml = r#"
2967when: "!role.hr"
2968do:
2969  - "plugin(audit_logger)"
2970  - "taint(unauth, session)"
2971  - "deny('refused', 'role.hr_required')"
2972"#;
2973        let step = parse_step_yaml(yaml).unwrap();
2974        let Step::Rule(rule) = step else {
2975            panic!("expected Step::Rule");
2976        };
2977        assert_eq!(rule.effects.len(), 3);
2978        assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
2979        assert!(matches!(
2980            rule.effects[1],
2981            Effect::Taint { ref label, .. } if label == "unauth"
2982        ));
2983        match &rule.effects[2] {
2984            Effect::Deny {
2985                reason: Some(r),
2986                code: Some(c),
2987            } => {
2988                assert_eq!(r, "refused");
2989                assert_eq!(c, "role.hr_required");
2990            },
2991            other => panic!("expected Deny+reason+code, got {:?}", other),
2992        }
2993    }
2994
2995    #[test]
2996    fn when_do_key_order_does_not_matter() {
2997        // YAML maps are unordered; `do:` first should parse the same.
2998        let step = parse_step_yaml("do: deny\nwhen: delegation.depth > 2").unwrap();
2999        assert!(matches!(step, Step::Rule(_)));
3000    }
3001
3002    #[test]
3003    fn when_do_with_unknown_key_rejected() {
3004        // Typo guard — surface unknown keys instead of silently dropping.
3005        let err = parse_step_yaml("when: x\ndo: deny\nwhne: typo").unwrap_err();
3006        assert!(format!("{}", err).contains("unexpected key"));
3007    }
3008
3009    #[test]
3010    fn when_do_empty_do_list_rejected() {
3011        // An empty `do:` is almost certainly an author mistake;
3012        // require at least one effect.
3013        let err = parse_step_yaml("when: x\ndo: []").unwrap_err();
3014        assert!(format!("{}", err).contains("no effects"));
3015    }
3016
3017    // ----- E1: shorthand multi-effect map (predicate: [list]) -----
3018
3019    #[test]
3020    fn shorthand_multi_effect_map() {
3021        // Shorthand for the canonical when/do form. The predicate is
3022        // the map's only key, the value is a list of effects.
3023        let yaml = r#"
3024"!role.hr":
3025  - "plugin(audit_logger)"
3026  - "deny('unauthorized')"
3027"#;
3028        let step = parse_step_yaml(yaml).unwrap();
3029        let Step::Rule(rule) = step else {
3030            panic!("expected Step::Rule");
3031        };
3032        assert_eq!(rule.effects.len(), 2);
3033        assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
3034        assert!(matches!(
3035            rule.effects[1],
3036            Effect::Deny { reason: Some(ref r), code: None } if r == "unauthorized"
3037        ));
3038    }
3039
3040    #[test]
3041    fn shorthand_multi_effect_map_with_nested_delegate() {
3042        // Map-form effects (like `delegate:`) work inside a shorthand
3043        // list, exercising the parse_effect_value path.
3044        let yaml = r#"
3045"role.hr":
3046  - delegate:
3047      plugin: workday-oauth
3048      config:
3049        audience: workday-api
3050  - "plugin(audit_logger)"
3051"#;
3052        let step = parse_step_yaml(yaml).unwrap();
3053        let Step::Rule(rule) = step else {
3054            panic!("expected Step::Rule");
3055        };
3056        assert_eq!(rule.effects.len(), 2);
3057        assert!(matches!(rule.effects[0], Effect::Delegate(_)));
3058        assert!(matches!(rule.effects[1], Effect::Plugin { .. }));
3059    }
3060
3061    #[test]
3062    fn cedar_with_list_body_still_parses_as_pdp() {
3063        // Regression guard — `cedar:` and other PDP keys whose body
3064        // happens to be list-shaped (e.g. when the author embeds a
3065        // bare reaction list) must NOT be reinterpreted as a
3066        // shorthand multi-effect map.
3067        //
3068        // Cedar bodies in production are maps with `action`/`resource`
3069        // keys — we don't actually accept a Sequence body, but the
3070        // shorthand-list detector explicitly excludes known PDP
3071        // dialect keys so the failure mode here is the existing PDP
3072        // body error, not a shorthand misparse.
3073        let err = parse_step_yaml("cedar: [oh no]").unwrap_err();
3074        // Existing PDP body validator complains about the shape —
3075        // proves we didn't try to read `cedar` as a predicate.
3076        assert!(format!("{}", err).contains("body must be a map"));
3077    }
3078
3079    #[test]
3080    fn shorthand_multi_effect_empty_list_rejected() {
3081        let err = parse_step_yaml(r#""x": []"#).unwrap_err();
3082        assert!(format!("{}", err).contains("no effects"));
3083    }
3084
3085    // ----- E2: content effects in do: (field pipe chains) -----
3086
3087    #[test]
3088    fn when_do_with_field_op_result_redact() {
3089        // The headline E2 case: `result.salary | redact` as an effect
3090        // inside a do: list, alongside other effect kinds.
3091        let yaml = r#"
3092when: "!perm.view_ssn"
3093do:
3094  - "plugin(audit_logger)"
3095  - "result.salary | redact"
3096"#;
3097        let step = parse_step_yaml(yaml).unwrap();
3098        let Step::Rule(rule) = step else {
3099            panic!("expected Step::Rule");
3100        };
3101        assert_eq!(rule.effects.len(), 2);
3102        assert!(matches!(rule.effects[0], Effect::Plugin { .. }));
3103        match &rule.effects[1] {
3104            Effect::FieldOp { path, stages } => {
3105                assert_eq!(path, "result.salary");
3106                assert_eq!(stages.len(), 1, "single `redact` stage");
3107            },
3108            other => panic!("expected FieldOp, got {:?}", other),
3109        }
3110    }
3111
3112    #[test]
3113    fn when_do_with_field_op_args_mask() {
3114        // `args.card_number | mask(4)` — args side + parametrised stage.
3115        let yaml = r#"
3116when: role.support
3117do: "args.card_number | mask(4)"
3118"#;
3119        let step = parse_step_yaml(yaml).unwrap();
3120        let Step::Rule(rule) = step else {
3121            panic!("expected Step::Rule");
3122        };
3123        match &rule.effects[..] {
3124            [Effect::FieldOp { path, stages }] => {
3125                assert_eq!(path, "args.card_number");
3126                assert_eq!(stages.len(), 1);
3127            },
3128            other => panic!("expected single FieldOp, got {:?}", other),
3129        }
3130    }
3131
3132    #[test]
3133    fn when_do_with_chained_field_op() {
3134        // Chained stages — type check + content effect. Uses stages
3135        // the pipeline parser actually knows about (`str` and `mask`).
3136        let yaml = r#"
3137when: role.support
3138do: "args.card_number | str | mask(4)"
3139"#;
3140        let step = parse_step_yaml(yaml).unwrap();
3141        let Step::Rule(rule) = step else {
3142            panic!("expected Step::Rule");
3143        };
3144        match &rule.effects[..] {
3145            [Effect::FieldOp { path, stages }] => {
3146                assert_eq!(path, "args.card_number");
3147                assert_eq!(stages.len(), 2, "two-stage chain");
3148            },
3149            other => panic!("expected single FieldOp, got {:?}", other),
3150        }
3151    }
3152
3153    #[test]
3154    fn field_stage_run_aliases_plugin() {
3155        // In a field pipeline, `run(name)` is the same plugin-transform
3156        // stage as `plugin(name)` — symmetry with the policy-step alias.
3157        let yaml = r#"
3158when: role.support
3159do: "args.card_number | run(luhn)"
3160"#;
3161        let step = parse_step_yaml(yaml).unwrap();
3162        let Step::Rule(rule) = step else {
3163            panic!("expected Step::Rule");
3164        };
3165        match &rule.effects[..] {
3166            [Effect::FieldOp { path, stages }] => {
3167                assert_eq!(path, "args.card_number");
3168                match &stages[..] {
3169                    [Stage::Plugin { name }] => assert_eq!(name, "luhn"),
3170                    other => panic!("expected [Stage::Plugin], got {:?}", other),
3171                }
3172            },
3173            other => panic!("expected single FieldOp, got {:?}", other),
3174        }
3175    }
3176
3177    #[test]
3178    fn field_stage_plugin_empty_name_is_rejected() {
3179        // `plugin()` / `run()` with no name in a field pipeline must be
3180        // rejected, mirroring the policy-step path (`parse_step_string`).
3181        // Previously the field-stage path accepted it as
3182        // `Stage::Plugin { name: "" }`.
3183        for verb in ["plugin", "run"] {
3184            let err = parse_stage(&format!("{verb}()")).expect_err("empty name must error");
3185            let msg = format!("{err}");
3186            assert!(
3187                msg.contains(verb) && msg.contains("must not be empty"),
3188                "{verb}(): expected verb-named empty-name error, got: {msg}"
3189            );
3190        }
3191    }
3192
3193    #[test]
3194    fn field_op_invalid_path_falls_through() {
3195        // `role.hr | redact` looks like a pipe chain but the path
3196        // doesn't start with `args.` / `result.`. We refuse to treat
3197        // it as a FieldOp; instead it falls through to the predicate
3198        // parser, which will fail with a more specific error.
3199        let yaml = r#"do: "role.hr | redact""#;
3200        let _ = parse_step_yaml(&format!("when: true\n{}", yaml));
3201        // The exact failure mode here isn't load-bearing — what matters
3202        // is we don't silently produce an unconditional FieldOp with a
3203        // bogus path. So just confirm we either error or produce
3204        // *something other than* a FieldOp.
3205        let step = parse_step_yaml("when: true\ndo: \"role.hr | redact\"");
3206        match step {
3207            Ok(Step::Rule(rule)) => {
3208                assert!(
3209                    !matches!(rule.effects.as_slice(), [Effect::FieldOp { .. }]),
3210                    "bare `role.hr` must NOT parse as a FieldOp path"
3211                );
3212            },
3213            Err(_) => {}, // also fine
3214            other => panic!("unexpected: {:?}", other),
3215        }
3216    }
3217
3218    #[test]
3219    fn field_op_empty_chain_rejected() {
3220        // `args.x |` (trailing pipe with nothing after) — author bug.
3221        let yaml = r#"when: true
3222do: "args.x | ""#;
3223        let _ = parse_step_yaml(yaml); // shape varies by YAML parser, just ensure no panic
3224    }
3225
3226    #[test]
3227    fn shorthand_multi_effect_with_field_op() {
3228        // Shorthand `predicate: [list]` with a content effect.
3229        let yaml = r#"
3230"!perm.view_ssn":
3231  - "plugin(audit_logger)"
3232  - "result.ssn | redact"
3233"#;
3234        let step = parse_step_yaml(yaml).unwrap();
3235        let Step::Rule(rule) = step else {
3236            panic!("expected Step::Rule");
3237        };
3238        assert_eq!(rule.effects.len(), 2);
3239        assert!(matches!(rule.effects[1], Effect::FieldOp { .. }));
3240    }
3241
3242    #[test]
3243    fn find_top_level_pipe_skips_inside_parens() {
3244        // Top-level `|` between path and chain → returns its index.
3245        // Inner `|` inside `(...)` or quotes is ignored.
3246        assert_eq!(find_top_level_pipe("args.x | mask(4)"), Some(7));
3247        assert_eq!(find_top_level_pipe("validate(luhn)"), None);
3248        assert_eq!(find_top_level_pipe(r#"args.x | mask("a|b")"#), Some(7));
3249        // No top-level pipe even with a `|` inside the parameter set.
3250        assert_eq!(find_top_level_pipe("mask(a|b)"), None);
3251    }
3252
3253    // ----- E3: sequential: / parallel: parsing -----
3254
3255    #[test]
3256    fn top_level_sequential() {
3257        // `- sequential: [list]` as a top-level policy step.
3258        let yaml = r#"
3259sequential:
3260  - "plugin(rate_limiter)"
3261  - "plugin(audit_logger)"
3262"#;
3263        let step = parse_step_yaml(yaml).unwrap();
3264        let Step::Rule(rule) = step else {
3265            panic!("expected Rule");
3266        };
3267        assert!(matches!(rule.condition, Expression::Always));
3268        match rule.effects.as_slice() {
3269            [Effect::Sequential(inner)] => {
3270                assert_eq!(inner.len(), 2);
3271                assert!(matches!(inner[0], Effect::Plugin { .. }));
3272                assert!(matches!(inner[1], Effect::Plugin { .. }));
3273            },
3274            other => panic!("expected single Sequential effect, got {:?}", other),
3275        }
3276    }
3277
3278    #[test]
3279    fn top_level_parallel() {
3280        let yaml = r#"
3281parallel:
3282  - "plugin(pii_scanner)"
3283  - "plugin(nemo_guardrails)"
3284"#;
3285        let step = parse_step_yaml(yaml).unwrap();
3286        let Step::Rule(rule) = step else {
3287            panic!("expected Rule");
3288        };
3289        match rule.effects.as_slice() {
3290            [Effect::Parallel(inner)] => {
3291                assert_eq!(inner.len(), 2);
3292            },
3293            other => panic!("expected single Parallel effect, got {:?}", other),
3294        }
3295    }
3296
3297    #[test]
3298    fn parallel_inside_do_body() {
3299        // The DSL spec's "Conditional parallel" example: a `when:`
3300        // rule whose `do:` is a single parallel block.
3301        let yaml = r#"
3302when: args.include_ssn == true
3303do:
3304  parallel:
3305    - "plugin(pii_scanner)"
3306    - "plugin(nemo_guardrails)"
3307"#;
3308        let step = parse_step_yaml(yaml).unwrap();
3309        let Step::Rule(rule) = step else {
3310            panic!("expected Rule");
3311        };
3312        match rule.effects.as_slice() {
3313            [Effect::Parallel(inner)] => assert_eq!(inner.len(), 2),
3314            other => panic!("expected Parallel in do:, got {:?}", other),
3315        }
3316    }
3317
3318    #[test]
3319    fn parallel_rejects_field_op_at_parse_time() {
3320        // FieldOp inside Parallel should fail at parse, not at runtime.
3321        let yaml = r#"
3322parallel:
3323  - "plugin(audit)"
3324  - "args.ssn | redact"
3325"#;
3326        let err = parse_step_yaml(yaml).unwrap_err();
3327        assert!(format!("{}", err).contains("mutation"), "got: {}", err);
3328    }
3329
3330    #[test]
3331    fn parallel_rejects_delegate_at_parse_time() {
3332        let yaml = r#"
3333parallel:
3334  - "plugin(audit)"
3335  - "delegate(workday)"
3336"#;
3337        let err = parse_step_yaml(yaml).unwrap_err();
3338        assert!(format!("{}", err).contains("mutation"));
3339    }
3340
3341    #[test]
3342    fn sequential_allows_mutations() {
3343        // The escape valve — Sequential lets mutations through.
3344        let yaml = r#"
3345sequential:
3346  - "args.ssn | redact"
3347  - "plugin(audit)"
3348"#;
3349        let step = parse_step_yaml(yaml).unwrap();
3350        let Step::Rule(rule) = step else {
3351            panic!("expected Rule")
3352        };
3353        match rule.effects.as_slice() {
3354            [Effect::Sequential(inner)] => {
3355                assert!(matches!(inner[0], Effect::FieldOp { .. }));
3356                assert!(matches!(inner[1], Effect::Plugin { .. }));
3357            },
3358            other => panic!("got {:?}", other),
3359        }
3360    }
3361
3362    #[test]
3363    fn parallel_empty_list_rejected() {
3364        let err = parse_step_yaml("parallel: []").unwrap_err();
3365        assert!(format!("{}", err).contains("empty"));
3366    }
3367
3368    #[test]
3369    fn sequential_empty_list_rejected() {
3370        let err = parse_step_yaml("sequential: []").unwrap_err();
3371        assert!(format!("{}", err).contains("empty"));
3372    }
3373
3374    #[test]
3375    fn nested_orchestration() {
3376        // `sequential: [plugin, parallel: [plugin, plugin]]` — the
3377        // parser handles arbitrary nesting through parse_effect_value.
3378        let yaml = r#"
3379sequential:
3380  - "plugin(rate_limiter)"
3381  - parallel:
3382      - "plugin(pii_scanner)"
3383      - "plugin(nemo)"
3384"#;
3385        let step = parse_step_yaml(yaml).unwrap();
3386        let Step::Rule(rule) = step else {
3387            panic!("expected Rule")
3388        };
3389        let Effect::Sequential(outer) = &rule.effects[0] else {
3390            panic!("expected Sequential");
3391        };
3392        assert_eq!(outer.len(), 2);
3393        assert!(matches!(outer[0], Effect::Plugin { .. }));
3394        match &outer[1] {
3395            Effect::Parallel(inner) => assert_eq!(inner.len(), 2),
3396            other => panic!("expected nested Parallel, got {:?}", other),
3397        }
3398    }
3399
3400    // ----- Colon-splitting edge cases -----
3401
3402    #[test]
3403    fn split_respects_quotes_and_parens() {
3404        // The `:` inside parens / quotes shouldn't be the separator.
3405        let r = parse_rule(r#"session.labels contains "a:b": deny"#, "test").unwrap();
3406        assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
3407        if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition {
3408            assert_eq!(value, Literal::String("a:b".into()));
3409        } else {
3410            panic!("expected Comparison");
3411        }
3412    }
3413
3414    // ----- YAML compilation -----
3415
3416    #[test]
3417    fn compile_simple_route() {
3418        let yaml = r#"
3419routes:
3420  get_compensation:
3421    pre_invocation:
3422      - "require(authenticated)"
3423      - "require(role.hr | role.finance)"
3424      - "delegation.depth > 2 & include_ssn: deny"
3425"#;
3426        let routes = compile_config(yaml).unwrap().routes;
3427        let route = routes.get("get_compensation").expect("route missing");
3428        assert_eq!(route.policy.len(), 3);
3429        assert!(route
3430            .declared_phases()
3431            .contains(crate::rules::Phase::Policy));
3432    }
3433
3434    #[test]
3435    fn authorization_nested_and_flat_forms_are_equivalent() {
3436        // The nested `authorization:` block and the flat
3437        // `pre_invocation:` / `post_invocation:` forms must compile to
3438        // the same route.
3439        let nested = r#"
3440routes:
3441  r:
3442    authorization:
3443      pre_invocation:
3444        - "require(authenticated)"
3445      post_invocation:
3446        - "taint(audit, session)"
3447"#;
3448        let flat = r#"
3449routes:
3450  r:
3451    pre_invocation:
3452      - "require(authenticated)"
3453    post_invocation:
3454      - "taint(audit, session)"
3455"#;
3456        let a = compile_config(nested).unwrap().routes;
3457        let b = compile_config(flat).unwrap().routes;
3458        let ra = a.get("r").expect("nested route");
3459        let rb = b.get("r").expect("flat route");
3460        assert_eq!(ra.policy.len(), rb.policy.len());
3461        assert_eq!(ra.post_policy.len(), rb.post_policy.len());
3462        assert_eq!(ra.policy.len(), 1);
3463        assert_eq!(ra.post_policy.len(), 1);
3464    }
3465
3466    #[test]
3467    fn legacy_key_nested_under_authorization_is_rejected() {
3468        // Fail-closed: a legacy key nested inside the new `authorization:`
3469        // wrapper must error, not be silently dropped (which would load a
3470        // route with no authorization enforced). Guarded by
3471        // `deny_unknown_fields` on `AuthorizationYaml`.
3472        let yaml = r#"
3473routes:
3474  r:
3475    authorization:
3476      policy:
3477        - "require(authenticated)"
3478"#;
3479        let err = compile_config(yaml).expect_err("nested legacy `policy:` must be rejected");
3480        let msg = format!("{err}");
3481        assert!(
3482            msg.contains("policy") || msg.contains("unknown field"),
3483            "error should flag the unknown/legacy nested key: {msg}"
3484        );
3485    }
3486
3487    #[test]
3488    fn authorization_typo_under_wrapper_is_rejected() {
3489        // `deny_unknown_fields` also catches typos so they don't silently
3490        // no-op the phase.
3491        let yaml = r#"
3492routes:
3493  r:
3494    authorization:
3495      pre_invocaton:
3496        - "require(authenticated)"
3497"#;
3498        assert!(
3499            compile_config(yaml).is_err(),
3500            "a typo'd sub-key under `authorization:` must be rejected, not ignored"
3501        );
3502    }
3503
3504    #[test]
3505    fn same_phase_declared_nested_and_flat_is_rejected() {
3506        // The two forms are alternatives, not additive: declaring a phase
3507        // both nested and flat on one section would run its effects twice.
3508        let yaml = r#"
3509routes:
3510  r:
3511    authorization:
3512      pre_invocation:
3513        - "require(authenticated)"
3514    pre_invocation:
3515      - "require(role.hr)"
3516"#;
3517        let err = compile_config(yaml).expect_err("both-forms-same-section must be rejected");
3518        assert!(
3519            matches!(err, ParseError::ConflictingAuthorizationForms { ref phase, .. } if phase == "pre_invocation"),
3520            "expected ConflictingAuthorizationForms for pre_invocation, got {err:?}"
3521        );
3522    }
3523
3524    #[test]
3525    fn field_pipeline_error_names_field_path() {
3526        // A malformed pipeline under `result:` names `result.<field>` in
3527        // the diagnostic so the operator can locate the offending field.
3528        let yaml = r#"
3529routes:
3530  r:
3531    result:
3532      x: "nonsense"
3533"#;
3534        let err = compile_config(yaml).unwrap_err();
3535        let msg = format!("{err}");
3536        assert!(msg.contains("result.x"), "expected result.x in: {msg}");
3537    }
3538
3539    #[test]
3540    fn legacy_policy_field_names_are_rejected() {
3541        // Breaking rename: the old authorization-phase keys must fail
3542        // loudly, never be silently dropped (which would fail open).
3543        for (old, hint) in [
3544            ("policy", "pre_invocation"),
3545            ("post_policy", "post_invocation"),
3546        ] {
3547            let yaml = format!("routes:\n  r:\n    {old}:\n      - \"require(authenticated)\"\n");
3548            let err = compile_config(&yaml).expect_err(&format!("legacy `{old}` must be rejected"));
3549            let msg = format!("{err}");
3550            assert!(
3551                msg.contains(old) && msg.contains(hint),
3552                "`{old}` rejection should name the replacement `{hint}`: {msg}"
3553            );
3554        }
3555    }
3556
3557    #[test]
3558    fn legacy_only_route_is_not_silently_omitted() {
3559        // A route whose *only* APL-ish key is a legacy name would look
3560        // empty to the has-APL gate and be dropped — a fail-open. It must
3561        // error instead.
3562        let yaml = r#"
3563routes:
3564  ghost:
3565    policy:
3566      - "require(authenticated)"
3567"#;
3568        assert!(
3569            matches!(compile_config(yaml), Err(ParseError::RenamedField { .. })),
3570            "legacy-only route must be rejected, not omitted"
3571        );
3572    }
3573
3574    #[test]
3575    fn compile_omits_routes_without_apl_blocks() {
3576        // A route with no APL blocks (no authorization / pre_invocation /
3577        // post_invocation / args / result) is a "legacy" route per
3578        // apl-design §5 and must be
3579        // omitted from the compiled output. Unknown route keys (e.g.
3580        // legacy CPEX `priority`) are stashed in `other`, not errored.
3581        let yaml = r#"
3582routes:
3583  legacy:
3584    priority: 50
3585  apl_route:
3586    pre_invocation:
3587      - "require(authenticated)"
3588"#;
3589        let routes = compile_config(yaml).unwrap().routes;
3590        assert!(routes.contains_key("apl_route"));
3591        assert!(
3592            !routes.contains_key("legacy"),
3593            "legacy route should be omitted, not compiled"
3594        );
3595    }
3596
3597    #[test]
3598    fn compile_unknown_top_level_keys_ignored() {
3599        let yaml = r#"
3600version: "0.1"
3601policy_evaluator:
3602  kind: apl
3603plugins:
3604  - name: rate_limiter
3605    kind: native
3606imports:
3607  - "./shared.yaml"
3608routes:
3609  ping:
3610    pre_invocation:
3611      - "require(authenticated)"
3612"#;
3613        let routes = compile_config(yaml).unwrap().routes;
3614        assert!(routes.contains_key("ping"));
3615    }
3616
3617    #[test]
3618    fn compile_propagates_rule_errors_with_source() {
3619        let yaml = r#"
3620routes:
3621  bad:
3622    pre_invocation:
3623      - "subject.id == garbage_ident"
3624"#;
3625        let err = compile_config(yaml).unwrap_err();
3626        // RHS-as-identifier is rejected; the error mentions the offending input.
3627        let msg = format!("{}", err);
3628        assert!(
3629            msg.contains("RHS-as-identifier") || msg.contains("garbage_ident"),
3630            "error message should reference the failure: {}",
3631            msg,
3632        );
3633    }
3634
3635    #[test]
3636    fn compile_plugin_step_string_form() {
3637        let yaml = r#"
3638routes:
3639  rate_limited:
3640    pre_invocation:
3641      - "plugin(rate_limiter)"
3642"#;
3643        let routes = compile_config(yaml).unwrap().routes;
3644        let route = routes.get("rate_limited").unwrap();
3645        assert_eq!(route.policy.len(), 1);
3646        match &route.policy[0] {
3647            Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
3648            other => panic!("expected Effect::Plugin, got {:?}", other),
3649        }
3650    }
3651
3652    #[test]
3653    fn compile_run_step_string_form_aliases_plugin() {
3654        // `run(name)` is an alias for `plugin(name)`: both invoke a named
3655        // plugin and compile to Effect::Plugin.
3656        let yaml = r#"
3657routes:
3658  rate_limited:
3659    pre_invocation:
3660      - "run(rate_limiter)"
3661"#;
3662        let routes = compile_config(yaml).unwrap().routes;
3663        let route = routes.get("rate_limited").unwrap();
3664        assert_eq!(route.policy.len(), 1);
3665        match &route.policy[0] {
3666            Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
3667            other => panic!("expected Effect::Plugin, got {:?}", other),
3668        }
3669    }
3670
3671    #[test]
3672    fn parse_step_run_is_plugin_alias() {
3673        for s in ["run(audit-log)", "plugin(audit-log)"] {
3674            let step = parse_step(&serde_yaml::Value::String(s.to_string()), "test").unwrap();
3675            match step {
3676                crate::step::Step::Plugin { name } => assert_eq!(name, "audit-log", "{s}"),
3677                other => panic!("expected Step::Plugin for `{s}`, got {other:?}"),
3678            }
3679        }
3680        // Empty / malformed `run(...)` surfaces a clear, verb-named error.
3681        let err = parse_step(&serde_yaml::Value::String("run()".to_string()), "test").unwrap_err();
3682        assert!(
3683            format!("{err}").contains("run("),
3684            "error should name `run(...)`: {err}"
3685        );
3686    }
3687
3688    #[test]
3689    fn compile_taint_step_string_form() {
3690        let yaml = r#"
3691routes:
3692  audit_marked:
3693    pre_invocation:
3694      - "taint(audit, session)"
3695"#;
3696        let routes = compile_config(yaml).unwrap().routes;
3697        let route = routes.get("audit_marked").unwrap();
3698        match &route.policy[0] {
3699            Effect::Taint { label, scopes } => {
3700                assert_eq!(label, "audit");
3701                assert_eq!(scopes, &vec![TaintScope::Session]);
3702            },
3703            other => panic!("expected Effect::Taint, got {:?}", other),
3704        }
3705    }
3706
3707    #[test]
3708    fn compile_pdp_call_cedar_map_form() {
3709        // Cedar uses the `cedar:` key with args inline + on_deny/on_allow.
3710        let yaml = r#"
3711routes:
3712  authz_check:
3713    pre_invocation:
3714      - cedar:
3715          action: read
3716          resource: employee
3717          on_deny:
3718            - deny
3719          on_allow:
3720            - "plugin(audit_logger)"
3721"#;
3722        let routes = compile_config(yaml).unwrap().routes;
3723        let route = routes.get("authz_check").unwrap();
3724        match &route.policy[0] {
3725            Effect::Pdp {
3726                call,
3727                on_deny,
3728                on_allow,
3729            } => {
3730                assert_eq!(call.dialect, PdpDialect::Cedar);
3731                // Cedar args are a map: action + resource (with reaction
3732                // keys stripped out).
3733                let args_map = call.args.as_mapping().expect("cedar args should be a map");
3734                assert!(args_map.contains_key(serde_yaml::Value::String("action".into())));
3735                assert!(args_map.contains_key(serde_yaml::Value::String("resource".into())));
3736                assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
3737                assert_eq!(on_deny.len(), 1);
3738                assert_eq!(on_allow.len(), 1);
3739            },
3740            other => panic!("expected Effect::Pdp, got {:?}", other),
3741        }
3742    }
3743
3744    #[test]
3745    fn compile_pdp_call_cel_map_form() {
3746        // `cel:` carries an `expr:` string + optional on_deny/on_allow
3747        // reactions. Routes to the CEL-backed resolver via PdpDialect::Cel.
3748        let yaml = r#"
3749routes:
3750  authz_check:
3751    pre_invocation:
3752      - cel:
3753          expr: "subject.id == 'alice' && delegation.depth <= 2"
3754          on_deny:
3755            - deny
3756"#;
3757        let routes = compile_config(yaml).unwrap().routes;
3758        let route = routes.get("authz_check").unwrap();
3759        match &route.policy[0] {
3760            Effect::Pdp {
3761                call,
3762                on_deny,
3763                on_allow,
3764            } => {
3765                assert_eq!(call.dialect, PdpDialect::Cel);
3766                let args_map = call.args.as_mapping().expect("cel args should be a map");
3767                assert!(args_map.contains_key(serde_yaml::Value::String("expr".into())));
3768                // Reaction keys are stripped from the opaque call args.
3769                assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
3770                assert_eq!(on_deny.len(), 1);
3771                assert_eq!(on_allow.len(), 0);
3772            },
3773            other => panic!("expected Effect::Pdp, got {:?}", other),
3774        }
3775    }
3776
3777    #[test]
3778    fn compile_pdp_call_opa_paren_form() {
3779        // OPA uses `opa("path"):` with the path inside parens + body is reactions.
3780        let yaml = r#"
3781routes:
3782  opa_check:
3783    pre_invocation:
3784      - 'opa("hr/compensation/deny"):':
3785          on_deny:
3786            - deny
3787"#;
3788        let routes = compile_config(yaml).unwrap().routes;
3789        let route = routes.get("opa_check").unwrap();
3790        match &route.policy[0] {
3791            Effect::Pdp { call, on_deny, .. } => {
3792                assert_eq!(call.dialect, PdpDialect::Opa);
3793                // OPA args are a string (the path).
3794                assert!(call.args.as_str().unwrap().contains("hr/compensation/deny"));
3795                assert_eq!(on_deny.len(), 1);
3796            },
3797            other => panic!("expected Effect::Pdp, got {:?}", other),
3798        }
3799    }
3800
3801    #[test]
3802    fn compile_pdp_unknown_dialect_becomes_custom() {
3803        let yaml = r#"
3804routes:
3805  custom_pdp:
3806    pre_invocation:
3807      - my_engine:
3808          on_deny: [deny]
3809"#;
3810        let routes = compile_config(yaml).unwrap().routes;
3811        match &routes.get("custom_pdp").unwrap().policy[0] {
3812            Effect::Pdp { call, .. } => {
3813                assert_eq!(call.dialect, PdpDialect::Custom("my_engine".into()));
3814            },
3815            other => panic!("expected Pdp, got {:?}", other),
3816        }
3817    }
3818
3819    // ----- End-to-end with evaluator -----
3820
3821    #[tokio::test]
3822    async fn end_to_end_hr_compensation() {
3823        let yaml = r#"
3824routes:
3825  get_compensation:
3826    pre_invocation:
3827      - "require(authenticated)"
3828      - "require(role.hr | role.finance)"
3829      - "delegation.depth > 2: deny"
3830"#;
3831        let routes = compile_config(yaml).unwrap().routes;
3832        let route = routes.get("get_compensation").unwrap();
3833
3834        let pdp: std::sync::Arc<dyn crate::PdpResolver> = std::sync::Arc::new(NullPdpResolver);
3835        let plugins: std::sync::Arc<dyn crate::PluginInvoker> =
3836            std::sync::Arc::new(NullPluginInvoker);
3837        let delegations: std::sync::Arc<dyn crate::DelegationInvoker> =
3838            std::sync::Arc::new(crate::NoopDelegationInvoker);
3839
3840        // Alice: authenticated, hr role, depth=1 → allow.
3841        let mut bag = AttributeBag::new();
3842        bag.set("authenticated", true);
3843        bag.set("role.hr", true);
3844        bag.set("delegation.depth", 1_i64);
3845        assert_eq!(
3846            crate::evaluate_effects(
3847                &route.policy,
3848                &mut bag,
3849                &pdp,
3850                &plugins,
3851                &delegations,
3852                crate::DispatchPhase::Pre,
3853                &mut crate::route::RoutePayload::new(serde_json::Value::Null)
3854            )
3855            .await
3856            .decision,
3857            Decision::Allow,
3858        );
3859
3860        // Same Alice but depth=3 → deny (third rule fires).
3861        bag.set("delegation.depth", 3_i64);
3862        match crate::evaluate_effects(
3863            &route.policy,
3864            &mut bag,
3865            &pdp,
3866            &plugins,
3867            &delegations,
3868            crate::DispatchPhase::Pre,
3869            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3870        )
3871        .await
3872        .decision
3873        {
3874            Decision::Deny { rule_source, .. } => {
3875                assert!(
3876                    rule_source.contains("pre_invocation[2]"),
3877                    "expected pre_invocation[2], got {}",
3878                    rule_source
3879                );
3880            },
3881            d => panic!("expected Deny, got {:?}", d),
3882        }
3883
3884        // Bob: authenticated but neither hr nor finance → deny on rule 1.
3885        let mut bag = AttributeBag::new();
3886        bag.set("authenticated", true);
3887        bag.set("delegation.depth", 1_i64);
3888        match crate::evaluate_effects(
3889            &route.policy,
3890            &mut bag,
3891            &pdp,
3892            &plugins,
3893            &delegations,
3894            crate::DispatchPhase::Pre,
3895            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3896        )
3897        .await
3898        .decision
3899        {
3900            Decision::Deny { rule_source, .. } => {
3901                assert!(
3902                    rule_source.contains("pre_invocation[1]"),
3903                    "expected pre_invocation[1], got {}",
3904                    rule_source
3905                );
3906            },
3907            d => panic!("expected Deny, got {:?}", d),
3908        }
3909    }
3910
3911    // Test fixtures for async evaluator — null resolvers that nothing in
3912    // a pure-rule route should ever invoke.
3913    struct NullPdpResolver;
3914    #[async_trait::async_trait]
3915    impl crate::PdpResolver for NullPdpResolver {
3916        fn dialect(&self) -> crate::PdpDialect {
3917            crate::PdpDialect::Cedar
3918        }
3919        async fn evaluate(
3920            &self,
3921            _call: &crate::PdpCall,
3922            _bag: &crate::AttributeBag,
3923        ) -> Result<crate::PdpDecision, crate::PdpError> {
3924            panic!("NullPdpResolver should not be invoked in pure-rule tests");
3925        }
3926    }
3927
3928    struct NullPluginInvoker;
3929    #[async_trait::async_trait]
3930    impl crate::PluginInvoker for NullPluginInvoker {
3931        async fn invoke(
3932            &self,
3933            _name: &str,
3934            _bag: &crate::AttributeBag,
3935            _invocation: crate::PluginInvocation<'_>,
3936        ) -> Result<crate::PluginOutcome, crate::PluginError> {
3937            panic!("NullPluginInvoker should not be invoked in pure-rule tests");
3938        }
3939    }
3940
3941    // ----- Pipeline parsing -----
3942
3943    #[test]
3944    fn pipeline_simple_bare_stages() {
3945        let p = parse_pipeline("str").unwrap();
3946        assert_eq!(p.stages, vec![Stage::Type(TypeCheck::Str)]);
3947
3948        let p = parse_pipeline("omit").unwrap();
3949        assert_eq!(p.stages, vec![Stage::Omit]);
3950
3951        let p = parse_pipeline("hash").unwrap();
3952        assert_eq!(p.stages, vec![Stage::Hash]);
3953    }
3954
3955    #[test]
3956    fn pipeline_chains_split_on_pipe() {
3957        let p = parse_pipeline("str | mask(4)").unwrap();
3958        assert_eq!(
3959            p.stages,
3960            vec![Stage::Type(TypeCheck::Str), Stage::Mask { keep_last: 4 },]
3961        );
3962
3963        let p = parse_pipeline("int | 0..1M").unwrap();
3964        assert_eq!(
3965            p.stages,
3966            vec![
3967                Stage::Type(TypeCheck::Int),
3968                Stage::Range {
3969                    min: Some(0),
3970                    max: Some(1_000_000)
3971                },
3972            ]
3973        );
3974    }
3975
3976    #[test]
3977    fn pipeline_pipe_inside_parens_does_not_split() {
3978        // `redact(!a | b)` is one stage; the inner `|` is OR inside a
3979        // predicate condition, not a chain separator.
3980        let p = parse_pipeline("str | redact(!perm.view_ssn | role.admin)").unwrap();
3981        assert_eq!(p.stages.len(), 2);
3982        match &p.stages[1] {
3983            Stage::Redact { condition: Some(_) } => {},
3984            other => panic!("expected Redact with condition, got {:?}", other),
3985        }
3986    }
3987
3988    #[test]
3989    fn pipeline_length_constraints() {
3990        let p = parse_pipeline("len(..500)").unwrap();
3991        assert_eq!(
3992            p.stages,
3993            vec![Stage::Length {
3994                min: None,
3995                max: Some(500)
3996            }]
3997        );
3998        let p = parse_pipeline("len(10..50)").unwrap();
3999        assert_eq!(
4000            p.stages,
4001            vec![Stage::Length {
4002                min: Some(10),
4003                max: Some(50)
4004            }]
4005        );
4006        let p = parse_pipeline("len(8..)").unwrap();
4007        assert_eq!(
4008            p.stages,
4009            vec![Stage::Length {
4010                min: Some(8),
4011                max: None
4012            }]
4013        );
4014    }
4015
4016    #[test]
4017    fn pipeline_range_with_suffixes() {
4018        let p = parse_pipeline("0..10k").unwrap();
4019        assert_eq!(
4020            p.stages,
4021            vec![Stage::Range {
4022                min: Some(0),
4023                max: Some(10_000)
4024            }]
4025        );
4026        let p = parse_pipeline("0..1M").unwrap();
4027        assert_eq!(
4028            p.stages,
4029            vec![Stage::Range {
4030                min: Some(0),
4031                max: Some(1_000_000)
4032            }]
4033        );
4034        let p = parse_pipeline("..500").unwrap();
4035        assert_eq!(
4036            p.stages,
4037            vec![Stage::Range {
4038                min: None,
4039                max: Some(500)
4040            }]
4041        );
4042    }
4043
4044    #[test]
4045    fn pipeline_enum_unquoted_and_quoted() {
4046        let p = parse_pipeline("enum(low, medium, high)").unwrap();
4047        assert_eq!(
4048            p.stages,
4049            vec![Stage::Enum {
4050                values: vec!["low".into(), "medium".into(), "high".into()],
4051            }]
4052        );
4053        let p = parse_pipeline(r#"enum("a", "b")"#).unwrap();
4054        assert_eq!(
4055            p.stages,
4056            vec![Stage::Enum {
4057                values: vec!["a".into(), "b".into()],
4058            }]
4059        );
4060    }
4061
4062    #[test]
4063    fn pipeline_redact_with_predicate_condition() {
4064        let p = parse_pipeline("str | redact(!perm.view_ssn)").unwrap();
4065        assert_eq!(p.stages.len(), 2);
4066        match &p.stages[1] {
4067            Stage::Redact {
4068                condition: Some(Expression::Not(inner)),
4069            } => match inner.as_ref() {
4070                Expression::Condition(Condition::IsTrue { key }) => {
4071                    assert_eq!(key, "perm.view_ssn");
4072                },
4073                other => panic!("expected IsTrue(perm.view_ssn), got {:?}", other),
4074            },
4075            other => panic!("expected Redact with Not condition, got {:?}", other),
4076        }
4077    }
4078
4079    #[test]
4080    fn pipeline_taint_scopes() {
4081        let p = parse_pipeline("taint(PII)").unwrap();
4082        assert_eq!(
4083            p.stages,
4084            vec![Stage::Taint {
4085                label: "PII".into(),
4086                scopes: vec![TaintScope::Session],
4087            }]
4088        );
4089        let p = parse_pipeline("taint(PII, message)").unwrap();
4090        assert_eq!(
4091            p.stages,
4092            vec![Stage::Taint {
4093                label: "PII".into(),
4094                scopes: vec![TaintScope::Message],
4095            }]
4096        );
4097        let p = parse_pipeline("taint(PII, [session, message])").unwrap();
4098        assert_eq!(
4099            p.stages,
4100            vec![Stage::Taint {
4101                label: "PII".into(),
4102                scopes: vec![TaintScope::Session, TaintScope::Message],
4103            }]
4104        );
4105    }
4106
4107    #[test]
4108    fn pipeline_unknown_stage_rejected() {
4109        let err = parse_pipeline("nonsense").unwrap_err();
4110        assert!(format!("{}", err).contains("unknown stage"));
4111    }
4112
4113    #[test]
4114    fn pipeline_omit_with_args_rejected() {
4115        // omit has no conditional form per DSL §4.1.
4116        let err = parse_pipeline("omit(!perm.x)").unwrap_err();
4117        assert!(format!("{}", err).contains("omit takes no arguments"));
4118    }
4119
4120    // ----- YAML compilation with pipelines -----
4121
4122    #[test]
4123    fn compile_route_with_args_and_result() {
4124        let yaml = r#"
4125routes:
4126  get_compensation:
4127    args:
4128      employee_id: "uuid"
4129      amount: "int | 0..1M"
4130    result:
4131      ssn: "str | redact(!perm.view_ssn)"
4132      employee_id: "str | mask(4)"
4133      internal_notes: "omit"
4134"#;
4135        let routes = compile_config(yaml).unwrap().routes;
4136        let route = routes.get("get_compensation").expect("missing route");
4137        assert_eq!(route.args.len(), 2);
4138        assert_eq!(route.result.len(), 3);
4139
4140        // Pull out the ssn pipeline and confirm shape.
4141        let ssn = route.result.iter().find(|f| f.field == "ssn").unwrap();
4142        assert_eq!(ssn.pipeline.stages.len(), 2);
4143        assert!(matches!(
4144            ssn.pipeline.stages[0],
4145            Stage::Type(TypeCheck::Str)
4146        ));
4147        assert!(matches!(
4148            ssn.pipeline.stages[1],
4149            Stage::Redact { condition: Some(_) }
4150        ));
4151
4152        // declared_phases should include Result and Args now.
4153        let phases = route.declared_phases();
4154        assert!(phases.contains(crate::rules::Phase::Args));
4155        assert!(phases.contains(crate::rules::Phase::Result));
4156    }
4157
4158    #[test]
4159    fn compile_route_with_only_args_still_compiles() {
4160        // A route with no authorization block but with `args:`
4161        // validators is still an APL route (declared_phases non-empty).
4162        let yaml = r#"
4163routes:
4164  validate_only:
4165    args:
4166      employee_id: "uuid"
4167"#;
4168        let routes = compile_config(yaml).unwrap().routes;
4169        assert!(routes.contains_key("validate_only"));
4170    }
4171
4172    #[test]
4173    fn compile_propagates_pipeline_parse_errors() {
4174        let yaml = r#"
4175routes:
4176  bad:
4177    result:
4178      x: "nonsense"
4179"#;
4180        let err = compile_config(yaml).unwrap_err();
4181        assert!(format!("{}", err).contains("unknown stage"));
4182    }
4183
4184    // ----- plugins: block + route-level overrides -----
4185
4186    #[test]
4187    fn compile_captures_root_plugins_block_into_registry() {
4188        let yaml = r#"
4189plugins:
4190  - name: rate_limiter
4191    kind: native
4192    hooks: [tool_pre_invoke]
4193    capabilities: [read_subject]
4194    config:
4195      max_requests: 100
4196  - name: audit
4197    kind: native
4198    hooks: [tool_post_invoke]
4199routes:
4200  get_compensation:
4201    pre_invocation:
4202      - "plugin(rate_limiter)"
4203"#;
4204        let cfg = compile_config(yaml).unwrap();
4205        assert_eq!(cfg.plugins.len(), 2);
4206        let rl = cfg.plugins.get("rate_limiter").unwrap();
4207        assert_eq!(rl.kind, "native");
4208        assert_eq!(rl.hooks, vec!["tool_pre_invoke".to_string()]);
4209        assert_eq!(rl.capabilities, vec!["read_subject".to_string()]);
4210        // The route should still compile (uses plugin(rate_limiter)).
4211        assert!(cfg.routes.contains_key("get_compensation"));
4212    }
4213
4214    #[test]
4215    fn compile_captures_route_level_plugin_overrides() {
4216        let yaml = r#"
4217plugins:
4218  - name: rate_limiter
4219    kind: native
4220    hooks: [tool_pre_invoke]
4221    config:
4222      max_requests: 100
4223routes:
4224  hot_path:
4225    pre_invocation:
4226      - "plugin(rate_limiter)"
4227    plugins:
4228      rate_limiter:
4229        config:
4230          max_requests: 10
4231        on_error: ignore
4232"#;
4233        let cfg = compile_config(yaml).unwrap();
4234        let route = cfg.routes.get("hot_path").unwrap();
4235        let ovr = route.plugin_overrides.get("rate_limiter").unwrap();
4236        assert_eq!(ovr.on_error.as_deref(), Some("ignore"));
4237        let cfg_yaml = ovr.config.as_ref().unwrap();
4238        assert_eq!(
4239            cfg_yaml["max_requests"],
4240            serde_yaml::from_str::<serde_yaml::Value>("10").unwrap()
4241        );
4242
4243        // Verify EffectivePlugin::resolve sees the override.
4244        let eff = crate::plugin_decl::EffectivePlugin::resolve(
4245            "rate_limiter",
4246            &cfg.plugins,
4247            &route.plugin_overrides,
4248        )
4249        .unwrap();
4250        assert_eq!(eff.on_error, Some("ignore"));
4251        // Hooks NOT overridable — still from the global declaration.
4252        assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]);
4253    }
4254
4255    // ----- compile_policy_block_value (single-block compiler for visitors) -----
4256
4257    #[test]
4258    fn compile_policy_block_value_parses_apl_body() {
4259        let yaml = r#"
4260pre_invocation:
4261  - "require(authenticated)"
4262result:
4263  ssn: "redact(!perm.view_ssn)"
4264"#;
4265        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4266        let compiled =
4267            compile_policy_block_value("global.policy.all", &value).expect("compile block");
4268        assert_eq!(compiled.route_key, "global.policy.all");
4269        assert_eq!(compiled.policy.len(), 1);
4270        assert_eq!(compiled.result.len(), 1);
4271        assert_eq!(compiled.result[0].field, "ssn");
4272    }
4273
4274    #[test]
4275    fn compile_policy_block_value_null_is_empty_route() {
4276        let value = serde_yaml::Value::Null;
4277        let compiled =
4278            compile_policy_block_value("global.defaults.tool", &value).expect("compile null");
4279        assert!(compiled.declared_phases().is_empty());
4280        assert_eq!(compiled.route_key, "global.defaults.tool");
4281    }
4282
4283    #[test]
4284    fn compile_policy_block_value_threads_source_into_rule_paths() {
4285        let yaml = r#"
4286pre_invocation:
4287  - "require(authenticated)"
4288"#;
4289        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4290        let compiled = compile_policy_block_value("global.policies.hr", &value).expect("compile");
4291        match &compiled.policy[0] {
4292            crate::rules::Effect::When { source, .. } => {
4293                assert_eq!(source, "global.policies.hr.pre_invocation[0]");
4294            },
4295            other => panic!("expected When, got {:?}", other),
4296        }
4297    }
4298
4299    // ----- delegate: step parsing -----
4300
4301    #[test]
4302    fn parse_delegate_step_with_only_plugin() {
4303        let yaml = r#"
4304- delegate:
4305    plugin: workday-oauth
4306"#;
4307        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4308        let entry = &value.as_sequence().unwrap()[0];
4309        let step = parse_step(entry, "test.policy[0]").expect("parse");
4310        let crate::step::Step::Delegate(ds) = step else {
4311            panic!("expected Delegate, got {step:?}");
4312        };
4313        assert_eq!(ds.plugin_name, "workday-oauth");
4314        assert!(ds.config_override.is_none());
4315        assert!(ds.on_error.is_none());
4316        assert_eq!(ds.source, "test.policy[0]");
4317    }
4318
4319    #[test]
4320    fn parse_delegate_step_with_config_and_on_error() {
4321        let yaml = r#"
4322- delegate:
4323    plugin: workday-oauth
4324    config:
4325      target: workday-api
4326      permissions: [read_compensation]
4327    on_error: deny
4328"#;
4329        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4330        let entry = &value.as_sequence().unwrap()[0];
4331        let step = parse_step(entry, "test.policy[1]").expect("parse");
4332        let crate::step::Step::Delegate(ds) = step else {
4333            panic!("expected Delegate, got {step:?}");
4334        };
4335        assert_eq!(ds.plugin_name, "workday-oauth");
4336        assert_eq!(ds.on_error.as_deref(), Some("deny"));
4337        let cfg = ds.config_override.as_ref().expect("config_override set");
4338        let target = cfg
4339            .as_mapping()
4340            .and_then(|m| m.get(serde_yaml::Value::String("target".into())))
4341            .and_then(|v| v.as_str());
4342        assert_eq!(target, Some("workday-api"));
4343    }
4344
4345    #[test]
4346    fn parse_delegate_step_missing_plugin_errors() {
4347        let yaml = r#"
4348- delegate:
4349    config: { target: workday-api }
4350"#;
4351        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4352        let entry = &value.as_sequence().unwrap()[0];
4353        let err = parse_step(entry, "test.policy[0]").expect_err("missing plugin");
4354        let msg = format!("{err}");
4355        assert!(msg.contains("requires `plugin:"), "got: {msg}");
4356    }
4357
4358    #[test]
4359    fn parse_delegate_step_empty_plugin_errors() {
4360        let yaml = r#"
4361- delegate:
4362    plugin: ""
4363"#;
4364        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4365        let entry = &value.as_sequence().unwrap()[0];
4366        let err = parse_step(entry, "test.policy[0]").expect_err("empty plugin");
4367        let msg = format!("{err}");
4368        assert!(msg.contains("cannot be empty"), "got: {msg}");
4369    }
4370
4371    #[test]
4372    fn parse_delegate_step_non_string_on_error_errors() {
4373        let yaml = r#"
4374- delegate:
4375    plugin: workday-oauth
4376    on_error: 42
4377"#;
4378        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4379        let entry = &value.as_sequence().unwrap()[0];
4380        let err = parse_step(entry, "test.policy[0]").expect_err("non-string on_error");
4381        let msg = format!("{err}");
4382        assert!(msg.contains("on_error"), "got: {msg}");
4383    }
4384
4385    #[test]
4386    fn parse_delegate_step_non_map_body_errors() {
4387        let yaml = r#"
4388- delegate: workday-oauth
4389"#;
4390        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4391        let entry = &value.as_sequence().unwrap()[0];
4392        let err = parse_step(entry, "test.policy[0]").expect_err("non-map delegate body");
4393        let msg = format!("{err}");
4394        assert!(msg.contains("must be a map"), "got: {msg}");
4395    }
4396
4397    // ----- delegate(...) function-call string form -----
4398
4399    #[test]
4400    fn parse_delegate_string_bare_plugin_name() {
4401        let yaml = r#"- "delegate(workday-oauth)""#;
4402        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4403        let entry = &value.as_sequence().unwrap()[0];
4404        let step = parse_step(entry, "test.policy[0]").expect("parse");
4405        let crate::step::Step::Delegate(ds) = step else {
4406            panic!("expected Delegate, got {step:?}");
4407        };
4408        assert_eq!(ds.plugin_name, "workday-oauth");
4409        assert!(ds.config_override.is_none());
4410        assert!(ds.on_error.is_none());
4411        assert_eq!(ds.source, "test.policy[0]");
4412    }
4413
4414    #[test]
4415    fn parse_delegate_string_with_string_kwargs() {
4416        let yaml =
4417            r#"- "delegate(workday-oauth, target: workday-api, audience: https://workday.com)""#;
4418        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4419        let entry = &value.as_sequence().unwrap()[0];
4420        let step = parse_step(entry, "test.policy[0]").expect("parse");
4421        let crate::step::Step::Delegate(ds) = step else {
4422            panic!("expected Delegate, got {step:?}");
4423        };
4424        assert_eq!(ds.plugin_name, "workday-oauth");
4425        let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4426        assert_eq!(
4427            cfg.get(serde_yaml::Value::String("target".into()))
4428                .and_then(|v| v.as_str()),
4429            Some("workday-api"),
4430        );
4431        assert_eq!(
4432            cfg.get(serde_yaml::Value::String("audience".into()))
4433                .and_then(|v| v.as_str()),
4434            Some("https://workday.com"),
4435        );
4436    }
4437
4438    #[test]
4439    fn parse_delegate_string_with_list_kwarg() {
4440        let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation, write_notes])""#;
4441        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4442        let entry = &value.as_sequence().unwrap()[0];
4443        let step = parse_step(entry, "test.policy[0]").expect("parse");
4444        let crate::step::Step::Delegate(ds) = step else {
4445            panic!("expected Delegate");
4446        };
4447        let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4448        let perms = cfg
4449            .get(serde_yaml::Value::String("permissions".into()))
4450            .and_then(|v| v.as_sequence())
4451            .expect("permissions sequence");
4452        let names: Vec<&str> = perms.iter().filter_map(|v| v.as_str()).collect();
4453        assert_eq!(names, vec!["read_compensation", "write_notes"]);
4454    }
4455
4456    #[test]
4457    fn parse_delegate_string_on_error_pulled_out() {
4458        let yaml = r#"- "delegate(workday-oauth, target: workday-api, on_error: continue)""#;
4459        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4460        let entry = &value.as_sequence().unwrap()[0];
4461        let step = parse_step(entry, "test.policy[0]").expect("parse");
4462        let crate::step::Step::Delegate(ds) = step else {
4463            panic!("expected Delegate");
4464        };
4465        assert_eq!(ds.on_error.as_deref(), Some("continue"));
4466        // on_error must NOT also leak into config_override.
4467        let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4468        assert!(
4469            cfg.get(serde_yaml::Value::String("on_error".into()))
4470                .is_none(),
4471            "on_error must not appear in config_override"
4472        );
4473    }
4474
4475    #[test]
4476    fn parse_delegate_string_quoted_plugin_name() {
4477        // Quoting the plugin name is harmless — the parser strips
4478        // the wrapping quotes. Useful when the name contains
4479        // characters the bare-ident reader doesn't like.
4480        let yaml = r#"- 'delegate("workday-oauth")'"#;
4481        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4482        let entry = &value.as_sequence().unwrap()[0];
4483        let step = parse_step(entry, "test.policy[0]").expect("parse");
4484        let crate::step::Step::Delegate(ds) = step else {
4485            panic!("expected Delegate");
4486        };
4487        assert_eq!(ds.plugin_name, "workday-oauth");
4488    }
4489
4490    #[test]
4491    fn parse_delegate_string_quoted_value_preserves_internal_commas() {
4492        let yaml =
4493            r#"- 'delegate(workday-oauth, audience: "https://workday.com,backup.workday.com")'"#;
4494        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4495        let entry = &value.as_sequence().unwrap()[0];
4496        let step = parse_step(entry, "test.policy[0]").expect("parse");
4497        let crate::step::Step::Delegate(ds) = step else {
4498            panic!("expected Delegate");
4499        };
4500        let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4501        assert_eq!(
4502            cfg.get(serde_yaml::Value::String("audience".into()))
4503                .and_then(|v| v.as_str()),
4504            Some("https://workday.com,backup.workday.com"),
4505        );
4506    }
4507
4508    #[test]
4509    fn parse_delegate_string_empty_args_errors() {
4510        let yaml = r#"- "delegate()""#;
4511        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4512        let entry = &value.as_sequence().unwrap()[0];
4513        let err = parse_step(entry, "test.policy[0]").expect_err("empty args");
4514        let msg = format!("{err}");
4515        assert!(msg.contains("plugin name"), "got: {msg}");
4516    }
4517
4518    #[test]
4519    fn parse_delegate_string_plugin_kwarg_rejected() {
4520        // `plugin:` as a kwarg is ambiguous when the plugin name is
4521        // also the positional first arg — reject loudly.
4522        let yaml = r#"- "delegate(workday-oauth, plugin: other-thing)""#;
4523        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4524        let entry = &value.as_sequence().unwrap()[0];
4525        let err = parse_step(entry, "test.policy[0]").expect_err("plugin kwarg");
4526        let msg = format!("{err}");
4527        assert!(msg.contains("positional"), "got: {msg}");
4528    }
4529
4530    #[test]
4531    fn parse_delegate_string_kwarg_missing_colon_errors() {
4532        let yaml = r#"- "delegate(workday-oauth, target workday-api)""#;
4533        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4534        let entry = &value.as_sequence().unwrap()[0];
4535        let err = parse_step(entry, "test.policy[0]").expect_err("missing colon");
4536        let msg = format!("{err}");
4537        assert!(msg.contains("key: value"), "got: {msg}");
4538    }
4539
4540    #[test]
4541    fn parse_delegate_string_unbalanced_brackets_errors() {
4542        let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation)""#;
4543        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4544        let entry = &value.as_sequence().unwrap()[0];
4545        let err = parse_step(entry, "test.policy[0]").expect_err("unbalanced");
4546        let msg = format!("{err}");
4547        assert!(
4548            msg.contains("unmatched") || msg.contains("unbalanced"),
4549            "got: {msg}"
4550        );
4551    }
4552
4553    #[test]
4554    fn compile_route_mixed_string_and_map_delegate_forms() {
4555        // Both forms coexist in the same policy block — string form
4556        // for the compact case, map form for richer config.
4557        let yaml = r#"
4558routes:
4559  get_compensation:
4560    pre_invocation:
4561      - "require(role.hr)"
4562      - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])"
4563      - delegate:
4564          plugin: audit-receipt
4565          on_error: continue
4566          config:
4567            mode: trace
4568"#;
4569        let cfg = compile_config(yaml).expect("compile");
4570        let route = cfg.routes.get("get_compensation").expect("route");
4571        assert_eq!(route.policy.len(), 3);
4572
4573        // Step [1] is the string-form delegate.
4574        let crate::rules::Effect::Delegate(s1) = &route.policy[1] else {
4575            panic!("expected Delegate at policy[1]");
4576        };
4577        assert_eq!(s1.plugin_name, "workday-oauth");
4578        assert!(s1.on_error.is_none());
4579
4580        // Step [2] is the map-form delegate.
4581        let crate::rules::Effect::Delegate(s2) = &route.policy[2] else {
4582            panic!("expected Delegate at policy[2]");
4583        };
4584        assert_eq!(s2.plugin_name, "audit-receipt");
4585        assert_eq!(s2.on_error.as_deref(), Some("continue"));
4586    }
4587
4588    #[test]
4589    fn compile_route_with_delegate_in_policy_and_post_policy() {
4590        // End-to-end: delegate() lands in the right phase with the
4591        // right source path for diagnostics. Mixed with normal rules
4592        // to prove it doesn't perturb existing step parsing.
4593        let yaml = r#"
4594routes:
4595  get_compensation:
4596    pre_invocation:
4597      - "require(role.hr)"
4598      - delegate:
4599          plugin: workday-oauth
4600          config:
4601            target: workday-api
4602            permissions: [read_compensation]
4603      - "require(authenticated)"
4604    post_invocation:
4605      - delegate:
4606          plugin: audit-biscuit
4607          on_error: continue
4608"#;
4609        let cfg = compile_config(yaml).expect("compile");
4610        let route = cfg.routes.get("get_compensation").expect("route present");
4611        assert_eq!(route.policy.len(), 3);
4612
4613        // Policy step [1] is the delegate.
4614        let crate::rules::Effect::Delegate(ds) = &route.policy[1] else {
4615            panic!("expected Delegate at policy[1], got {:?}", route.policy[1]);
4616        };
4617        assert_eq!(ds.plugin_name, "workday-oauth");
4618        assert_eq!(ds.source, "get_compensation.pre_invocation[1]");
4619
4620        // post_policy[0] is the audit-biscuit delegate.
4621        let crate::rules::Effect::Delegate(post_ds) = &route.post_policy[0] else {
4622            panic!("expected Delegate at post_policy[0]");
4623        };
4624        assert_eq!(post_ds.plugin_name, "audit-biscuit");
4625        assert_eq!(post_ds.on_error.as_deref(), Some("continue"));
4626        assert_eq!(post_ds.source, "get_compensation.post_invocation[0]");
4627    }
4628
4629    // ----- validate(name) compile-time rejection (DSL spec §4.2) -----
4630
4631    #[test]
4632    fn parse_pipeline_rejects_validate_stage_at_compile_time() {
4633        // Named-validator dispatch isn't implemented; the parser
4634        // rejects `validate(...)` rather than letting it through to
4635        // a runtime stub that silently passes. Diagnostic points the
4636        // operator at the working alternatives.
4637        let err = parse_pipeline("str | validate(ssn_format) | mask(4)")
4638            .expect_err("validate(name) should fail to parse");
4639        let msg = format!("{err}");
4640        assert!(
4641            msg.contains("not implemented"),
4642            "diagnostic should explain that validate is unimplemented: {msg}",
4643        );
4644        assert!(
4645            msg.contains("regex") && msg.contains("plugin"),
4646            "diagnostic should suggest regex(...) and plugin(...): {msg}",
4647        );
4648        assert!(
4649            msg.contains("ssn_format"),
4650            "diagnostic should echo the rejected validator name: {msg}",
4651        );
4652    }
4653
4654    #[test]
4655    fn parse_pipeline_does_not_reject_other_stages() {
4656        // Sanity: the validate rejection doesn't catch unrelated
4657        // stages. A pipeline with no validate stage parses cleanly.
4658        let p = parse_pipeline("str | len(..100) | regex(\"^[A-Z]+$\") | mask(4)")
4659            .expect("non-validate pipeline parses");
4660        assert_eq!(p.stages.len(), 4);
4661    }
4662}