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, ElicitKind, ElicitStep, 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    // Elicitation sugar verbs — each desugars to `Step::Elicit` with a
926    // fixed `ElicitKind`. All-kwarg form (`from:`, `channel:`, …), same
927    // `key: value` shape as `delegate(...)`. Verbs are matched with the
928    // trailing `(` so `require_approval` / `require_attestation` /
929    // `require_review` / `require_step_up` don't collide on the
930    // `require_` prefix.
931    for (verb, kind) in ELICIT_VERBS {
932        if trimmed.starts_with(&format!("{verb}(")) {
933            let inside = extract_call_args(trimmed, verb).ok_or_else(|| ParseError::Rule {
934                rule: trimmed.to_string(),
935                msg: format!("malformed `{verb}(...)`"),
936            })?;
937            let parsed = parse_elicit_call_args(verb, &inside, source)?;
938            return Ok(Step::Elicit(ElicitStep {
939                kind: *kind,
940                plugin_name: parsed.plugin_name,
941                channel: parsed.channel,
942                from: parsed.from,
943                purpose: parsed.purpose,
944                scope: parsed.scope,
945                timeout: parsed.timeout,
946                config_override: parsed.config_override,
947                on_error: parsed.on_error,
948                source: source.to_string(),
949            }));
950        }
951    }
952
953    // Otherwise fall through to the rule parser — predicate-and-action.
954    let rule = parse_rule(trimmed, source)?;
955    Ok(Step::Rule(rule))
956}
957
958/// Intermediate shape produced by [`parse_delegate_call_args`]. The
959/// string-form parser fills this; the caller wraps into `Step::Delegate`
960/// with the source path it has in scope.
961struct ParsedDelegateCall {
962    plugin_name: String,
963    config_override: Option<serde_yaml::Value>,
964    on_error: Option<String>,
965}
966
967/// Parse the inside-parens of `delegate(name, key: value, key: [a, b], ...)`.
968///
969/// Grammar (informal):
970/// ```text
971/// delegate_args := plugin_name [, kwarg [, kwarg]*]
972/// plugin_name   := bare_ident_or_string
973/// kwarg         := key ":" value
974/// value         := scalar | "[" value (, value)* "]"
975/// scalar        := bare_word | number | "true" | "false" | quoted_string
976/// ```
977///
978/// Reserved keys consumed before going into `config_override`:
979///   - `on_error` — pulled out as `DelegateStep.on_error`
980///
981/// Everything else lands in `config_override` as a yaml mapping. Use
982/// the map form (`- delegate: { plugin: ..., config: { ... }, ... }`)
983/// for nested config shapes the flat kwarg parser doesn't handle.
984fn parse_delegate_call_args(inside: &str, source: &str) -> Result<ParsedDelegateCall, ParseError> {
985    let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule {
986        rule: format!("delegate({inside})"),
987        msg: format!("{source}: {msg}"),
988    })?;
989    let mut parts_iter = parts.into_iter();
990
991    let plugin_name = parts_iter
992        .next()
993        .map(|s| s.trim().to_string())
994        .filter(|s| !s.is_empty())
995        .ok_or_else(|| ParseError::Rule {
996            rule: format!("delegate({inside})"),
997            msg: format!(
998                "{source}: `delegate(...)` requires a plugin name as the first \
999                 positional argument"
1000            ),
1001        })?;
1002    // Strip wrapping quotes if the operator wrote `delegate("workday-oauth", ...)`.
1003    let plugin_name = strip_wrapping_quotes(&plugin_name).to_string();
1004    if plugin_name.is_empty() {
1005        return Err(ParseError::Rule {
1006            rule: format!("delegate({inside})"),
1007            msg: format!("{source}: `delegate(...)` plugin name cannot be empty"),
1008        });
1009    }
1010
1011    let mut on_error: Option<String> = None;
1012    let mut config_map = serde_yaml::Mapping::new();
1013
1014    for raw_kwarg in parts_iter {
1015        let kwarg = raw_kwarg.trim();
1016        if kwarg.is_empty() {
1017            continue;
1018        }
1019        let (key, value_str) = kwarg.split_once(':').ok_or_else(|| ParseError::Rule {
1020            rule: kwarg.to_string(),
1021            msg: format!(
1022                "{source}: `delegate(...)` kwarg `{kwarg}` must be `key: value` \
1023                     (use the map form for richer config)"
1024            ),
1025        })?;
1026        let key = key.trim();
1027        let value_str = value_str.trim();
1028        if key.is_empty() {
1029            return Err(ParseError::Rule {
1030                rule: kwarg.to_string(),
1031                msg: format!("{source}: `delegate(...)` kwarg has empty key"),
1032            });
1033        }
1034        if key == "on_error" {
1035            let val = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
1036                rule: kwarg.to_string(),
1037                msg: format!("{source}: on_error: {msg}"),
1038            })?;
1039            on_error = Some(
1040                val.as_str()
1041                    .ok_or_else(|| ParseError::Rule {
1042                        rule: kwarg.to_string(),
1043                        msg: format!("{source}: `on_error` must be a string"),
1044                    })?
1045                    .to_string(),
1046            );
1047            continue;
1048        }
1049        // Reject `plugin:` as a kwarg — the plugin name is the positional
1050        // first argument; allowing both would be ambiguous.
1051        if key == "plugin" {
1052            return Err(ParseError::Rule {
1053                rule: kwarg.to_string(),
1054                msg: format!(
1055                    "{source}: `plugin` is set as the first positional argument \
1056                     of `delegate(...)`; don't pass it as a kwarg too"
1057                ),
1058            });
1059        }
1060        let value = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
1061            rule: kwarg.to_string(),
1062            msg: format!("{source}: `{key}`: {msg}"),
1063        })?;
1064        config_map.insert(serde_yaml::Value::String(key.to_string()), value);
1065    }
1066
1067    let config_override = if config_map.is_empty() {
1068        None
1069    } else {
1070        Some(serde_yaml::Value::Mapping(config_map))
1071    };
1072
1073    Ok(ParsedDelegateCall {
1074        plugin_name,
1075        config_override,
1076        on_error,
1077    })
1078}
1079
1080/// Sugar verb → [`ElicitKind`] table. Each verb desugars to the same
1081/// `Step::Elicit` node with the kind fixed. See
1082/// `docs/apl-elicitation-hook-design.md` for the per-kind contracts.
1083const ELICIT_VERBS: &[(&str, ElicitKind)] = &[
1084    ("require_approval", ElicitKind::Approval),
1085    ("confirm", ElicitKind::Confirm),
1086    ("require_step_up", ElicitKind::StepUp),
1087    ("require_attestation", ElicitKind::Attestation),
1088    ("request_info", ElicitKind::Info),
1089    ("require_review", ElicitKind::Review),
1090];
1091
1092/// Intermediate shape produced by [`parse_elicit_call_args`]. The
1093/// caller fixes `kind` (from the verb) and `source`, then wraps into
1094/// `Step::Elicit`.
1095struct ParsedElicitCall {
1096    plugin_name: String,
1097    from: String,
1098    channel: Option<String>,
1099    purpose: Option<String>,
1100    scope: Option<String>,
1101    timeout: Option<String>,
1102    on_error: Option<String>,
1103    config_override: Option<serde_yaml::Value>,
1104}
1105
1106/// Parse the inside-parens of an elicitation verb,
1107/// `verb(plugin_name, from: ..., scope: ..., purpose: ..., timeout: ...)`.
1108///
1109/// Shape mirrors `delegate(...)`: the **first positional argument is the
1110/// `ElicitationHandler` plugin name** (the routing key, resolved
1111/// `name → entry`). `from` is a required kwarg (the approver, CIBA
1112/// `login_hint`). `channel` is an OPTIONAL audit label — not a routing
1113/// key. Recognized keys map to `ElicitStep` fields; `prompt` is an alias
1114/// for `purpose` (the elicitation-hook doc uses `prompt` for
1115/// `confirm`/`require_attestation`, `purpose` for `require_approval` —
1116/// both are the human-readable message). Everything else lands in
1117/// `config_override` (e.g. `details_link`) for the plugin.
1118fn parse_elicit_call_args(
1119    verb: &str,
1120    inside: &str,
1121    source: &str,
1122) -> Result<ParsedElicitCall, ParseError> {
1123    let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule {
1124        rule: format!("{verb}({inside})"),
1125        msg: format!("{source}: {msg}"),
1126    })?;
1127    let mut parts_iter = parts.into_iter();
1128
1129    // First positional argument: the plugin name (same as delegate()).
1130    let plugin_name = parts_iter
1131        .next()
1132        .map(|s| strip_wrapping_quotes(s.trim()).to_string())
1133        .filter(|s| !s.is_empty())
1134        .ok_or_else(|| ParseError::Rule {
1135            rule: format!("{verb}({inside})"),
1136            msg: format!(
1137                "{source}: `{verb}(...)` requires an ElicitationHandler plugin name \
1138                 as the first positional argument"
1139            ),
1140        })?;
1141
1142    let mut from: Option<String> = None;
1143    let mut channel: Option<String> = None;
1144    let mut purpose: Option<String> = None;
1145    let mut scope: Option<String> = None;
1146    let mut timeout: Option<String> = None;
1147    let mut on_error: Option<String> = None;
1148    let mut config_map = serde_yaml::Mapping::new();
1149
1150    // Coerce a parsed value to a string, erroring if it isn't one.
1151    let as_string = |value: serde_yaml::Value, key: &str| -> Result<String, ParseError> {
1152        match value {
1153            serde_yaml::Value::String(s) => Ok(s),
1154            other => Err(ParseError::Rule {
1155                rule: format!("{verb}(...)"),
1156                msg: format!("{source}: `{key}` must be a string, got {other:?}"),
1157            }),
1158        }
1159    };
1160
1161    for raw_kwarg in parts_iter {
1162        let kwarg = raw_kwarg.trim();
1163        if kwarg.is_empty() {
1164            continue;
1165        }
1166        let (key, value_str) = kwarg.split_once(':').ok_or_else(|| ParseError::Rule {
1167            rule: kwarg.to_string(),
1168            msg: format!(
1169                "{source}: `{verb}(...)` argument `{kwarg}` must be `key: value` \
1170                 (the plugin name is the first positional argument)"
1171            ),
1172        })?;
1173        let key = key.trim();
1174        let value_str = value_str.trim();
1175        if key.is_empty() {
1176            return Err(ParseError::Rule {
1177                rule: kwarg.to_string(),
1178                msg: format!("{source}: `{verb}(...)` argument has empty key"),
1179            });
1180        }
1181        let value = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
1182            rule: kwarg.to_string(),
1183            msg: format!("{source}: `{key}`: {msg}"),
1184        })?;
1185        match key {
1186            "from" => from = Some(as_string(value, "from")?),
1187            "channel" => channel = Some(as_string(value, "channel")?),
1188            "scope" => scope = Some(as_string(value, "scope")?),
1189            "purpose" | "prompt" => purpose = Some(as_string(value, key)?),
1190            "timeout" => timeout = Some(as_string(value, "timeout")?),
1191            "on_error" => on_error = Some(as_string(value, "on_error")?),
1192            // Reject `plugin:` as a kwarg — it's the positional arg.
1193            "plugin" => {
1194                return Err(ParseError::Rule {
1195                    rule: kwarg.to_string(),
1196                    msg: format!(
1197                        "{source}: the plugin name is the first positional argument \
1198                         of `{verb}(...)`; don't pass it as a kwarg too"
1199                    ),
1200                });
1201            },
1202            _ => {
1203                config_map.insert(serde_yaml::Value::String(key.to_string()), value);
1204            },
1205        }
1206    }
1207
1208    let from = from.ok_or_else(|| ParseError::Rule {
1209        rule: format!("{verb}({inside})"),
1210        msg: format!("{source}: `{verb}(...)` requires `from` (the approver)"),
1211    })?;
1212
1213    let config_override = if config_map.is_empty() {
1214        None
1215    } else {
1216        Some(serde_yaml::Value::Mapping(config_map))
1217    };
1218
1219    Ok(ParsedElicitCall {
1220        plugin_name,
1221        from,
1222        channel,
1223        purpose,
1224        scope,
1225        timeout,
1226        on_error,
1227        config_override,
1228    })
1229}
1230
1231/// Split a `key: value, key: value` string on TOP-LEVEL commas only —
1232/// commas inside `[...]` or quoted strings are preserved as part of
1233/// the surrounding value. Returns the comma-separated pieces (trimmed
1234/// at boundaries; whitespace inside values preserved).
1235///
1236/// Errors on unmatched brackets / unterminated quotes — those produce
1237/// confusing downstream errors otherwise.
1238fn split_top_level_commas(input: &str) -> Result<Vec<String>, String> {
1239    let mut parts = Vec::new();
1240    let mut current = String::new();
1241    let mut bracket_depth: usize = 0;
1242    let mut quote: Option<char> = None;
1243    let mut escape = false;
1244
1245    for ch in input.chars() {
1246        if escape {
1247            current.push(ch);
1248            escape = false;
1249            continue;
1250        }
1251        if let Some(q) = quote {
1252            current.push(ch);
1253            if ch == '\\' {
1254                escape = true;
1255            } else if ch == q {
1256                quote = None;
1257            }
1258            continue;
1259        }
1260        match ch {
1261            '"' | '\'' => {
1262                quote = Some(ch);
1263                current.push(ch);
1264            },
1265            '[' | '(' | '{' => {
1266                bracket_depth += 1;
1267                current.push(ch);
1268            },
1269            ']' | ')' | '}' => {
1270                bracket_depth = bracket_depth
1271                    .checked_sub(1)
1272                    .ok_or_else(|| format!("unmatched `{ch}` in delegate(...) args"))?;
1273                current.push(ch);
1274            },
1275            ',' if bracket_depth == 0 => {
1276                parts.push(std::mem::take(&mut current));
1277            },
1278            _ => current.push(ch),
1279        }
1280    }
1281    if quote.is_some() {
1282        return Err("unterminated quoted string in delegate(...) args".to_string());
1283    }
1284    if bracket_depth != 0 {
1285        return Err("unbalanced brackets in delegate(...) args".to_string());
1286    }
1287    parts.push(current);
1288    Ok(parts)
1289}
1290
1291/// Parse a single value from the function-call form: a scalar
1292/// (string / number / bool) or a list literal `[a, b, c]`. Use the
1293/// map form for anything more complex.
1294fn parse_delegate_value(s: &str) -> Result<serde_yaml::Value, String> {
1295    let trimmed = s.trim();
1296    if trimmed.is_empty() {
1297        return Err("empty value".to_string());
1298    }
1299    // List literal — recursive scalar parse on each element.
1300    if let Some(stripped) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
1301        let items = split_top_level_commas(stripped)?;
1302        let mut out = Vec::with_capacity(items.len());
1303        for item in items {
1304            let item = item.trim();
1305            if item.is_empty() {
1306                continue;
1307            }
1308            out.push(parse_delegate_value(item)?);
1309        }
1310        return Ok(serde_yaml::Value::Sequence(out));
1311    }
1312    // Quoted string — strip the surrounding quotes.
1313    if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2)
1314        || (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2)
1315    {
1316        return Ok(serde_yaml::Value::String(
1317            trimmed[1..trimmed.len() - 1].to_string(),
1318        ));
1319    }
1320    // Bool literals.
1321    if trimmed == "true" {
1322        return Ok(serde_yaml::Value::Bool(true));
1323    }
1324    if trimmed == "false" {
1325        return Ok(serde_yaml::Value::Bool(false));
1326    }
1327    // Numeric literals — integer first, then float.
1328    if let Ok(n) = trimmed.parse::<i64>() {
1329        return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(n)));
1330    }
1331    if let Ok(f) = trimmed.parse::<f64>() {
1332        return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(f)));
1333    }
1334    // Fallback: treat as bare string (e.g. `target: workday-api` →
1335    // value is `workday-api`). Same convention as YAML scalars.
1336    Ok(serde_yaml::Value::String(trimmed.to_string()))
1337}
1338
1339/// Strip a single pair of wrapping `"`/`'` if present. No-op on
1340/// unquoted input. Used for the positional plugin name where the
1341/// operator may have quoted to escape a hyphen or similar (`delegate("workday-oauth")`).
1342fn strip_wrapping_quotes(s: &str) -> &str {
1343    let bytes = s.as_bytes();
1344    if bytes.len() >= 2 {
1345        let first = bytes[0];
1346        let last = bytes[bytes.len() - 1];
1347        if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1348            return &s[1..s.len() - 1];
1349        }
1350    }
1351    s
1352}
1353
1354fn parse_step_map(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
1355    // Canonical structured rule: `- when: X\n  do: Y` (DSL §3.2).
1356    // Detected by the presence of *both* `when` and `do` keys — order
1357    // doesn't matter, and the map can carry extra keys for future
1358    // extensions (e.g. `id:` for rule identifiers).
1359    if has_key(m, "when") && has_key(m, "do") {
1360        return parse_when_do_rule(m, source);
1361    }
1362
1363    if m.len() != 1 {
1364        return Err(ParseError::Rule {
1365            rule: format!("{:?}", m),
1366            msg: "step map must have exactly one key (PDP call signature, \
1367                   `when:`/`do:`, or a `predicate: [effects...]` shorthand)"
1368                .into(),
1369        });
1370    }
1371    let (key_val, body_val) = m.iter().next().unwrap();
1372    let key = key_val.as_str().ok_or_else(|| ParseError::Rule {
1373        rule: format!("{:?}", key_val),
1374        msg: "PDP step key must be a string".into(),
1375    })?;
1376
1377    // Shorthand multi-effect map: `- "predicate": [list]` (DSL §3.1
1378    // multi-effect from one predicate). Detected by a single-key map
1379    // whose value is a YAML sequence. Single-effect map shorthand
1380    // (`- "predicate": deny`) still goes through `parse_step_string`
1381    // via the colon-split, NOT here — by the time we land in this
1382    // function, single-string values have already been resolved by
1383    // the caller's `parse_step` dispatch.
1384    if let serde_yaml::Value::Sequence(items) = body_val {
1385        // Skip PDP keys — `cedar:` / `opa:` etc. have list bodies for
1386        // `on_deny:` / `on_allow:` and need the existing handling.
1387        // Also skip `sequential:` / `parallel:` orchestration keys
1388        // since they take a list body and would otherwise be parsed
1389        // as predicates. The shorthand recognises only predicate-
1390        // shaped keys.
1391        let trimmed = key.trim();
1392        if trimmed != "delegate"
1393            && trimmed != "sequential"
1394            && trimmed != "parallel"
1395            && !is_known_pdp_dialect(trimmed)
1396        {
1397            return parse_shorthand_multi_effect(trimmed, items, source);
1398        }
1399    }
1400
1401    // `delegate:` is a special non-PDP step shape — branch before the
1402    // dialect logic. See `parse_delegate_step` for the expected body.
1403    if key.trim() == "delegate" {
1404        return parse_delegate_step(body_val, source);
1405    }
1406
1407    // E3: top-level `sequential:` / `parallel:` orchestration —
1408    // wrap the resulting Effect into an unconditional Rule so the
1409    // top-level Vec<Step> stays uniform.
1410    match key.trim() {
1411        "sequential" => {
1412            let effect = parse_sequential_effect(body_val, source)?;
1413            return Ok(Step::Rule(Rule {
1414                condition: Expression::Always,
1415                effects: vec![effect],
1416                source: source.to_string(),
1417            }));
1418        },
1419        "parallel" => {
1420            let effect = parse_parallel_effect(body_val, source)?;
1421            return Ok(Step::Rule(Rule {
1422                condition: Expression::Always,
1423                effects: vec![effect],
1424                source: source.to_string(),
1425            }));
1426        },
1427        _ => {},
1428    }
1429
1430    // Split the key into "dialect" + optional "(args)" portion.
1431    let (dialect_str, paren_args) = if let Some(open) = key.find('(') {
1432        let close = key.rfind(')').ok_or_else(|| ParseError::Rule {
1433            rule: key.to_string(),
1434            msg: "missing `)` in PDP call signature".into(),
1435        })?;
1436        let inside = key[open + 1..close].trim().to_string();
1437        (key[..open].trim(), Some(inside))
1438    } else {
1439        (key.trim(), None)
1440    };
1441
1442    let dialect = PdpDialect::from_key(dialect_str);
1443
1444    // Extract args + on_deny/on_allow.
1445    // Cedar: body map carries args fields directly + on_deny/on_allow.
1446    // Others: paren_args carries the call signature; body map is reactions only.
1447    let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
1448        rule: format!("{:?}", body_val),
1449        msg: format!(
1450            "`{}:` body must be a map (with on_deny / on_allow / args)",
1451            key
1452        ),
1453    })?;
1454
1455    let (args, on_deny, on_allow) = extract_pdp_body(body, paren_args.as_deref(), source)?;
1456
1457    Ok(Step::Pdp {
1458        call: PdpCall { dialect, args },
1459        on_deny,
1460        on_allow,
1461    })
1462}
1463
1464/// Parse a `delegate:` step body into a `Step::Delegate`. Accepted
1465/// YAML shape:
1466///
1467/// ```yaml
1468/// - delegate:
1469///     plugin: workday-oauth          # required — TokenDelegateHook plugin name
1470///     config:                         # optional — per-call config override
1471///       target: workday-api
1472///       permissions: [read_compensation]
1473///     on_error: deny                  # optional — deny | continue (default deny)
1474/// ```
1475///
1476// =====================================================================
1477// Effect / when-do parsing (E1)
1478// =====================================================================
1479
1480/// Lookup helper — `serde_yaml::Mapping::contains_key` only matches when
1481/// the search key is a `Value`, so we wrap the string conversion.
1482fn has_key(m: &serde_yaml::Mapping, key: &str) -> bool {
1483    m.contains_key(serde_yaml::Value::String(key.to_string()))
1484}
1485
1486/// Whether a top-level map key is a recognized PDP dialect. Used by
1487/// the shorthand-list detector to avoid mis-parsing a `cedar: [...]`
1488/// reaction list as a predicate-with-effects map.
1489fn is_known_pdp_dialect(key: &str) -> bool {
1490    let base = key.find('(').map(|i| &key[..i]).unwrap_or(key);
1491    matches!(base.trim(), "cedar" | "opa" | "authzen" | "nemo" | "cel")
1492}
1493
1494/// Parse the canonical `- when: X` `do: Y` rule form (DSL §3.2). `Y`
1495/// may be a single effect string (`do: deny`) or a list of effect
1496/// entries (`do: [plugin(audit), taint(X), deny('msg')]`). Map-form
1497/// effects (like a nested `delegate:` block) are allowed inside `do:`
1498/// via the same dispatch as top-level steps.
1499fn parse_when_do_rule(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
1500    // Validate keys — surface a useful error if there's stray content
1501    // beyond `when:` / `do:` (e.g. typo'd `whens:`). `id:` is reserved
1502    // for a future rule-identifier extension; tolerate it as a
1503    // pass-through for now.
1504    for (k, _) in m.iter() {
1505        let key = k.as_str().unwrap_or("");
1506        if !matches!(key, "when" | "do" | "id") {
1507            return Err(ParseError::Rule {
1508                rule: format!("{:?}", m),
1509                msg: format!(
1510                    "unexpected key `{}` in when/do rule (allowed: `when`, `do`, `id`)",
1511                    key
1512                ),
1513            });
1514        }
1515    }
1516
1517    let when_val = m
1518        .get(serde_yaml::Value::String("when".into()))
1519        .expect("has_key verified above");
1520    let predicate = when_val.as_str().ok_or_else(|| ParseError::Rule {
1521        rule: format!("{:?}", when_val),
1522        msg: "`when:` must be a predicate string".into(),
1523    })?;
1524    let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
1525        rule: format!("when: {}", predicate),
1526        msg: format!("{}", e),
1527    })?;
1528
1529    let do_val = m
1530        .get(serde_yaml::Value::String("do".into()))
1531        .expect("has_key verified above");
1532    let effects = parse_do_body(do_val, source)?;
1533    if effects.is_empty() {
1534        return Err(ParseError::Rule {
1535            rule: format!("{:?}", m),
1536            msg: "`do:` produced no effects".into(),
1537        });
1538    }
1539
1540    Ok(Step::Rule(Rule {
1541        condition,
1542        effects,
1543        source: source.to_string(),
1544    }))
1545}
1546
1547/// Parse the shorthand multi-effect map form: `- "predicate": [list]`
1548/// (DSL §3 example at line 386). Equivalent to the canonical
1549/// `when: predicate` `do: [list]` shape, just terser.
1550fn parse_shorthand_multi_effect(
1551    predicate: &str,
1552    effect_list: &[serde_yaml::Value],
1553    source: &str,
1554) -> Result<Step, ParseError> {
1555    let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
1556        rule: predicate.to_string(),
1557        msg: format!("{}", e),
1558    })?;
1559
1560    let mut effects = Vec::with_capacity(effect_list.len());
1561    for item in effect_list {
1562        effects.push(parse_effect_value(item, source)?);
1563    }
1564    if effects.is_empty() {
1565        return Err(ParseError::Rule {
1566            rule: predicate.to_string(),
1567            msg: "shorthand multi-effect map produced no effects".into(),
1568        });
1569    }
1570    Ok(Step::Rule(Rule {
1571        condition,
1572        effects,
1573        source: source.to_string(),
1574    }))
1575}
1576
1577/// Parse a `do:` body — single effect string, list of effects, or a
1578/// single map-shaped effect (`do: { parallel: [...] }`,
1579/// `do: { delegate: {...} }`, etc.).
1580fn parse_do_body(val: &serde_yaml::Value, source: &str) -> Result<Vec<Effect>, ParseError> {
1581    match val {
1582        serde_yaml::Value::String(s) => Ok(vec![parse_effect_string(s, source)?]),
1583        serde_yaml::Value::Sequence(items) => items
1584            .iter()
1585            .map(|item| parse_effect_value(item, source))
1586            .collect(),
1587        serde_yaml::Value::Mapping(_) => {
1588            // Single map-form effect — delegate, sequential, parallel.
1589            // Route through parse_effect_value which dispatches by key.
1590            Ok(vec![parse_effect_value(val, source)?])
1591        },
1592        other => Err(ParseError::Rule {
1593            rule: format!("{:?}", other),
1594            msg: "`do:` value must be a string, a list of effects, or an effect map".into(),
1595        }),
1596    }
1597}
1598
1599/// Parse one effect entry from a YAML value — string form or map form
1600/// (the latter for `delegate:` configs nested inside `do:`,
1601/// `sequential:`, and `parallel:`).
1602fn parse_effect_value(val: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1603    match val {
1604        serde_yaml::Value::String(s) => parse_effect_string(s, source),
1605        serde_yaml::Value::Mapping(m) => {
1606            // E3: `sequential:` / `parallel:` map forms — a single-key
1607            // map whose key is `sequential` / `parallel` and whose
1608            // value is a list of effects.
1609            if m.len() == 1 {
1610                let (k, v) = m.iter().next().unwrap();
1611                if let Some(key_str) = k.as_str() {
1612                    match key_str.trim() {
1613                        "sequential" => return parse_sequential_effect(v, source),
1614                        "parallel" => return parse_parallel_effect(v, source),
1615                        _ => {},
1616                    }
1617                }
1618            }
1619            // Otherwise reuse the existing step-map parser for
1620            // `delegate:`, `cedar:` etc. and collapse the Step.
1621            let step = parse_step(val, source)?;
1622            step_to_effect(step, source)
1623        },
1624        other => Err(ParseError::Rule {
1625            rule: format!("{:?}", other),
1626            msg: "effect entry must be a string or a map".into(),
1627        }),
1628    }
1629}
1630
1631/// Parse a `sequential: [list]` effect value. The body MUST be a list
1632/// (a single effect would defeat the purpose of explicit grouping).
1633fn parse_sequential_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1634    let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
1635        rule: format!("{:?}", body),
1636        msg: "`sequential:` body must be a list of effects".into(),
1637    })?;
1638    if items.is_empty() {
1639        return Err(ParseError::Rule {
1640            rule: format!("{:?}", body),
1641            msg: "`sequential:` body is empty".into(),
1642        });
1643    }
1644    let mut effects = Vec::with_capacity(items.len());
1645    for item in items {
1646        effects.push(parse_effect_value(item, source)?);
1647    }
1648    Ok(Effect::Sequential(effects))
1649}
1650
1651/// Parse a `parallel: [list]` effect value. The body MUST be a list,
1652/// and the parsed Effect is validated for parallel-purity (rejects
1653/// `FieldOp` / `Delegate` nested anywhere underneath).
1654fn parse_parallel_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1655    let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
1656        rule: format!("{:?}", body),
1657        msg: "`parallel:` body must be a list of effects".into(),
1658    })?;
1659    if items.is_empty() {
1660        return Err(ParseError::Rule {
1661            rule: format!("{:?}", body),
1662            msg: "`parallel:` body is empty".into(),
1663        });
1664    }
1665    let mut effects = Vec::with_capacity(items.len());
1666    for item in items {
1667        effects.push(parse_effect_value(item, source)?);
1668    }
1669    let parallel = Effect::Parallel(effects);
1670    parallel
1671        .validate_parallel_purity()
1672        .map_err(|msg| ParseError::Rule {
1673            rule: source.to_string(),
1674            msg,
1675        })?;
1676    Ok(parallel)
1677}
1678
1679/// Parse one effect string. Reuses [`parse_step_string`] for forms
1680/// shared with top-level steps (`plugin(...)`, `taint(...)`,
1681/// `delegate(...)`, predicate-action rules), then collapses the
1682/// resulting Step into an Effect.
1683fn parse_effect_string(s: &str, source: &str) -> Result<Effect, ParseError> {
1684    // Bare `allow` / `deny` / `deny('reason')` / `deny('reason', 'code')`
1685    // are accepted directly — they map to control effects with no
1686    // associated condition. Same parsing as the right-hand side of a
1687    // shorthand `predicate: action` rule.
1688    let trimmed = s.trim();
1689    if let Some(mut effects) = try_bare_action(trimmed) {
1690        if effects.len() == 1 {
1691            return Ok(effects.pop().unwrap());
1692        }
1693    }
1694    if let Some(effect) = try_parse_deny_call(trimmed, s)? {
1695        return Ok(effect);
1696    }
1697    // Content effect — `result.salary | redact`, `args.ssn | mask(4)`,
1698    // etc. Detected by a top-level `|` that splits a dotted path from
1699    // a pipe chain. The pipe is at top level (depth 0); commas /
1700    // parens inside the chain don't get confused.
1701    if let Some(field_op) = try_parse_field_op(trimmed, s)? {
1702        return Ok(field_op);
1703    }
1704    // Everything else (plugin/delegate/taint/rule) routes through the
1705    // step parser; collapse the result.
1706    let step = parse_step_string(s, source)?;
1707    step_to_effect(step, source)
1708}
1709
1710/// Parse `<path> | <stage> [| <stage>...]` into an `Effect::FieldOp`.
1711/// Returns `Ok(None)` when no top-level `|` is found so the caller can
1712/// fall through to other effect handlers.
1713fn try_parse_field_op(s: &str, rule: &str) -> Result<Option<Effect>, ParseError> {
1714    let Some(pipe_idx) = find_top_level_pipe(s) else {
1715        return Ok(None);
1716    };
1717    let path = s[..pipe_idx].trim();
1718    let chain = s[pipe_idx + 1..].trim();
1719    if path.is_empty() || chain.is_empty() {
1720        return Ok(None);
1721    }
1722    // The path must look like a dotted field reference. Anything else
1723    // (e.g. `role.hr | role.security` — though that wouldn't get here
1724    // because predicates don't appear in effect position) is a sign
1725    // the author meant something other than a field op.
1726    if !is_valid_field_path(path) {
1727        return Ok(None);
1728    }
1729    let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
1730        rule: rule.to_string(),
1731        msg: format!("field op `{}`: {}", path, e),
1732    })?;
1733    if pipeline.stages.is_empty() {
1734        return Err(ParseError::Rule {
1735            rule: rule.to_string(),
1736            msg: format!("field op `{}` has no stages", path),
1737        });
1738    }
1739    Ok(Some(Effect::FieldOp {
1740        path: path.to_string(),
1741        stages: pipeline.stages,
1742    }))
1743}
1744
1745/// Find the byte index of the first top-level `|` that isn't part of
1746/// `||` (logical-or inside a predicate). Depth-aware: skips `|` inside
1747/// `(...)` / `[...]` and inside single- or double-quoted strings.
1748fn find_top_level_pipe(s: &str) -> Option<usize> {
1749    let bytes = s.as_bytes();
1750    let mut depth: i32 = 0;
1751    let mut quote: Option<u8> = None;
1752    let mut i = 0;
1753    while i < bytes.len() {
1754        let b = bytes[i];
1755        if let Some(q) = quote {
1756            if b == b'\\' {
1757                i += 2;
1758                continue;
1759            }
1760            if b == q {
1761                quote = None;
1762            }
1763            i += 1;
1764            continue;
1765        }
1766        match b {
1767            b'\'' | b'"' => quote = Some(b),
1768            b'(' | b'[' => depth += 1,
1769            b')' | b']' => depth -= 1,
1770            b'|' if depth == 0 => {
1771                // Skip `||` — never appears in effect strings today
1772                // but defend against it anyway.
1773                if bytes.get(i + 1) == Some(&b'|') {
1774                    i += 2;
1775                    continue;
1776                }
1777                return Some(i);
1778            },
1779            _ => {},
1780        }
1781        i += 1;
1782    }
1783    None
1784}
1785
1786/// A field path is a dotted identifier sequence rooted at `args.` or
1787/// `result.`. Reject anything else early so a stray `role.hr | …` in
1788/// effect position fails fast.
1789fn is_valid_field_path(s: &str) -> bool {
1790    let Some(rest) = s
1791        .strip_prefix("args.")
1792        .or_else(|| s.strip_prefix("result."))
1793    else {
1794        return false;
1795    };
1796    !rest.is_empty()
1797        && rest
1798            .split('.')
1799            .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_alphanumeric() || c == '_'))
1800}
1801
1802/// Collapse a `Step` produced by the legacy step parser into an
1803/// `Effect`. The legitimate inputs are `Plugin`, `Delegate`, `Taint`,
1804/// and `Rule` (when a control action like `deny`/`allow` was parsed).
1805/// Anything else (`Pdp`) is rejected — nested PDP calls inside `do:`
1806/// are out of scope for E1.
1807/// Recursively map a top-level `Step` (as produced by `parse_step`) into
1808/// an `Effect`. Used at compile_apl_blocks during E4 — keeps `parse_step`'s
1809/// internal shape for the moment while the public IR collapses to Effect.
1810/// All five Step variants map cleanly: Rule → When, Pdp → Pdp (recursive
1811/// on reactions), Plugin/Delegate/Taint pass-through.
1812pub(crate) fn step_to_top_level_effect(step: Step) -> Result<Effect, ParseError> {
1813    match step {
1814        Step::Rule(rule) => Ok(Effect::When {
1815            condition: rule.condition,
1816            body: rule.effects,
1817            source: rule.source,
1818        }),
1819        Step::Pdp {
1820            call,
1821            on_allow,
1822            on_deny,
1823        } => {
1824            let on_allow = on_allow
1825                .into_iter()
1826                .map(step_to_top_level_effect)
1827                .collect::<Result<Vec<_>, _>>()?;
1828            let on_deny = on_deny
1829                .into_iter()
1830                .map(step_to_top_level_effect)
1831                .collect::<Result<Vec<_>, _>>()?;
1832            Ok(Effect::Pdp {
1833                call,
1834                on_allow,
1835                on_deny,
1836            })
1837        },
1838        Step::Plugin { name } => Ok(Effect::Plugin { name }),
1839        Step::Delegate(d) => Ok(Effect::Delegate(d)),
1840        Step::Elicit(e) => Ok(Effect::Elicit(e)),
1841        Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
1842    }
1843}
1844
1845fn step_to_effect(step: Step, source: &str) -> Result<Effect, ParseError> {
1846    match step {
1847        Step::Plugin { name } => Ok(Effect::Plugin { name }),
1848        Step::Delegate(d) => Ok(Effect::Delegate(d)),
1849        Step::Elicit(e) => Ok(Effect::Elicit(e)),
1850        Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
1851        Step::Rule(rule) => {
1852            // Nested when/do inside a do: list isn't supported in E1
1853            // — only control effects (allow/deny) flatten cleanly.
1854            if !matches!(rule.condition, Expression::Always) {
1855                return Err(ParseError::Rule {
1856                    rule: source.to_string(),
1857                    msg: "conditional rules nested inside `do:` are not supported in E1 \
1858                          (use a sibling `when:`/`do:` rule instead)"
1859                        .into(),
1860                });
1861            }
1862            if rule.effects.len() != 1 {
1863                return Err(ParseError::Rule {
1864                    rule: source.to_string(),
1865                    msg: format!(
1866                        "unconditional rule inside `do:` must produce exactly one \
1867                         effect, got {}",
1868                        rule.effects.len()
1869                    ),
1870                });
1871            }
1872            Ok(rule.effects.into_iter().next().unwrap())
1873        },
1874        Step::Pdp { .. } => Err(ParseError::Rule {
1875            rule: source.to_string(),
1876            msg: "PDP calls inside `do:` are not supported in E1 (use a sibling \
1877                  step instead)"
1878                .into(),
1879        }),
1880    }
1881}
1882
1883/// `config:` is opaque — the framework hands it to the named plugin
1884/// via the existing per-call config-override pathway. The plugin
1885/// owns the typed schema (target / audience / permissions / mode /
1886/// attenuation are conventions, not parser-enforced).
1887fn parse_delegate_step(body_val: &serde_yaml::Value, source: &str) -> Result<Step, ParseError> {
1888    let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
1889        rule: source.to_string(),
1890        msg: "`delegate:` body must be a map with `plugin:` and optional \
1891              `config:` / `on_error:`"
1892            .to_string(),
1893    })?;
1894
1895    let plugin = body
1896        .get(serde_yaml::Value::String("plugin".to_string()))
1897        .ok_or_else(|| ParseError::Rule {
1898            rule: source.to_string(),
1899            msg: "`delegate:` requires `plugin: <name>` referencing a \
1900                  top-level plugin registered under `token.delegate`"
1901                .to_string(),
1902        })?;
1903    let plugin_name = plugin
1904        .as_str()
1905        .ok_or_else(|| ParseError::Rule {
1906            rule: source.to_string(),
1907            msg: "`delegate.plugin` must be a string".to_string(),
1908        })?
1909        .to_string();
1910    if plugin_name.is_empty() {
1911        return Err(ParseError::Rule {
1912            rule: source.to_string(),
1913            msg: "`delegate.plugin` cannot be empty".to_string(),
1914        });
1915    }
1916
1917    let config_override = body
1918        .get(serde_yaml::Value::String("config".to_string()))
1919        .cloned();
1920
1921    let on_error = match body.get(serde_yaml::Value::String("on_error".to_string())) {
1922        Some(v) => Some(
1923            v.as_str()
1924                .ok_or_else(|| ParseError::Rule {
1925                    rule: source.to_string(),
1926                    msg: "`delegate.on_error` must be a string (e.g. `deny`, \
1927                          `continue`)"
1928                        .to_string(),
1929                })?
1930                .to_string(),
1931        ),
1932        None => None,
1933    };
1934
1935    Ok(Step::Delegate(DelegateStep {
1936        plugin_name,
1937        config_override,
1938        on_error,
1939        source: source.to_string(),
1940    }))
1941}
1942
1943/// Split a PDP body into (args, on_deny, on_allow).
1944///
1945/// If `paren_args` is `Some`, the call's args are the string inside the
1946/// parens (OPA-style) and the body map only carries reactions. If `None`,
1947/// the body map carries both args and reactions (Cedar-style); we strip
1948/// the reaction keys and treat what's left as args.
1949fn extract_pdp_body(
1950    body: &serde_yaml::Mapping,
1951    paren_args: Option<&str>,
1952    source: &str,
1953) -> Result<(serde_yaml::Value, Vec<Step>, Vec<Step>), ParseError> {
1954    let mut on_deny = Vec::new();
1955    let mut on_allow = Vec::new();
1956    let mut args_map = serde_yaml::Mapping::new();
1957
1958    for (k, v) in body {
1959        match k.as_str() {
1960            Some("on_deny") => {
1961                on_deny = parse_reaction_list(v, source, "on_deny")?;
1962            },
1963            Some("on_allow") => {
1964                on_allow = parse_reaction_list(v, source, "on_allow")?;
1965            },
1966            _ => {
1967                // Non-reaction key — part of args (Cedar-style).
1968                args_map.insert(k.clone(), v.clone());
1969            },
1970        }
1971    }
1972
1973    let args = match paren_args {
1974        Some(s) => serde_yaml::Value::String(s.to_string()),
1975        None => serde_yaml::Value::Mapping(args_map),
1976    };
1977
1978    Ok((args, on_deny, on_allow))
1979}
1980
1981fn parse_reaction_list(
1982    v: &serde_yaml::Value,
1983    source: &str,
1984    which: &str,
1985) -> Result<Vec<Step>, ParseError> {
1986    let list = v.as_sequence().ok_or_else(|| ParseError::Rule {
1987        rule: format!("{:?}", v),
1988        msg: format!("`{}:` must be a list of steps", which),
1989    })?;
1990    list.iter()
1991        .enumerate()
1992        .map(|(i, entry)| parse_step(entry, &format!("{}.{}[{}]", source, which, i)))
1993        .collect()
1994}
1995
1996/// Extract the args inside a call like `taint(X, Y)` or `plugin(foo)`.
1997/// Returns the substring between the outermost matching parens.
1998fn extract_call_args(line: &str, name: &str) -> Option<String> {
1999    let line = line.trim();
2000    if !line.starts_with(name) {
2001        return None;
2002    }
2003    let after = &line[name.len()..];
2004    if !after.starts_with('(') {
2005        return None;
2006    }
2007    // Find the matching close paren.
2008    let bytes = after.as_bytes();
2009    let mut depth = 0;
2010    for (i, &b) in bytes.iter().enumerate() {
2011        match b {
2012            b'(' => depth += 1,
2013            b')' => {
2014                depth -= 1;
2015                if depth == 0 {
2016                    // Anything after the close paren is invalid.
2017                    if after[i + 1..].trim().is_empty() {
2018                        return Some(after[1..i].to_string());
2019                    }
2020                    return None;
2021                }
2022            },
2023            _ => {},
2024        }
2025    }
2026    None
2027}
2028
2029// =====================================================================
2030// Pipe-chain parser (args: / result: field pipelines)
2031// =====================================================================
2032
2033/// Parse a pipe-chain string into a `Pipeline`.
2034///
2035/// Splits on `|` (outside parens/quotes), trims each stage, parses each.
2036/// Empty pipelines (empty string or whitespace) are valid — they produce
2037/// `Pipeline { stages: vec![] }`.
2038pub fn parse_pipeline(src: &str) -> Result<Pipeline, ParseError> {
2039    let mut pipeline = Pipeline::new();
2040    for seg in split_top_level(src.trim(), b'|') {
2041        let seg = seg.trim();
2042        if seg.is_empty() {
2043            continue;
2044        }
2045        pipeline.push(parse_stage(seg)?);
2046    }
2047    Ok(pipeline)
2048}
2049
2050/// Split `s` on `delim` at depth 0 — respects parens and quotes.
2051fn split_top_level(s: &str, delim: u8) -> Vec<&str> {
2052    let bytes = s.as_bytes();
2053    let mut out = Vec::new();
2054    let mut depth: i32 = 0;
2055    let mut in_quote: Option<u8> = None;
2056    let mut start = 0;
2057    for (i, &b) in bytes.iter().enumerate() {
2058        match (in_quote, b) {
2059            (Some(q), c) if c == q => in_quote = None,
2060            (Some(_), _) => {},
2061            (None, b'"') | (None, b'\'') => in_quote = Some(b),
2062            (None, b'(') | (None, b'[') => depth += 1,
2063            (None, b')') | (None, b']') => depth -= 1,
2064            (None, c) if c == delim && depth == 0 => {
2065                out.push(&s[start..i]);
2066                start = i + 1;
2067            },
2068            _ => {},
2069        }
2070    }
2071    out.push(&s[start..]);
2072    out
2073}
2074
2075fn parse_stage(src: &str) -> Result<Stage, ParseError> {
2076    let s = src.trim();
2077    let bad = |msg: &str| ParseError::Predicate {
2078        predicate: src.to_string(),
2079        msg: msg.to_string(),
2080    };
2081
2082    // Bare range literal: starts with `-`, digit, or `..`.
2083    if let Some(stage) = try_parse_range(s) {
2084        return Ok(stage);
2085    }
2086
2087    // Otherwise the stage starts with an identifier (keyword) optionally
2088    // followed by `(args)`.
2089    let (head, args) = split_head_args(s).ok_or_else(|| bad("expected stage identifier"))?;
2090
2091    match (head, args.as_deref()) {
2092        // ----- Bare validators / transforms / effects -----
2093        ("str", None) => Ok(Stage::Type(TypeCheck::Str)),
2094        ("int", None) => Ok(Stage::Type(TypeCheck::Int)),
2095        ("bool", None) => Ok(Stage::Type(TypeCheck::Bool)),
2096        ("float", None) => Ok(Stage::Type(TypeCheck::Float)),
2097        ("email", None) => Ok(Stage::Type(TypeCheck::Email)),
2098        ("url", None) => Ok(Stage::Type(TypeCheck::Url)),
2099        ("uuid", None) => Ok(Stage::Type(TypeCheck::Uuid)),
2100        ("redact", None) => Ok(Stage::Redact { condition: None }),
2101        ("omit", None) => Ok(Stage::Omit),
2102        ("hash", None) => Ok(Stage::Hash),
2103        // Scan placeholders parse as bare identifiers (DSL §4.5).
2104        ("pii.redact", None) => Ok(Stage::Scan {
2105            kind: ScanKind::PiiRedact,
2106        }),
2107        ("pii.detect", None) => Ok(Stage::Scan {
2108            kind: ScanKind::PiiDetect,
2109        }),
2110        ("injection.scan", None) => Ok(Stage::Scan {
2111            kind: ScanKind::InjectionScan,
2112        }),
2113
2114        // ----- Parameterized -----
2115        ("mask", Some(a)) => {
2116            let n: usize = a
2117                .trim()
2118                .parse()
2119                .map_err(|_| bad(&format!("mask(N) expects integer, got `{}`", a)))?;
2120            Ok(Stage::Mask { keep_last: n })
2121        },
2122        ("redact", Some(a)) => {
2123            // redact(!perm.view_ssn) — argument is a predicate expression.
2124            let cond = parse_predicate(a).map_err(|e| ParseError::Predicate {
2125                predicate: src.to_string(),
2126                msg: format!("invalid redact() condition: {}", e),
2127            })?;
2128            Ok(Stage::Redact {
2129                condition: Some(cond),
2130            })
2131        },
2132        ("hash", Some(_)) => Err(bad("hash takes no arguments")),
2133        ("omit", Some(_)) => Err(bad(
2134            "omit takes no arguments — for conditional omit, use a policy rule predicate",
2135        )),
2136        ("len", Some(a)) => {
2137            let (min, max) = parse_range_inner(a)
2138                .ok_or_else(|| bad(&format!("len(...) expects N..M range, got `{}`", a)))?;
2139            let to_usize = |v: i64| -> Result<usize, ParseError> {
2140                if v < 0 {
2141                    Err(bad("len bounds must be non-negative"))
2142                } else {
2143                    Ok(v as usize)
2144                }
2145            };
2146            Ok(Stage::Length {
2147                min: min.map(to_usize).transpose()?,
2148                max: max.map(to_usize).transpose()?,
2149            })
2150        },
2151        ("enum", Some(a)) => {
2152            let values = split_top_level(a, b',')
2153                .into_iter()
2154                .map(|v| {
2155                    let t = v.trim();
2156                    // Allow either bare identifier or quoted string.
2157                    if (t.starts_with('"') && t.ends_with('"'))
2158                        || (t.starts_with('\'') && t.ends_with('\''))
2159                    {
2160                        t[1..t.len() - 1].to_string()
2161                    } else {
2162                        t.to_string()
2163                    }
2164                })
2165                .filter(|s| !s.is_empty())
2166                .collect::<Vec<_>>();
2167            if values.is_empty() {
2168                return Err(bad("enum() requires at least one value"));
2169            }
2170            Ok(Stage::Enum { values })
2171        },
2172        ("regex", Some(a)) => {
2173            let pattern = a.trim();
2174            let pat = if (pattern.starts_with('"') && pattern.ends_with('"'))
2175                || (pattern.starts_with('\'') && pattern.ends_with('\''))
2176            {
2177                pattern[1..pattern.len() - 1].to_string()
2178            } else {
2179                pattern.to_string()
2180            };
2181            Ok(Stage::Regex { pattern: pat })
2182        },
2183        ("validate", Some(a)) => {
2184            // Named-validator dispatch (`validate(name)`) is in the
2185            // spec (DSL §4.2) but not implemented in this build —
2186            // the evaluator's no-op stub would silently let invalid
2187            // values through. Reject at compile time so operators
2188            // notice immediately and reach for one of the working
2189            // alternatives:
2190            //
2191            //   * `regex("pattern")` — inline named-regex equivalent
2192            //   * `plugin(name)` — full plugin dispatch for rich
2193            //     validation (Luhn, format-with-context, etc.)
2194            //
2195            // When the ValidatorRegistry slice lands, this arm flips
2196            // back to returning `Stage::Validate { name }`.
2197            Err(bad(&format!(
2198                "`validate({})` — named-validator dispatch is not implemented \
2199                 in this build. Use `regex(\"pattern\")` for a named-regex \
2200                 equivalent, or `plugin({})` for richer validation logic.",
2201                a.trim(),
2202                a.trim(),
2203            )))
2204        },
2205        // `run` is an alias for `plugin` (mirrors the policy-step alias).
2206        ("plugin" | "run", Some(a)) => {
2207            let name = a.trim();
2208            if name.is_empty() {
2209                // Mirror the empty-name guard in `parse_step_string` so
2210                // both the policy-step and field-stage paths reject a
2211                // nameless `plugin()` / `run()` with the same diagnostic.
2212                return Err(bad(&format!(
2213                    "`{head}(...)`: plugin name must not be empty"
2214                )));
2215            }
2216            Ok(Stage::Plugin {
2217                name: name.to_string(),
2218            })
2219        },
2220        ("taint", Some(a)) => parse_taint(a, src),
2221
2222        (other, _) => Err(bad(&format!("unknown stage `{}`", other))),
2223    }
2224}
2225
2226/// Try to parse `s` as a bare range literal: `0..100`, `..500`, `0..`, `0..1M`.
2227fn try_parse_range(s: &str) -> Option<Stage> {
2228    if !s.contains("..") {
2229        return None;
2230    }
2231    // Quick reject: must not start with a letter (would be a keyword).
2232    let first = s.as_bytes().first().copied()?;
2233    if first.is_ascii_alphabetic() || first == b'_' {
2234        return None;
2235    }
2236    let (min, max) = parse_range_inner(s)?;
2237    Some(Stage::Range { min, max })
2238}
2239
2240/// Parse the inside of a range expression: `N..M`, `..M`, `N..`.
2241/// Returns `Some((min, max))` if shape is valid; `None` if it's not a range.
2242fn parse_range_inner(s: &str) -> Option<(Option<i64>, Option<i64>)> {
2243    let dotdot = s.find("..")?;
2244    let left = s[..dotdot].trim();
2245    let right = s[dotdot + 2..].trim();
2246    let min = if left.is_empty() {
2247        None
2248    } else {
2249        Some(parse_numeric_with_suffix(left)?)
2250    };
2251    let max = if right.is_empty() {
2252        None
2253    } else {
2254        Some(parse_numeric_with_suffix(right)?)
2255    };
2256    if min.is_none() && max.is_none() {
2257        return None; // `..` alone isn't a useful range
2258    }
2259    Some((min, max))
2260}
2261
2262/// Parse a number with optional `k/K` (×1000) or `m/M` (×1_000_000) suffix.
2263fn parse_numeric_with_suffix(s: &str) -> Option<i64> {
2264    let s = s.trim();
2265    if s.is_empty() {
2266        return None;
2267    }
2268    let (num_part, mult) = match s.as_bytes().last().copied()? {
2269        b'k' | b'K' => (&s[..s.len() - 1], 1_000_i64),
2270        b'm' | b'M' => (&s[..s.len() - 1], 1_000_000_i64),
2271        _ => (s, 1_i64),
2272    };
2273    let n: i64 = num_part.parse().ok()?;
2274    n.checked_mul(mult)
2275}
2276
2277/// Split `s` (a stage form like `mask(4)`) into `(head, Some(args_inside_parens))`
2278/// or `(head, None)` if there are no parens.
2279fn split_head_args(s: &str) -> Option<(&str, Option<String>)> {
2280    if let Some(open) = s.find('(') {
2281        // Match the corresponding closing paren at depth 0.
2282        let bytes = s.as_bytes();
2283        let mut depth = 0;
2284        let mut close = None;
2285        for (i, &b) in bytes.iter().enumerate().skip(open) {
2286            match b {
2287                b'(' => depth += 1,
2288                b')' => {
2289                    depth -= 1;
2290                    if depth == 0 {
2291                        close = Some(i);
2292                        break;
2293                    }
2294                },
2295                _ => {},
2296            }
2297        }
2298        let close = close?;
2299        let head = s[..open].trim();
2300        if head.is_empty() {
2301            return None;
2302        }
2303        let args = s[open + 1..close].to_string();
2304        // Reject trailing garbage after the closing paren.
2305        if s[close + 1..].trim().is_empty() {
2306            Some((head, Some(args)))
2307        } else {
2308            None
2309        }
2310    } else {
2311        let head = s.trim();
2312        if head.is_empty() {
2313            None
2314        } else {
2315            Some((head, None))
2316        }
2317    }
2318}
2319
2320fn parse_taint(args: &str, src: &str) -> Result<Stage, ParseError> {
2321    // taint(label) | taint(label, session) | taint(label, [session, message])
2322    let parts = split_top_level(args, b',');
2323    if parts.is_empty() {
2324        return Err(ParseError::Predicate {
2325            predicate: src.to_string(),
2326            msg: "taint() requires at least a label".into(),
2327        });
2328    }
2329    let label = parts[0].trim().to_string();
2330    if label.is_empty() {
2331        return Err(ParseError::Predicate {
2332            predicate: src.to_string(),
2333            msg: "taint label must not be empty".into(),
2334        });
2335    }
2336
2337    let scopes = if parts.len() == 1 {
2338        vec![TaintScope::Session] // default per DSL §4.6
2339    } else {
2340        let scope_arg = parts[1..].join(",");
2341        let scope_arg = scope_arg.trim();
2342        if scope_arg.starts_with('[') && scope_arg.ends_with(']') {
2343            split_top_level(&scope_arg[1..scope_arg.len() - 1], b',')
2344                .into_iter()
2345                .map(|s| parse_taint_scope(s.trim(), src))
2346                .collect::<Result<Vec<_>, _>>()?
2347        } else {
2348            vec![parse_taint_scope(scope_arg, src)?]
2349        }
2350    };
2351
2352    Ok(Stage::Taint { label, scopes })
2353}
2354
2355fn parse_taint_scope(s: &str, src: &str) -> Result<TaintScope, ParseError> {
2356    match s {
2357        "session" => Ok(TaintScope::Session),
2358        "message" => Ok(TaintScope::Message),
2359        other => Err(ParseError::Predicate {
2360            predicate: src.to_string(),
2361            msg: format!(
2362                "unknown taint scope `{}` (expected `session` or `message`)",
2363                other
2364            ),
2365        }),
2366    }
2367}
2368
2369// =====================================================================
2370// YAML config
2371// =====================================================================
2372
2373/// Top-level config — only the bits step 5a understands.
2374///
2375/// `policy_evaluator:`, `imports:`, `global:`, `defaults:`, `tags:`,
2376/// `plugin_dirs:`, `plugin_settings:`, `version:` are all accepted and
2377/// stored opaquely; this struct deserializes leniently.
2378///
2379/// `plugins:` (the root block) is parsed into [`PluginDeclaration`]s so
2380/// the runtime can look up hook names + capabilities per plugin without
2381/// going back to the raw YAML.
2382#[derive(Debug, Default, Deserialize)]
2383pub struct ConfigYaml {
2384    #[serde(default)]
2385    pub routes: HashMap<String, RouteYaml>,
2386
2387    /// Root `plugins:` block — full declarations.
2388    #[serde(default)]
2389    pub plugins: Vec<PluginDeclaration>,
2390
2391    /// Anything else top-level goes here — picked up by later steps.
2392    #[serde(flatten)]
2393    pub other: HashMap<String, serde_yaml::Value>,
2394}
2395
2396#[derive(Debug, Default, Deserialize)]
2397pub struct RouteYaml {
2398    /// Flat pre-invocation authorization effects (was `policy:`). Each
2399    /// entry is either a string (rule / plugin / taint) or a single-key
2400    /// map (PDP call with reactions). See `parse_step`. Merged with any
2401    /// `authorization.pre_invocation` entries.
2402    #[serde(default)]
2403    pub pre_invocation: Vec<serde_yaml::Value>,
2404
2405    /// Flat post-invocation authorization effects (was `post_policy:`).
2406    /// Merged with any `authorization.post_invocation` entries.
2407    #[serde(default)]
2408    pub post_invocation: Vec<serde_yaml::Value>,
2409
2410    /// Nested `authorization:` block — `{ pre_invocation, post_invocation }`.
2411    /// Equivalent to the flat forms; entries from both are concatenated.
2412    #[serde(default)]
2413    pub authorization: Option<AuthorizationYaml>,
2414
2415    /// `args:` field → pipe-chain string. Compiled to per-field pipelines.
2416    #[serde(default)]
2417    pub args: HashMap<String, String>,
2418
2419    /// `result:` field → pipe-chain string. Compiled to per-field pipelines.
2420    #[serde(default)]
2421    pub result: HashMap<String, String>,
2422
2423    /// Per-route plugin overrides — only the spec-overridable keys
2424    /// (config / capabilities / on_error). Merged on top of the root
2425    /// `plugins:` declaration at dispatch time.
2426    #[serde(default)]
2427    pub plugins: HashMap<String, PluginOverride>,
2428
2429    /// Anything else on the route (meta, taint, when) — stashed. Also
2430    /// where renamed legacy keys land; `reject_legacy_keys` fails loudly
2431    /// on them so a dropped authz block never fails open.
2432    #[serde(flatten)]
2433    pub other: HashMap<String, serde_yaml::Value>,
2434}
2435
2436/// Nested `authorization:` block. Both sub-lists are optional and default
2437/// to empty; each is compiled the same way as the flat `pre_invocation:` /
2438/// `post_invocation:` forms.
2439///
2440/// `deny_unknown_fields` is load-bearing: without it a legacy key nested
2441/// under the wrapper (`authorization: { policy: [...] }`) would be silently
2442/// dropped by serde — both lists empty, no error, no authorization enforced
2443/// (a fail-open). The top-level `reject_legacy_keys` can't catch it because
2444/// the key is consumed as part of the `authorization` value and never lands
2445/// in `RouteYaml.other`. Denying unknown fields turns that into a load error
2446/// and also catches typos like `pre_invocaton:`. Safe here because the struct
2447/// has no `#[serde(flatten)]`.
2448#[derive(Debug, Default, Deserialize)]
2449#[serde(deny_unknown_fields)]
2450pub struct AuthorizationYaml {
2451    #[serde(default)]
2452    pub pre_invocation: Vec<serde_yaml::Value>,
2453
2454    #[serde(default)]
2455    pub post_invocation: Vec<serde_yaml::Value>,
2456}
2457
2458/// Output of [`compile_config`] — the routes that have APL blocks plus
2459/// the registry of plugin declarations from the root `plugins:` block.
2460///
2461/// The two travel together because the evaluator needs both: the route
2462/// gives it the steps to run, and the registry gives the dispatcher the
2463/// hook name / kind for each plugin name referenced by those steps.
2464#[derive(Debug, Default)]
2465pub struct CompiledConfig {
2466    pub routes: HashMap<String, CompiledRoute>,
2467    pub plugins: PluginRegistry,
2468}
2469
2470/// Compile a YAML config into a [`CompiledConfig`] (routes + plugin
2471/// registry).
2472///
2473/// Routes with no APL fields populated (no `authorization:` /
2474/// `pre_invocation:` / `post_invocation:` / `args:` / `result:`) are
2475/// **omitted from `routes`**, per apl-design §5
2476/// "Routes without APL blocks fall back to legacy plugin-chain execution."
2477/// A route-level `plugins:` override block alone is not enough — overrides
2478/// only have meaning when the route actually dispatches plugins via APL
2479/// steps, so an override-only route is treated as legacy.
2480pub fn compile_config(yaml: &str) -> Result<CompiledConfig, ParseError> {
2481    let cfg: ConfigYaml = serde_yaml::from_str(yaml)?;
2482    let mut routes = HashMap::with_capacity(cfg.routes.len());
2483    for (route_key, raw) in cfg.routes {
2484        if let Some(route) = compile_route(&route_key, raw)? {
2485            routes.insert(route_key, route);
2486        }
2487    }
2488    let mut plugins = PluginRegistry::with_capacity(cfg.plugins.len());
2489    for decl in cfg.plugins {
2490        // Duplicate plugin names: last-one-wins for v0. The spec doesn't
2491        // currently prescribe an error here; flag if real configs hit it.
2492        plugins.insert(decl.name.clone(), decl);
2493    }
2494    Ok(CompiledConfig { routes, plugins })
2495}
2496
2497/// Legacy field names, mapped to their replacements. Because unknown keys
2498/// land in `RouteYaml.other` via `#[serde(flatten)]`, a config still using
2499/// an old name would otherwise be *silently dropped* — dropping a `policy:`
2500/// block fails open (no authorization enforced). We reject them loudly
2501/// instead. `identity` is renamed in cpex-core config, not here.
2502const RENAMED_FIELDS: [(&str, &str); 2] = [
2503    (
2504        "policy",
2505        "authorization.pre_invocation (or flat pre_invocation)",
2506    ),
2507    (
2508        "post_policy",
2509        "authorization.post_invocation (or flat post_invocation)",
2510    ),
2511];
2512
2513/// Fail loudly if a stashed key is a renamed legacy field, so a dropped
2514/// authz block never fails open. Run before the has-APL gate.
2515fn reject_legacy_keys(
2516    location: &str,
2517    other: &HashMap<String, serde_yaml::Value>,
2518) -> Result<(), ParseError> {
2519    for (old, new) in RENAMED_FIELDS {
2520        if other.contains_key(old) {
2521            return Err(ParseError::RenamedField {
2522                location: location.to_string(),
2523                old: old.to_string(),
2524                new: new.to_string(),
2525            });
2526        }
2527    }
2528    Ok(())
2529}
2530
2531fn compile_route(route_key: &str, raw: RouteYaml) -> Result<Option<CompiledRoute>, ParseError> {
2532    // Reject legacy keys *before* the gate: a legacy-only route would
2533    // otherwise look empty and be silently omitted (fail open).
2534    reject_legacy_keys(route_key, &raw.other)?;
2535    let has_authz = raw
2536        .authorization
2537        .as_ref()
2538        .is_some_and(|a| !a.pre_invocation.is_empty() || !a.post_invocation.is_empty());
2539    let has_apl = !raw.pre_invocation.is_empty()
2540        || !raw.post_invocation.is_empty()
2541        || has_authz
2542        || !raw.args.is_empty()
2543        || !raw.result.is_empty();
2544    if !has_apl {
2545        return Ok(None);
2546    }
2547    Ok(Some(compile_apl_blocks(route_key, raw)?))
2548}
2549
2550/// Compile the APL bodies (authorization/args/result/plugins) of a
2551/// single block into a `CompiledRoute`. Doesn't gate on "has any APL
2552/// fields" — callers that need the gate (compile_config) check first.
2553/// `source` is the path prefix baked into rule/pipeline diagnostics
2554/// (e.g. `"global.policy.all"`, `"route.get_compensation"`).
2555///
2556/// The nested `authorization:` block and the flat `pre_invocation:` /
2557/// `post_invocation:` forms are equivalent alternatives. Declaring the same
2558/// phase in both forms on one section is rejected (it would run the effects
2559/// twice); only one form may carry a given phase per section.
2560fn compile_apl_blocks(source: &str, raw: RouteYaml) -> Result<CompiledRoute, ParseError> {
2561    reject_legacy_keys(source, &raw.other)?;
2562    let mut route = CompiledRoute::new(source);
2563    let (auth_pre, auth_post) = raw
2564        .authorization
2565        .map(|a| (a.pre_invocation, a.post_invocation))
2566        .unwrap_or_default();
2567    // Reject declaring the same phase both nested and flat on one section:
2568    // the two forms are alternatives, not additive, so merging them would
2569    // run each effect twice (a `run(...)` / `delegate(...)` double-fire).
2570    // Stacking across *different* scopes (e.g. global nested + route flat)
2571    // is fine — those are separate `compile_apl_blocks` calls.
2572    if !auth_pre.is_empty() && !raw.pre_invocation.is_empty() {
2573        return Err(ParseError::ConflictingAuthorizationForms {
2574            location: source.to_string(),
2575            phase: "pre_invocation".to_string(),
2576        });
2577    }
2578    if !auth_post.is_empty() && !raw.post_invocation.is_empty() {
2579        return Err(ParseError::ConflictingAuthorizationForms {
2580            location: source.to_string(),
2581            phase: "post_invocation".to_string(),
2582        });
2583    }
2584    for (i, entry) in auth_pre.iter().chain(raw.pre_invocation.iter()).enumerate() {
2585        let step = parse_step(entry, &format!("{}.pre_invocation[{}]", source, i))?;
2586        route.policy.push(step_to_top_level_effect(step)?);
2587    }
2588    for (i, entry) in auth_post
2589        .iter()
2590        .chain(raw.post_invocation.iter())
2591        .enumerate()
2592    {
2593        let step = parse_step(entry, &format!("{}.post_invocation[{}]", source, i))?;
2594        route.post_policy.push(step_to_top_level_effect(step)?);
2595    }
2596    for (field, chain) in &raw.args {
2597        let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
2598            rule: format!("args.{}: {:?}", field, chain),
2599            msg: format!("{}", e),
2600        })?;
2601        route.args.push(FieldRule {
2602            field: field.clone(),
2603            pipeline,
2604            source: format!("{}.args.{}", source, field),
2605        });
2606    }
2607    for (field, chain) in &raw.result {
2608        let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
2609            rule: format!("result.{}: {:?}", field, chain),
2610            msg: format!("{}", e),
2611        })?;
2612        route.result.push(FieldRule {
2613            field: field.clone(),
2614            pipeline,
2615            source: format!("{}.result.{}", source, field),
2616        });
2617    }
2618    route.plugin_overrides = raw.plugins;
2619    Ok(route)
2620}
2621
2622/// Compile a single APL policy block from a `serde_yaml::Value` whose
2623/// shape is the body of a route's `apl:` block:
2624///
2625/// ```yaml
2626/// args:
2627///   employee_id: "str"
2628/// authorization:
2629///   pre_invocation:
2630///     - "require(authenticated)"
2631///   post_invocation:
2632///     - "taint(forward)"
2633/// result:
2634///   ssn: "redact(!perm.view_ssn)"
2635/// ```
2636///
2637/// Used by external orchestrators (apl-cpex's `AplConfigVisitor`) that
2638/// have already located an APL block inside a larger unified-config
2639/// YAML. `source` is woven into per-rule / per-pipeline diagnostic paths.
2640/// Returns an empty `CompiledRoute` when the value is null or contains
2641/// no APL fields — callers that want a "is this empty?" gate can check
2642/// `declared_phases().is_empty()` on the result.
2643pub fn compile_policy_block_value(
2644    source: &str,
2645    block: &serde_yaml::Value,
2646) -> Result<CompiledRoute, ParseError> {
2647    if block.is_null() {
2648        return Ok(CompiledRoute::new(source));
2649    }
2650    let raw: RouteYaml = serde_yaml::from_value(block.clone())?;
2651    compile_apl_blocks(source, raw)
2652}
2653
2654// =====================================================================
2655// Tests
2656// =====================================================================
2657
2658#[cfg(test)]
2659mod tests {
2660    use super::*;
2661    use crate::attributes::AttributeBag;
2662    use crate::evaluator::Decision;
2663
2664    // ----- Lexer -----
2665
2666    #[test]
2667    fn lex_basic() {
2668        let toks = Lexer::new("delegation.depth > 2").tokenize_all().unwrap();
2669        assert_eq!(
2670            toks,
2671            vec![
2672                Tok::Ident("delegation.depth".into()),
2673                Tok::Gt,
2674                Tok::IntLit(2),
2675            ]
2676        );
2677    }
2678
2679    #[test]
2680    fn lex_strings_both_quotes() {
2681        let a = Lexer::new(r#""double""#).tokenize_all().unwrap();
2682        let b = Lexer::new(r#"'single'"#).tokenize_all().unwrap();
2683        assert_eq!(a, vec![Tok::StringLit("double".into())]);
2684        assert_eq!(b, vec![Tok::StringLit("single".into())]);
2685    }
2686
2687    #[test]
2688    fn lex_keywords_vs_idents() {
2689        let toks = Lexer::new("require(role.hr) & authenticated")
2690            .tokenize_all()
2691            .unwrap();
2692        assert_eq!(
2693            toks,
2694            vec![
2695                Tok::Require,
2696                Tok::LParen,
2697                Tok::Ident("role.hr".into()),
2698                Tok::RParen,
2699                Tok::And,
2700                Tok::Ident("authenticated".into()),
2701            ]
2702        );
2703    }
2704
2705    #[test]
2706    fn lex_rejects_single_equals() {
2707        let err = Lexer::new("a = 1").tokenize_all().unwrap_err();
2708        assert!(format!("{}", err).contains("expected `==`"));
2709    }
2710
2711    // ----- Predicate parser -----
2712
2713    #[test]
2714    fn pred_bare_identifier() {
2715        let e = parse_predicate("authenticated").unwrap();
2716        assert_eq!(
2717            e,
2718            Expression::Condition(Condition::IsTrue {
2719                key: "authenticated".into()
2720            })
2721        );
2722    }
2723
2724    #[test]
2725    fn pred_comparison() {
2726        let e = parse_predicate("delegation.depth > 2").unwrap();
2727        assert_eq!(
2728            e,
2729            Expression::Condition(Condition::Comparison {
2730                key: "delegation.depth".into(),
2731                op: CompareOp::Gt,
2732                value: Literal::Int(2),
2733            })
2734        );
2735    }
2736
2737    #[test]
2738    fn pred_contains() {
2739        let e = parse_predicate(r#"session.labels contains "PII""#).unwrap();
2740        assert_eq!(
2741            e,
2742            Expression::Condition(Condition::Comparison {
2743                key: "session.labels".into(),
2744                op: CompareOp::Contains,
2745                value: Literal::String("PII".into()),
2746            })
2747        );
2748    }
2749
2750    #[test]
2751    fn pred_precedence_or_lowest_and_middle_not_highest() {
2752        // `!a & b | c` should parse as `(!a & b) | c`.
2753        let e = parse_predicate("!a & b | c").unwrap();
2754        match e {
2755            Expression::Or(parts) => {
2756                assert_eq!(parts.len(), 2);
2757                match &parts[0] {
2758                    Expression::And(_) => {},
2759                    other => panic!("first OR branch should be AND, got {:?}", other),
2760                }
2761            },
2762            other => panic!("top-level should be OR, got {:?}", other),
2763        }
2764    }
2765
2766    #[test]
2767    fn pred_parens_override_precedence() {
2768        // `(role.finance | role.admin) & !delegated` from DSL §2.5.
2769        let e = parse_predicate("(role.finance | role.admin) & !delegated").unwrap();
2770        match e {
2771            Expression::And(parts) => {
2772                assert_eq!(parts.len(), 2);
2773                matches!(parts[0], Expression::Or(_));
2774                matches!(parts[1], Expression::Not(_));
2775            },
2776            other => panic!("expected top-level AND, got {:?}", other),
2777        }
2778    }
2779
2780    #[test]
2781    fn pred_require_rejected_as_predicate() {
2782        // require() is a rule-level shorthand per DSL §8, not a sub-predicate.
2783        // Trying to use it inside a predicate expression must fail clearly.
2784        let err = parse_predicate("require(authenticated)").unwrap_err();
2785        assert!(format!("{}", err).contains("rule-level shorthand"));
2786    }
2787
2788    #[test]
2789    fn rule_require_single_arg_desugars_to_isfalse_and_deny() {
2790        // require(X)  →  Rule { condition: IsFalse(X), action: Deny }   (DSL §8.1)
2791        let r = parse_rule("require(authenticated)", "test").unwrap();
2792        assert!(matches!(
2793            r.effects.as_slice(),
2794            [Effect::Deny {
2795                reason: None,
2796                code: None
2797            }]
2798        ));
2799        assert_eq!(
2800            r.condition,
2801            Expression::Condition(Condition::IsFalse {
2802                key: "authenticated".into()
2803            }),
2804        );
2805    }
2806
2807    #[test]
2808    fn rule_require_comma_is_and_desugars_to_or_of_isfalse() {
2809        // require(X, Y)  →  Or([IsFalse(X), IsFalse(Y)]) + Deny   (DSL §8.1)
2810        // i.e., "deny if any are falsy" = "any are falsy → deny"
2811        let r = parse_rule("require(role.hr, perm.view_ssn)", "test").unwrap();
2812        assert_eq!(
2813            r.condition,
2814            Expression::Or(vec![
2815                Expression::Condition(Condition::IsFalse {
2816                    key: "role.hr".into()
2817                }),
2818                Expression::Condition(Condition::IsFalse {
2819                    key: "perm.view_ssn".into()
2820                }),
2821            ]),
2822        );
2823    }
2824
2825    #[test]
2826    fn rule_require_pipe_is_or_desugars_to_and_of_isfalse() {
2827        // require(X | Y)  →  And([IsFalse(X), IsFalse(Y)]) + Deny   (DSL §8.1)
2828        // i.e., "deny only if all are falsy" = "all are falsy → deny"
2829        let r = parse_rule("require(role.finance | role.admin)", "test").unwrap();
2830        assert_eq!(
2831            r.condition,
2832            Expression::And(vec![
2833                Expression::Condition(Condition::IsFalse {
2834                    key: "role.finance".into()
2835                }),
2836                Expression::Condition(Condition::IsFalse {
2837                    key: "role.admin".into()
2838                }),
2839            ]),
2840        );
2841    }
2842
2843    #[test]
2844    fn rule_require_mixed_rejected() {
2845        let err = parse_rule("require(a, b | c)", "test").unwrap_err();
2846        assert!(format!("{}", err).contains("cannot mix"));
2847    }
2848
2849    #[test]
2850    fn pred_eq_with_ident_rhs_rejected_with_in_hint() {
2851        // `subject.type == allowed_types` — `==` doesn't take an ident RHS,
2852        // and the error should hint at `in` for set membership.
2853        let err = parse_predicate("subject.type == allowed_types").unwrap_err();
2854        let msg = format!("{}", err);
2855        assert!(msg.contains("RHS-as-identifier"));
2856        assert!(msg.contains("set membership use"));
2857    }
2858
2859    #[test]
2860    fn pred_in_set_basic() {
2861        let e = parse_predicate("subject.type in allowed_types").unwrap();
2862        assert_eq!(
2863            e,
2864            Expression::Condition(Condition::InSet {
2865                value_key: "subject.type".into(),
2866                set_key: "allowed_types".into(),
2867                negate: false,
2868            }),
2869        );
2870    }
2871
2872    #[test]
2873    fn pred_not_in_set() {
2874        let e = parse_predicate("subject.type not in blocked_types").unwrap();
2875        assert_eq!(
2876            e,
2877            Expression::Condition(Condition::InSet {
2878                value_key: "subject.type".into(),
2879                set_key: "blocked_types".into(),
2880                negate: true,
2881            }),
2882        );
2883    }
2884
2885    #[test]
2886    fn pred_exists_basic() {
2887        let e = parse_predicate("exists(args.amount)").unwrap();
2888        assert_eq!(
2889            e,
2890            Expression::Condition(Condition::Exists {
2891                key: "args.amount".into()
2892            }),
2893        );
2894    }
2895
2896    #[test]
2897    fn pred_exists_inside_compound() {
2898        // exists() is a sub-predicate (unlike require) — can nest in & / |.
2899        let e = parse_predicate("exists(args.amount) & args.amount > 0").unwrap();
2900        match e {
2901            Expression::And(parts) => {
2902                assert_eq!(parts.len(), 2);
2903                assert_eq!(
2904                    parts[0],
2905                    Expression::Condition(Condition::Exists {
2906                        key: "args.amount".into()
2907                    }),
2908                );
2909            },
2910            other => panic!("expected And, got {:?}", other),
2911        }
2912    }
2913
2914    #[test]
2915    fn pred_exists_requires_paren_and_ident() {
2916        assert!(parse_predicate("exists").is_err());
2917        assert!(parse_predicate("exists()").is_err());
2918        assert!(parse_predicate("exists(authenticated").is_err());
2919    }
2920
2921    #[test]
2922    fn pred_trailing_tokens_rejected() {
2923        let err = parse_predicate("a b").unwrap_err();
2924        assert!(format!("{}", err).contains("trailing"));
2925    }
2926
2927    // ----- Rule parser -----
2928
2929    #[test]
2930    fn rule_predicate_action_form() {
2931        let r = parse_rule("delegation.depth > 2: deny", "test").unwrap();
2932        match r.effects.as_slice() {
2933            [Effect::Deny { .. }] => {},
2934            other => panic!("expected [Deny], got {:?}", other),
2935        }
2936        match r.condition {
2937            Expression::Condition(Condition::Comparison { .. }) => {},
2938            other => panic!("expected Comparison, got {:?}", other),
2939        }
2940    }
2941
2942    #[test]
2943    fn rule_predicate_only_defaults_to_deny() {
2944        // DSL §2: missing action defaults to deny.
2945        let r = parse_rule("!authenticated", "test").unwrap();
2946        assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
2947    }
2948
2949    #[test]
2950    fn rule_explicit_allow() {
2951        let r = parse_rule("role.admin: allow", "test").unwrap();
2952        assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
2953    }
2954
2955    #[test]
2956    fn rule_bare_action_unconditional() {
2957        // Bare `- deny` and `- allow` are unconditional rules with
2958        // Expression::Always as the predicate (DSL §3.1).
2959        let r = parse_rule("deny", "test").unwrap();
2960        assert_eq!(r.condition, Expression::Always);
2961        assert!(matches!(
2962            r.effects.as_slice(),
2963            [Effect::Deny {
2964                reason: None,
2965                code: None
2966            }]
2967        ));
2968
2969        let r = parse_rule("allow", "test").unwrap();
2970        assert_eq!(r.condition, Expression::Always);
2971        assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
2972    }
2973
2974    #[test]
2975    fn rule_bare_deny_call_carries_reason_and_code() {
2976        // Unconditional `deny('reason')` / `deny('reason', 'code')` parse
2977        // to an Always-guarded Deny, so they're usable as bare rule lines
2978        // and as `on_deny:` / `on_allow:` reactions.
2979        let r = parse_rule("deny('nope')", "test").unwrap();
2980        assert_eq!(r.condition, Expression::Always);
2981        match r.effects.as_slice() {
2982            [Effect::Deny {
2983                reason: Some(reason),
2984                code: None,
2985            }] => assert_eq!(reason, "nope"),
2986            other => panic!(
2987                "expected [Deny{{reason: Some, code: None}}], got {:?}",
2988                other
2989            ),
2990        }
2991
2992        let r = parse_rule("deny('nope', 'cel.policy')", "test").unwrap();
2993        assert_eq!(r.condition, Expression::Always);
2994        match r.effects.as_slice() {
2995            [Effect::Deny {
2996                reason: Some(reason),
2997                code: Some(code),
2998            }] => {
2999                assert_eq!(reason, "nope");
3000                assert_eq!(code, "cel.policy");
3001            },
3002            other => panic!("expected [Deny{{reason, code}}], got {:?}", other),
3003        }
3004    }
3005
3006    #[test]
3007    fn rule_malformed_bare_deny_call_errors() {
3008        // A malformed `deny(...)` must surface its own error rather than
3009        // falling through to the predicate parser.
3010        let err = parse_rule("deny(unquoted)", "test").unwrap_err();
3011        assert!(
3012            matches!(err, ParseError::Rule { .. }),
3013            "expected ParseError::Rule, got {:?}",
3014            err
3015        );
3016    }
3017
3018    #[test]
3019    fn rule_step_kinds_rejected_clearly() {
3020        for s in [
3021            "plugin(rate_limiter)",
3022            "cedar:(action: read)",
3023            "opa(path)",
3024            "taint(audit)",
3025        ] {
3026            let err = parse_rule(s, "test").unwrap_err();
3027            assert!(
3028                matches!(err, ParseError::UnsupportedStep { .. }),
3029                "expected UnsupportedStep for `{}`, got {:?}",
3030                s,
3031                err
3032            );
3033        }
3034    }
3035
3036    #[test]
3037    fn rule_deny_with_unquoted_arg_rejected() {
3038        // `deny "reason"` (space-separated, no parens) is not a valid
3039        // form. The supported reason-carrying shape is
3040        // `deny('reason')` / `deny('reason', 'code')` per DSL §3 and
3041        // the E1 `code` extension.
3042        let err = parse_rule(r#"authenticated: deny "go away""#, "test").unwrap_err();
3043        assert!(format!("{}", err).contains("unsupported action"));
3044    }
3045
3046    #[test]
3047    fn rule_deny_with_quoted_reason_accepted() {
3048        // `deny('reason')` — single-arg form. Reason landing on the
3049        // effect; code defaulting to None.
3050        let r = parse_rule(r#"delegation.depth > 2: deny('too deep')"#, "test").unwrap();
3051        assert!(matches!(
3052            r.effects.as_slice(),
3053            [Effect::Deny { reason: Some(s), code: None }] if s == "too deep"
3054        ));
3055    }
3056
3057    #[test]
3058    fn rule_deny_with_reason_and_code_accepted() {
3059        // `deny('reason', 'code')` — E1 extension. Both reason and
3060        // author-supplied code surface in the violation.
3061        let r = parse_rule(
3062            r#"delegation.depth > 2: deny('too deep', 'delegation.depth_exceeded')"#,
3063            "test",
3064        )
3065        .unwrap();
3066        match r.effects.as_slice() {
3067            [Effect::Deny {
3068                reason: Some(reason),
3069                code: Some(code),
3070            }] => {
3071                assert_eq!(reason, "too deep");
3072                assert_eq!(code, "delegation.depth_exceeded");
3073            },
3074            other => panic!("expected Deny with reason+code, got {:?}", other),
3075        }
3076    }
3077
3078    #[test]
3079    fn rule_deny_with_too_many_args_rejected() {
3080        // Cap on positional args — `deny(reason, code)` is the limit.
3081        let err = parse_rule(r#"x: deny('a', 'b', 'c')"#, "test").unwrap_err();
3082        assert!(format!("{}", err).contains("at most two args"));
3083    }
3084
3085    #[test]
3086    fn rule_deny_with_unquoted_args_in_call_rejected() {
3087        // The args MUST be quoted; bare identifiers aren't legal.
3088        let err = parse_rule(r#"x: deny(bare, identifier)"#, "test").unwrap_err();
3089        assert!(format!("{}", err).contains("expected a quoted string"));
3090    }
3091
3092    // ----- E1: when/do canonical form -----
3093
3094    fn parse_step_yaml(yaml: &str) -> Result<Step, ParseError> {
3095        let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
3096        parse_step(&v, "test")
3097    }
3098
3099    #[test]
3100    fn when_do_single_effect_deny() {
3101        // do: deny  — single string value, no list.
3102        let step = parse_step_yaml("when: delegation.depth > 2\ndo: deny").unwrap();
3103        match step {
3104            Step::Rule(rule) => {
3105                assert!(matches!(
3106                    rule.condition,
3107                    Expression::Condition(Condition::Comparison { .. })
3108                ));
3109                assert!(matches!(
3110                    rule.effects.as_slice(),
3111                    [Effect::Deny {
3112                        reason: None,
3113                        code: None
3114                    }]
3115                ));
3116            },
3117            other => panic!("expected Step::Rule, got {:?}", other),
3118        }
3119    }
3120
3121    #[test]
3122    fn when_do_single_effect_deny_with_reason_and_code() {
3123        // The E1 `deny('reason', 'code')` extension works inside `do:` too.
3124        let step = parse_step_yaml(
3125            "when: delegation.depth > 2\ndo: deny('too deep', 'delegation.depth_exceeded')",
3126        )
3127        .unwrap();
3128        let Step::Rule(rule) = step else {
3129            panic!("expected Step::Rule");
3130        };
3131        match rule.effects.as_slice() {
3132            [Effect::Deny {
3133                reason: Some(r),
3134                code: Some(c),
3135            }] => {
3136                assert_eq!(r, "too deep");
3137                assert_eq!(c, "delegation.depth_exceeded");
3138            },
3139            other => panic!("expected Deny+reason+code, got {:?}", other),
3140        }
3141    }
3142
3143    #[test]
3144    fn when_do_multi_effect_list() {
3145        // The headline demo case: fan-out from one predicate.
3146        // do: [plugin(audit_logger), taint(unauth), deny('refused')]
3147        let yaml = r#"
3148when: "!role.hr"
3149do:
3150  - "plugin(audit_logger)"
3151  - "taint(unauth, session)"
3152  - "deny('refused', 'role.hr_required')"
3153"#;
3154        let step = parse_step_yaml(yaml).unwrap();
3155        let Step::Rule(rule) = step else {
3156            panic!("expected Step::Rule");
3157        };
3158        assert_eq!(rule.effects.len(), 3);
3159        assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
3160        assert!(matches!(
3161            rule.effects[1],
3162            Effect::Taint { ref label, .. } if label == "unauth"
3163        ));
3164        match &rule.effects[2] {
3165            Effect::Deny {
3166                reason: Some(r),
3167                code: Some(c),
3168            } => {
3169                assert_eq!(r, "refused");
3170                assert_eq!(c, "role.hr_required");
3171            },
3172            other => panic!("expected Deny+reason+code, got {:?}", other),
3173        }
3174    }
3175
3176    #[test]
3177    fn when_do_key_order_does_not_matter() {
3178        // YAML maps are unordered; `do:` first should parse the same.
3179        let step = parse_step_yaml("do: deny\nwhen: delegation.depth > 2").unwrap();
3180        assert!(matches!(step, Step::Rule(_)));
3181    }
3182
3183    #[test]
3184    fn when_do_with_unknown_key_rejected() {
3185        // Typo guard — surface unknown keys instead of silently dropping.
3186        let err = parse_step_yaml("when: x\ndo: deny\nwhne: typo").unwrap_err();
3187        assert!(format!("{}", err).contains("unexpected key"));
3188    }
3189
3190    #[test]
3191    fn when_do_empty_do_list_rejected() {
3192        // An empty `do:` is almost certainly an author mistake;
3193        // require at least one effect.
3194        let err = parse_step_yaml("when: x\ndo: []").unwrap_err();
3195        assert!(format!("{}", err).contains("no effects"));
3196    }
3197
3198    // ----- E1: shorthand multi-effect map (predicate: [list]) -----
3199
3200    #[test]
3201    fn shorthand_multi_effect_map() {
3202        // Shorthand for the canonical when/do form. The predicate is
3203        // the map's only key, the value is a list of effects.
3204        let yaml = r#"
3205"!role.hr":
3206  - "plugin(audit_logger)"
3207  - "deny('unauthorized')"
3208"#;
3209        let step = parse_step_yaml(yaml).unwrap();
3210        let Step::Rule(rule) = step else {
3211            panic!("expected Step::Rule");
3212        };
3213        assert_eq!(rule.effects.len(), 2);
3214        assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
3215        assert!(matches!(
3216            rule.effects[1],
3217            Effect::Deny { reason: Some(ref r), code: None } if r == "unauthorized"
3218        ));
3219    }
3220
3221    #[test]
3222    fn shorthand_multi_effect_map_with_nested_delegate() {
3223        // Map-form effects (like `delegate:`) work inside a shorthand
3224        // list, exercising the parse_effect_value path.
3225        let yaml = r#"
3226"role.hr":
3227  - delegate:
3228      plugin: workday-oauth
3229      config:
3230        audience: workday-api
3231  - "plugin(audit_logger)"
3232"#;
3233        let step = parse_step_yaml(yaml).unwrap();
3234        let Step::Rule(rule) = step else {
3235            panic!("expected Step::Rule");
3236        };
3237        assert_eq!(rule.effects.len(), 2);
3238        assert!(matches!(rule.effects[0], Effect::Delegate(_)));
3239        assert!(matches!(rule.effects[1], Effect::Plugin { .. }));
3240    }
3241
3242    #[test]
3243    fn cedar_with_list_body_still_parses_as_pdp() {
3244        // Regression guard — `cedar:` and other PDP keys whose body
3245        // happens to be list-shaped (e.g. when the author embeds a
3246        // bare reaction list) must NOT be reinterpreted as a
3247        // shorthand multi-effect map.
3248        //
3249        // Cedar bodies in production are maps with `action`/`resource`
3250        // keys — we don't actually accept a Sequence body, but the
3251        // shorthand-list detector explicitly excludes known PDP
3252        // dialect keys so the failure mode here is the existing PDP
3253        // body error, not a shorthand misparse.
3254        let err = parse_step_yaml("cedar: [oh no]").unwrap_err();
3255        // Existing PDP body validator complains about the shape —
3256        // proves we didn't try to read `cedar` as a predicate.
3257        assert!(format!("{}", err).contains("body must be a map"));
3258    }
3259
3260    #[test]
3261    fn shorthand_multi_effect_empty_list_rejected() {
3262        let err = parse_step_yaml(r#""x": []"#).unwrap_err();
3263        assert!(format!("{}", err).contains("no effects"));
3264    }
3265
3266    // ----- E2: content effects in do: (field pipe chains) -----
3267
3268    #[test]
3269    fn when_do_with_field_op_result_redact() {
3270        // The headline E2 case: `result.salary | redact` as an effect
3271        // inside a do: list, alongside other effect kinds.
3272        let yaml = r#"
3273when: "!perm.view_ssn"
3274do:
3275  - "plugin(audit_logger)"
3276  - "result.salary | redact"
3277"#;
3278        let step = parse_step_yaml(yaml).unwrap();
3279        let Step::Rule(rule) = step else {
3280            panic!("expected Step::Rule");
3281        };
3282        assert_eq!(rule.effects.len(), 2);
3283        assert!(matches!(rule.effects[0], Effect::Plugin { .. }));
3284        match &rule.effects[1] {
3285            Effect::FieldOp { path, stages } => {
3286                assert_eq!(path, "result.salary");
3287                assert_eq!(stages.len(), 1, "single `redact` stage");
3288            },
3289            other => panic!("expected FieldOp, got {:?}", other),
3290        }
3291    }
3292
3293    #[test]
3294    fn when_do_with_field_op_args_mask() {
3295        // `args.card_number | mask(4)` — args side + parametrised stage.
3296        let yaml = r#"
3297when: role.support
3298do: "args.card_number | mask(4)"
3299"#;
3300        let step = parse_step_yaml(yaml).unwrap();
3301        let Step::Rule(rule) = step else {
3302            panic!("expected Step::Rule");
3303        };
3304        match &rule.effects[..] {
3305            [Effect::FieldOp { path, stages }] => {
3306                assert_eq!(path, "args.card_number");
3307                assert_eq!(stages.len(), 1);
3308            },
3309            other => panic!("expected single FieldOp, got {:?}", other),
3310        }
3311    }
3312
3313    #[test]
3314    fn when_do_with_chained_field_op() {
3315        // Chained stages — type check + content effect. Uses stages
3316        // the pipeline parser actually knows about (`str` and `mask`).
3317        let yaml = r#"
3318when: role.support
3319do: "args.card_number | str | mask(4)"
3320"#;
3321        let step = parse_step_yaml(yaml).unwrap();
3322        let Step::Rule(rule) = step else {
3323            panic!("expected Step::Rule");
3324        };
3325        match &rule.effects[..] {
3326            [Effect::FieldOp { path, stages }] => {
3327                assert_eq!(path, "args.card_number");
3328                assert_eq!(stages.len(), 2, "two-stage chain");
3329            },
3330            other => panic!("expected single FieldOp, got {:?}", other),
3331        }
3332    }
3333
3334    #[test]
3335    fn field_stage_run_aliases_plugin() {
3336        // In a field pipeline, `run(name)` is the same plugin-transform
3337        // stage as `plugin(name)` — symmetry with the policy-step alias.
3338        let yaml = r#"
3339when: role.support
3340do: "args.card_number | run(luhn)"
3341"#;
3342        let step = parse_step_yaml(yaml).unwrap();
3343        let Step::Rule(rule) = step else {
3344            panic!("expected Step::Rule");
3345        };
3346        match &rule.effects[..] {
3347            [Effect::FieldOp { path, stages }] => {
3348                assert_eq!(path, "args.card_number");
3349                match &stages[..] {
3350                    [Stage::Plugin { name }] => assert_eq!(name, "luhn"),
3351                    other => panic!("expected [Stage::Plugin], got {:?}", other),
3352                }
3353            },
3354            other => panic!("expected single FieldOp, got {:?}", other),
3355        }
3356    }
3357
3358    #[test]
3359    fn field_stage_plugin_empty_name_is_rejected() {
3360        // `plugin()` / `run()` with no name in a field pipeline must be
3361        // rejected, mirroring the policy-step path (`parse_step_string`).
3362        // Previously the field-stage path accepted it as
3363        // `Stage::Plugin { name: "" }`.
3364        for verb in ["plugin", "run"] {
3365            let err = parse_stage(&format!("{verb}()")).expect_err("empty name must error");
3366            let msg = format!("{err}");
3367            assert!(
3368                msg.contains(verb) && msg.contains("must not be empty"),
3369                "{verb}(): expected verb-named empty-name error, got: {msg}"
3370            );
3371        }
3372    }
3373
3374    #[test]
3375    fn field_op_invalid_path_falls_through() {
3376        // `role.hr | redact` looks like a pipe chain but the path
3377        // doesn't start with `args.` / `result.`. We refuse to treat
3378        // it as a FieldOp; instead it falls through to the predicate
3379        // parser, which will fail with a more specific error.
3380        let yaml = r#"do: "role.hr | redact""#;
3381        let _ = parse_step_yaml(&format!("when: true\n{}", yaml));
3382        // The exact failure mode here isn't load-bearing — what matters
3383        // is we don't silently produce an unconditional FieldOp with a
3384        // bogus path. So just confirm we either error or produce
3385        // *something other than* a FieldOp.
3386        let step = parse_step_yaml("when: true\ndo: \"role.hr | redact\"");
3387        match step {
3388            Ok(Step::Rule(rule)) => {
3389                assert!(
3390                    !matches!(rule.effects.as_slice(), [Effect::FieldOp { .. }]),
3391                    "bare `role.hr` must NOT parse as a FieldOp path"
3392                );
3393            },
3394            Err(_) => {}, // also fine
3395            other => panic!("unexpected: {:?}", other),
3396        }
3397    }
3398
3399    #[test]
3400    fn field_op_empty_chain_rejected() {
3401        // `args.x |` (trailing pipe with nothing after) — author bug.
3402        let yaml = r#"when: true
3403do: "args.x | ""#;
3404        let _ = parse_step_yaml(yaml); // shape varies by YAML parser, just ensure no panic
3405    }
3406
3407    #[test]
3408    fn shorthand_multi_effect_with_field_op() {
3409        // Shorthand `predicate: [list]` with a content effect.
3410        let yaml = r#"
3411"!perm.view_ssn":
3412  - "plugin(audit_logger)"
3413  - "result.ssn | redact"
3414"#;
3415        let step = parse_step_yaml(yaml).unwrap();
3416        let Step::Rule(rule) = step else {
3417            panic!("expected Step::Rule");
3418        };
3419        assert_eq!(rule.effects.len(), 2);
3420        assert!(matches!(rule.effects[1], Effect::FieldOp { .. }));
3421    }
3422
3423    #[test]
3424    fn find_top_level_pipe_skips_inside_parens() {
3425        // Top-level `|` between path and chain → returns its index.
3426        // Inner `|` inside `(...)` or quotes is ignored.
3427        assert_eq!(find_top_level_pipe("args.x | mask(4)"), Some(7));
3428        assert_eq!(find_top_level_pipe("validate(luhn)"), None);
3429        assert_eq!(find_top_level_pipe(r#"args.x | mask("a|b")"#), Some(7));
3430        // No top-level pipe even with a `|` inside the parameter set.
3431        assert_eq!(find_top_level_pipe("mask(a|b)"), None);
3432    }
3433
3434    // ----- E3: sequential: / parallel: parsing -----
3435
3436    #[test]
3437    fn top_level_sequential() {
3438        // `- sequential: [list]` as a top-level policy step.
3439        let yaml = r#"
3440sequential:
3441  - "plugin(rate_limiter)"
3442  - "plugin(audit_logger)"
3443"#;
3444        let step = parse_step_yaml(yaml).unwrap();
3445        let Step::Rule(rule) = step else {
3446            panic!("expected Rule");
3447        };
3448        assert!(matches!(rule.condition, Expression::Always));
3449        match rule.effects.as_slice() {
3450            [Effect::Sequential(inner)] => {
3451                assert_eq!(inner.len(), 2);
3452                assert!(matches!(inner[0], Effect::Plugin { .. }));
3453                assert!(matches!(inner[1], Effect::Plugin { .. }));
3454            },
3455            other => panic!("expected single Sequential effect, got {:?}", other),
3456        }
3457    }
3458
3459    #[test]
3460    fn top_level_parallel() {
3461        let yaml = r#"
3462parallel:
3463  - "plugin(pii_scanner)"
3464  - "plugin(nemo_guardrails)"
3465"#;
3466        let step = parse_step_yaml(yaml).unwrap();
3467        let Step::Rule(rule) = step else {
3468            panic!("expected Rule");
3469        };
3470        match rule.effects.as_slice() {
3471            [Effect::Parallel(inner)] => {
3472                assert_eq!(inner.len(), 2);
3473            },
3474            other => panic!("expected single Parallel effect, got {:?}", other),
3475        }
3476    }
3477
3478    #[test]
3479    fn parallel_inside_do_body() {
3480        // The DSL spec's "Conditional parallel" example: a `when:`
3481        // rule whose `do:` is a single parallel block.
3482        let yaml = r#"
3483when: args.include_ssn == true
3484do:
3485  parallel:
3486    - "plugin(pii_scanner)"
3487    - "plugin(nemo_guardrails)"
3488"#;
3489        let step = parse_step_yaml(yaml).unwrap();
3490        let Step::Rule(rule) = step else {
3491            panic!("expected Rule");
3492        };
3493        match rule.effects.as_slice() {
3494            [Effect::Parallel(inner)] => assert_eq!(inner.len(), 2),
3495            other => panic!("expected Parallel in do:, got {:?}", other),
3496        }
3497    }
3498
3499    #[test]
3500    fn parallel_rejects_field_op_at_parse_time() {
3501        // FieldOp inside Parallel should fail at parse, not at runtime.
3502        let yaml = r#"
3503parallel:
3504  - "plugin(audit)"
3505  - "args.ssn | redact"
3506"#;
3507        let err = parse_step_yaml(yaml).unwrap_err();
3508        assert!(format!("{}", err).contains("mutation"), "got: {}", err);
3509    }
3510
3511    #[test]
3512    fn parallel_rejects_delegate_at_parse_time() {
3513        let yaml = r#"
3514parallel:
3515  - "plugin(audit)"
3516  - "delegate(workday)"
3517"#;
3518        let err = parse_step_yaml(yaml).unwrap_err();
3519        assert!(format!("{}", err).contains("mutation"));
3520    }
3521
3522    #[test]
3523    fn sequential_allows_mutations() {
3524        // The escape valve — Sequential lets mutations through.
3525        let yaml = r#"
3526sequential:
3527  - "args.ssn | redact"
3528  - "plugin(audit)"
3529"#;
3530        let step = parse_step_yaml(yaml).unwrap();
3531        let Step::Rule(rule) = step else {
3532            panic!("expected Rule")
3533        };
3534        match rule.effects.as_slice() {
3535            [Effect::Sequential(inner)] => {
3536                assert!(matches!(inner[0], Effect::FieldOp { .. }));
3537                assert!(matches!(inner[1], Effect::Plugin { .. }));
3538            },
3539            other => panic!("got {:?}", other),
3540        }
3541    }
3542
3543    #[test]
3544    fn parallel_empty_list_rejected() {
3545        let err = parse_step_yaml("parallel: []").unwrap_err();
3546        assert!(format!("{}", err).contains("empty"));
3547    }
3548
3549    #[test]
3550    fn sequential_empty_list_rejected() {
3551        let err = parse_step_yaml("sequential: []").unwrap_err();
3552        assert!(format!("{}", err).contains("empty"));
3553    }
3554
3555    #[test]
3556    fn nested_orchestration() {
3557        // `sequential: [plugin, parallel: [plugin, plugin]]` — the
3558        // parser handles arbitrary nesting through parse_effect_value.
3559        let yaml = r#"
3560sequential:
3561  - "plugin(rate_limiter)"
3562  - parallel:
3563      - "plugin(pii_scanner)"
3564      - "plugin(nemo)"
3565"#;
3566        let step = parse_step_yaml(yaml).unwrap();
3567        let Step::Rule(rule) = step else {
3568            panic!("expected Rule")
3569        };
3570        let Effect::Sequential(outer) = &rule.effects[0] else {
3571            panic!("expected Sequential");
3572        };
3573        assert_eq!(outer.len(), 2);
3574        assert!(matches!(outer[0], Effect::Plugin { .. }));
3575        match &outer[1] {
3576            Effect::Parallel(inner) => assert_eq!(inner.len(), 2),
3577            other => panic!("expected nested Parallel, got {:?}", other),
3578        }
3579    }
3580
3581    // ----- Colon-splitting edge cases -----
3582
3583    #[test]
3584    fn split_respects_quotes_and_parens() {
3585        // The `:` inside parens / quotes shouldn't be the separator.
3586        let r = parse_rule(r#"session.labels contains "a:b": deny"#, "test").unwrap();
3587        assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
3588        if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition {
3589            assert_eq!(value, Literal::String("a:b".into()));
3590        } else {
3591            panic!("expected Comparison");
3592        }
3593    }
3594
3595    // ----- YAML compilation -----
3596
3597    #[test]
3598    fn compile_simple_route() {
3599        let yaml = r#"
3600routes:
3601  get_compensation:
3602    pre_invocation:
3603      - "require(authenticated)"
3604      - "require(role.hr | role.finance)"
3605      - "delegation.depth > 2 & include_ssn: deny"
3606"#;
3607        let routes = compile_config(yaml).unwrap().routes;
3608        let route = routes.get("get_compensation").expect("route missing");
3609        assert_eq!(route.policy.len(), 3);
3610        assert!(route
3611            .declared_phases()
3612            .contains(crate::rules::Phase::Policy));
3613    }
3614
3615    #[test]
3616    fn authorization_nested_and_flat_forms_are_equivalent() {
3617        // The nested `authorization:` block and the flat
3618        // `pre_invocation:` / `post_invocation:` forms must compile to
3619        // the same route.
3620        let nested = r#"
3621routes:
3622  r:
3623    authorization:
3624      pre_invocation:
3625        - "require(authenticated)"
3626      post_invocation:
3627        - "taint(audit, session)"
3628"#;
3629        let flat = r#"
3630routes:
3631  r:
3632    pre_invocation:
3633      - "require(authenticated)"
3634    post_invocation:
3635      - "taint(audit, session)"
3636"#;
3637        let a = compile_config(nested).unwrap().routes;
3638        let b = compile_config(flat).unwrap().routes;
3639        let ra = a.get("r").expect("nested route");
3640        let rb = b.get("r").expect("flat route");
3641        assert_eq!(ra.policy.len(), rb.policy.len());
3642        assert_eq!(ra.post_policy.len(), rb.post_policy.len());
3643        assert_eq!(ra.policy.len(), 1);
3644        assert_eq!(ra.post_policy.len(), 1);
3645    }
3646
3647    #[test]
3648    fn legacy_key_nested_under_authorization_is_rejected() {
3649        // Fail-closed: a legacy key nested inside the new `authorization:`
3650        // wrapper must error, not be silently dropped (which would load a
3651        // route with no authorization enforced). Guarded by
3652        // `deny_unknown_fields` on `AuthorizationYaml`.
3653        let yaml = r#"
3654routes:
3655  r:
3656    authorization:
3657      policy:
3658        - "require(authenticated)"
3659"#;
3660        let err = compile_config(yaml).expect_err("nested legacy `policy:` must be rejected");
3661        let msg = format!("{err}");
3662        assert!(
3663            msg.contains("policy") || msg.contains("unknown field"),
3664            "error should flag the unknown/legacy nested key: {msg}"
3665        );
3666    }
3667
3668    #[test]
3669    fn authorization_typo_under_wrapper_is_rejected() {
3670        // `deny_unknown_fields` also catches typos so they don't silently
3671        // no-op the phase.
3672        let yaml = r#"
3673routes:
3674  r:
3675    authorization:
3676      pre_invocaton:
3677        - "require(authenticated)"
3678"#;
3679        assert!(
3680            compile_config(yaml).is_err(),
3681            "a typo'd sub-key under `authorization:` must be rejected, not ignored"
3682        );
3683    }
3684
3685    #[test]
3686    fn same_phase_declared_nested_and_flat_is_rejected() {
3687        // The two forms are alternatives, not additive: declaring a phase
3688        // both nested and flat on one section would run its effects twice.
3689        let yaml = r#"
3690routes:
3691  r:
3692    authorization:
3693      pre_invocation:
3694        - "require(authenticated)"
3695    pre_invocation:
3696      - "require(role.hr)"
3697"#;
3698        let err = compile_config(yaml).expect_err("both-forms-same-section must be rejected");
3699        assert!(
3700            matches!(err, ParseError::ConflictingAuthorizationForms { ref phase, .. } if phase == "pre_invocation"),
3701            "expected ConflictingAuthorizationForms for pre_invocation, got {err:?}"
3702        );
3703    }
3704
3705    #[test]
3706    fn field_pipeline_error_names_field_path() {
3707        // A malformed pipeline under `result:` names `result.<field>` in
3708        // the diagnostic so the operator can locate the offending field.
3709        let yaml = r#"
3710routes:
3711  r:
3712    result:
3713      x: "nonsense"
3714"#;
3715        let err = compile_config(yaml).unwrap_err();
3716        let msg = format!("{err}");
3717        assert!(msg.contains("result.x"), "expected result.x in: {msg}");
3718    }
3719
3720    #[test]
3721    fn legacy_policy_field_names_are_rejected() {
3722        // Breaking rename: the old authorization-phase keys must fail
3723        // loudly, never be silently dropped (which would fail open).
3724        for (old, hint) in [
3725            ("policy", "pre_invocation"),
3726            ("post_policy", "post_invocation"),
3727        ] {
3728            let yaml = format!("routes:\n  r:\n    {old}:\n      - \"require(authenticated)\"\n");
3729            let err = compile_config(&yaml).expect_err(&format!("legacy `{old}` must be rejected"));
3730            let msg = format!("{err}");
3731            assert!(
3732                msg.contains(old) && msg.contains(hint),
3733                "`{old}` rejection should name the replacement `{hint}`: {msg}"
3734            );
3735        }
3736    }
3737
3738    #[test]
3739    fn legacy_only_route_is_not_silently_omitted() {
3740        // A route whose *only* APL-ish key is a legacy name would look
3741        // empty to the has-APL gate and be dropped — a fail-open. It must
3742        // error instead.
3743        let yaml = r#"
3744routes:
3745  ghost:
3746    policy:
3747      - "require(authenticated)"
3748"#;
3749        assert!(
3750            matches!(compile_config(yaml), Err(ParseError::RenamedField { .. })),
3751            "legacy-only route must be rejected, not omitted"
3752        );
3753    }
3754
3755    #[test]
3756    fn compile_omits_routes_without_apl_blocks() {
3757        // A route with no APL blocks (no authorization / pre_invocation /
3758        // post_invocation / args / result) is a "legacy" route per
3759        // apl-design §5 and must be
3760        // omitted from the compiled output. Unknown route keys (e.g.
3761        // legacy CPEX `priority`) are stashed in `other`, not errored.
3762        let yaml = r#"
3763routes:
3764  legacy:
3765    priority: 50
3766  apl_route:
3767    pre_invocation:
3768      - "require(authenticated)"
3769"#;
3770        let routes = compile_config(yaml).unwrap().routes;
3771        assert!(routes.contains_key("apl_route"));
3772        assert!(
3773            !routes.contains_key("legacy"),
3774            "legacy route should be omitted, not compiled"
3775        );
3776    }
3777
3778    #[test]
3779    fn compile_unknown_top_level_keys_ignored() {
3780        let yaml = r#"
3781version: "0.1"
3782policy_evaluator:
3783  kind: apl
3784plugins:
3785  - name: rate_limiter
3786    kind: native
3787imports:
3788  - "./shared.yaml"
3789routes:
3790  ping:
3791    pre_invocation:
3792      - "require(authenticated)"
3793"#;
3794        let routes = compile_config(yaml).unwrap().routes;
3795        assert!(routes.contains_key("ping"));
3796    }
3797
3798    #[test]
3799    fn compile_propagates_rule_errors_with_source() {
3800        let yaml = r#"
3801routes:
3802  bad:
3803    pre_invocation:
3804      - "subject.id == garbage_ident"
3805"#;
3806        let err = compile_config(yaml).unwrap_err();
3807        // RHS-as-identifier is rejected; the error mentions the offending input.
3808        let msg = format!("{}", err);
3809        assert!(
3810            msg.contains("RHS-as-identifier") || msg.contains("garbage_ident"),
3811            "error message should reference the failure: {}",
3812            msg,
3813        );
3814    }
3815
3816    #[test]
3817    fn compile_plugin_step_string_form() {
3818        let yaml = r#"
3819routes:
3820  rate_limited:
3821    pre_invocation:
3822      - "plugin(rate_limiter)"
3823"#;
3824        let routes = compile_config(yaml).unwrap().routes;
3825        let route = routes.get("rate_limited").unwrap();
3826        assert_eq!(route.policy.len(), 1);
3827        match &route.policy[0] {
3828            Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
3829            other => panic!("expected Effect::Plugin, got {:?}", other),
3830        }
3831    }
3832
3833    #[test]
3834    fn compile_run_step_string_form_aliases_plugin() {
3835        // `run(name)` is an alias for `plugin(name)`: both invoke a named
3836        // plugin and compile to Effect::Plugin.
3837        let yaml = r#"
3838routes:
3839  rate_limited:
3840    pre_invocation:
3841      - "run(rate_limiter)"
3842"#;
3843        let routes = compile_config(yaml).unwrap().routes;
3844        let route = routes.get("rate_limited").unwrap();
3845        assert_eq!(route.policy.len(), 1);
3846        match &route.policy[0] {
3847            Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
3848            other => panic!("expected Effect::Plugin, got {:?}", other),
3849        }
3850    }
3851
3852    #[test]
3853    fn parse_step_run_is_plugin_alias() {
3854        for s in ["run(audit-log)", "plugin(audit-log)"] {
3855            let step = parse_step(&serde_yaml::Value::String(s.to_string()), "test").unwrap();
3856            match step {
3857                crate::step::Step::Plugin { name } => assert_eq!(name, "audit-log", "{s}"),
3858                other => panic!("expected Step::Plugin for `{s}`, got {other:?}"),
3859            }
3860        }
3861        // Empty / malformed `run(...)` surfaces a clear, verb-named error.
3862        let err = parse_step(&serde_yaml::Value::String("run()".to_string()), "test").unwrap_err();
3863        assert!(
3864            format!("{err}").contains("run("),
3865            "error should name `run(...)`: {err}"
3866        );
3867    }
3868
3869    #[test]
3870    fn compile_taint_step_string_form() {
3871        let yaml = r#"
3872routes:
3873  audit_marked:
3874    pre_invocation:
3875      - "taint(audit, session)"
3876"#;
3877        let routes = compile_config(yaml).unwrap().routes;
3878        let route = routes.get("audit_marked").unwrap();
3879        match &route.policy[0] {
3880            Effect::Taint { label, scopes } => {
3881                assert_eq!(label, "audit");
3882                assert_eq!(scopes, &vec![TaintScope::Session]);
3883            },
3884            other => panic!("expected Effect::Taint, got {:?}", other),
3885        }
3886    }
3887
3888    #[test]
3889    fn compile_pdp_call_cedar_map_form() {
3890        // Cedar uses the `cedar:` key with args inline + on_deny/on_allow.
3891        let yaml = r#"
3892routes:
3893  authz_check:
3894    pre_invocation:
3895      - cedar:
3896          action: read
3897          resource: employee
3898          on_deny:
3899            - deny
3900          on_allow:
3901            - "plugin(audit_logger)"
3902"#;
3903        let routes = compile_config(yaml).unwrap().routes;
3904        let route = routes.get("authz_check").unwrap();
3905        match &route.policy[0] {
3906            Effect::Pdp {
3907                call,
3908                on_deny,
3909                on_allow,
3910            } => {
3911                assert_eq!(call.dialect, PdpDialect::Cedar);
3912                // Cedar args are a map: action + resource (with reaction
3913                // keys stripped out).
3914                let args_map = call.args.as_mapping().expect("cedar args should be a map");
3915                assert!(args_map.contains_key(serde_yaml::Value::String("action".into())));
3916                assert!(args_map.contains_key(serde_yaml::Value::String("resource".into())));
3917                assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
3918                assert_eq!(on_deny.len(), 1);
3919                assert_eq!(on_allow.len(), 1);
3920            },
3921            other => panic!("expected Effect::Pdp, got {:?}", other),
3922        }
3923    }
3924
3925    #[test]
3926    fn compile_pdp_call_cel_map_form() {
3927        // `cel:` carries an `expr:` string + optional on_deny/on_allow
3928        // reactions. Routes to the CEL-backed resolver via PdpDialect::Cel.
3929        let yaml = r#"
3930routes:
3931  authz_check:
3932    pre_invocation:
3933      - cel:
3934          expr: "subject.id == 'alice' && delegation.depth <= 2"
3935          on_deny:
3936            - deny
3937"#;
3938        let routes = compile_config(yaml).unwrap().routes;
3939        let route = routes.get("authz_check").unwrap();
3940        match &route.policy[0] {
3941            Effect::Pdp {
3942                call,
3943                on_deny,
3944                on_allow,
3945            } => {
3946                assert_eq!(call.dialect, PdpDialect::Cel);
3947                let args_map = call.args.as_mapping().expect("cel args should be a map");
3948                assert!(args_map.contains_key(serde_yaml::Value::String("expr".into())));
3949                // Reaction keys are stripped from the opaque call args.
3950                assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
3951                assert_eq!(on_deny.len(), 1);
3952                assert_eq!(on_allow.len(), 0);
3953            },
3954            other => panic!("expected Effect::Pdp, got {:?}", other),
3955        }
3956    }
3957
3958    #[test]
3959    fn compile_pdp_call_opa_paren_form() {
3960        // OPA uses `opa("path"):` with the path inside parens + body is reactions.
3961        let yaml = r#"
3962routes:
3963  opa_check:
3964    pre_invocation:
3965      - 'opa("hr/compensation/deny"):':
3966          on_deny:
3967            - deny
3968"#;
3969        let routes = compile_config(yaml).unwrap().routes;
3970        let route = routes.get("opa_check").unwrap();
3971        match &route.policy[0] {
3972            Effect::Pdp { call, on_deny, .. } => {
3973                assert_eq!(call.dialect, PdpDialect::Opa);
3974                // OPA args are a string (the path).
3975                assert!(call.args.as_str().unwrap().contains("hr/compensation/deny"));
3976                assert_eq!(on_deny.len(), 1);
3977            },
3978            other => panic!("expected Effect::Pdp, got {:?}", other),
3979        }
3980    }
3981
3982    #[test]
3983    fn compile_pdp_unknown_dialect_becomes_custom() {
3984        let yaml = r#"
3985routes:
3986  custom_pdp:
3987    pre_invocation:
3988      - my_engine:
3989          on_deny: [deny]
3990"#;
3991        let routes = compile_config(yaml).unwrap().routes;
3992        match &routes.get("custom_pdp").unwrap().policy[0] {
3993            Effect::Pdp { call, .. } => {
3994                assert_eq!(call.dialect, PdpDialect::Custom("my_engine".into()));
3995            },
3996            other => panic!("expected Pdp, got {:?}", other),
3997        }
3998    }
3999
4000    // ----- End-to-end with evaluator -----
4001
4002    #[tokio::test]
4003    async fn end_to_end_hr_compensation() {
4004        let yaml = r#"
4005routes:
4006  get_compensation:
4007    pre_invocation:
4008      - "require(authenticated)"
4009      - "require(role.hr | role.finance)"
4010      - "delegation.depth > 2: deny"
4011"#;
4012        let routes = compile_config(yaml).unwrap().routes;
4013        let route = routes.get("get_compensation").unwrap();
4014
4015        let pdp: std::sync::Arc<dyn crate::PdpResolver> = std::sync::Arc::new(NullPdpResolver);
4016        let plugins: std::sync::Arc<dyn crate::PluginInvoker> =
4017            std::sync::Arc::new(NullPluginInvoker);
4018        let delegations: std::sync::Arc<dyn crate::DelegationInvoker> =
4019            std::sync::Arc::new(crate::NoopDelegationInvoker);
4020        let elicitations: std::sync::Arc<dyn crate::ElicitationInvoker> =
4021            std::sync::Arc::new(crate::NoopElicitationInvoker);
4022
4023        // Alice: authenticated, hr role, depth=1 → allow.
4024        let mut bag = AttributeBag::new();
4025        bag.set("authenticated", true);
4026        bag.set("role.hr", true);
4027        bag.set("delegation.depth", 1_i64);
4028        assert_eq!(
4029            crate::evaluate_effects(
4030                &route.policy,
4031                &mut bag,
4032                &pdp,
4033                &plugins,
4034                &delegations,
4035                &elicitations,
4036                crate::DispatchPhase::Pre,
4037                &mut crate::route::RoutePayload::new(serde_json::Value::Null)
4038            )
4039            .await
4040            .decision,
4041            Decision::Allow,
4042        );
4043
4044        // Same Alice but depth=3 → deny (third rule fires).
4045        bag.set("delegation.depth", 3_i64);
4046        match crate::evaluate_effects(
4047            &route.policy,
4048            &mut bag,
4049            &pdp,
4050            &plugins,
4051            &delegations,
4052            &elicitations,
4053            crate::DispatchPhase::Pre,
4054            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
4055        )
4056        .await
4057        .decision
4058        {
4059            Decision::Deny { rule_source, .. } => {
4060                assert!(
4061                    rule_source.contains("pre_invocation[2]"),
4062                    "expected pre_invocation[2], got {}",
4063                    rule_source
4064                );
4065            },
4066            d => panic!("expected Deny, got {:?}", d),
4067        }
4068
4069        // Bob: authenticated but neither hr nor finance → deny on rule 1.
4070        let mut bag = AttributeBag::new();
4071        bag.set("authenticated", true);
4072        bag.set("delegation.depth", 1_i64);
4073        match crate::evaluate_effects(
4074            &route.policy,
4075            &mut bag,
4076            &pdp,
4077            &plugins,
4078            &delegations,
4079            &elicitations,
4080            crate::DispatchPhase::Pre,
4081            &mut crate::route::RoutePayload::new(serde_json::Value::Null),
4082        )
4083        .await
4084        .decision
4085        {
4086            Decision::Deny { rule_source, .. } => {
4087                assert!(
4088                    rule_source.contains("pre_invocation[1]"),
4089                    "expected pre_invocation[1], got {}",
4090                    rule_source
4091                );
4092            },
4093            d => panic!("expected Deny, got {:?}", d),
4094        }
4095    }
4096
4097    // Test fixtures for async evaluator — null resolvers that nothing in
4098    // a pure-rule route should ever invoke.
4099    struct NullPdpResolver;
4100    #[async_trait::async_trait]
4101    impl crate::PdpResolver for NullPdpResolver {
4102        fn dialect(&self) -> crate::PdpDialect {
4103            crate::PdpDialect::Cedar
4104        }
4105        async fn evaluate(
4106            &self,
4107            _call: &crate::PdpCall,
4108            _bag: &crate::AttributeBag,
4109        ) -> Result<crate::PdpDecision, crate::PdpError> {
4110            panic!("NullPdpResolver should not be invoked in pure-rule tests");
4111        }
4112    }
4113
4114    struct NullPluginInvoker;
4115    #[async_trait::async_trait]
4116    impl crate::PluginInvoker for NullPluginInvoker {
4117        async fn invoke(
4118            &self,
4119            _name: &str,
4120            _bag: &crate::AttributeBag,
4121            _invocation: crate::PluginInvocation<'_>,
4122        ) -> Result<crate::PluginOutcome, crate::PluginError> {
4123            panic!("NullPluginInvoker should not be invoked in pure-rule tests");
4124        }
4125    }
4126
4127    // ----- Pipeline parsing -----
4128
4129    #[test]
4130    fn pipeline_simple_bare_stages() {
4131        let p = parse_pipeline("str").unwrap();
4132        assert_eq!(p.stages, vec![Stage::Type(TypeCheck::Str)]);
4133
4134        let p = parse_pipeline("omit").unwrap();
4135        assert_eq!(p.stages, vec![Stage::Omit]);
4136
4137        let p = parse_pipeline("hash").unwrap();
4138        assert_eq!(p.stages, vec![Stage::Hash]);
4139    }
4140
4141    #[test]
4142    fn pipeline_chains_split_on_pipe() {
4143        let p = parse_pipeline("str | mask(4)").unwrap();
4144        assert_eq!(
4145            p.stages,
4146            vec![Stage::Type(TypeCheck::Str), Stage::Mask { keep_last: 4 },]
4147        );
4148
4149        let p = parse_pipeline("int | 0..1M").unwrap();
4150        assert_eq!(
4151            p.stages,
4152            vec![
4153                Stage::Type(TypeCheck::Int),
4154                Stage::Range {
4155                    min: Some(0),
4156                    max: Some(1_000_000)
4157                },
4158            ]
4159        );
4160    }
4161
4162    #[test]
4163    fn pipeline_pipe_inside_parens_does_not_split() {
4164        // `redact(!a | b)` is one stage; the inner `|` is OR inside a
4165        // predicate condition, not a chain separator.
4166        let p = parse_pipeline("str | redact(!perm.view_ssn | role.admin)").unwrap();
4167        assert_eq!(p.stages.len(), 2);
4168        match &p.stages[1] {
4169            Stage::Redact { condition: Some(_) } => {},
4170            other => panic!("expected Redact with condition, got {:?}", other),
4171        }
4172    }
4173
4174    #[test]
4175    fn pipeline_length_constraints() {
4176        let p = parse_pipeline("len(..500)").unwrap();
4177        assert_eq!(
4178            p.stages,
4179            vec![Stage::Length {
4180                min: None,
4181                max: Some(500)
4182            }]
4183        );
4184        let p = parse_pipeline("len(10..50)").unwrap();
4185        assert_eq!(
4186            p.stages,
4187            vec![Stage::Length {
4188                min: Some(10),
4189                max: Some(50)
4190            }]
4191        );
4192        let p = parse_pipeline("len(8..)").unwrap();
4193        assert_eq!(
4194            p.stages,
4195            vec![Stage::Length {
4196                min: Some(8),
4197                max: None
4198            }]
4199        );
4200    }
4201
4202    #[test]
4203    fn pipeline_range_with_suffixes() {
4204        let p = parse_pipeline("0..10k").unwrap();
4205        assert_eq!(
4206            p.stages,
4207            vec![Stage::Range {
4208                min: Some(0),
4209                max: Some(10_000)
4210            }]
4211        );
4212        let p = parse_pipeline("0..1M").unwrap();
4213        assert_eq!(
4214            p.stages,
4215            vec![Stage::Range {
4216                min: Some(0),
4217                max: Some(1_000_000)
4218            }]
4219        );
4220        let p = parse_pipeline("..500").unwrap();
4221        assert_eq!(
4222            p.stages,
4223            vec![Stage::Range {
4224                min: None,
4225                max: Some(500)
4226            }]
4227        );
4228    }
4229
4230    #[test]
4231    fn pipeline_enum_unquoted_and_quoted() {
4232        let p = parse_pipeline("enum(low, medium, high)").unwrap();
4233        assert_eq!(
4234            p.stages,
4235            vec![Stage::Enum {
4236                values: vec!["low".into(), "medium".into(), "high".into()],
4237            }]
4238        );
4239        let p = parse_pipeline(r#"enum("a", "b")"#).unwrap();
4240        assert_eq!(
4241            p.stages,
4242            vec![Stage::Enum {
4243                values: vec!["a".into(), "b".into()],
4244            }]
4245        );
4246    }
4247
4248    #[test]
4249    fn pipeline_redact_with_predicate_condition() {
4250        let p = parse_pipeline("str | redact(!perm.view_ssn)").unwrap();
4251        assert_eq!(p.stages.len(), 2);
4252        match &p.stages[1] {
4253            Stage::Redact {
4254                condition: Some(Expression::Not(inner)),
4255            } => match inner.as_ref() {
4256                Expression::Condition(Condition::IsTrue { key }) => {
4257                    assert_eq!(key, "perm.view_ssn");
4258                },
4259                other => panic!("expected IsTrue(perm.view_ssn), got {:?}", other),
4260            },
4261            other => panic!("expected Redact with Not condition, got {:?}", other),
4262        }
4263    }
4264
4265    #[test]
4266    fn pipeline_taint_scopes() {
4267        let p = parse_pipeline("taint(PII)").unwrap();
4268        assert_eq!(
4269            p.stages,
4270            vec![Stage::Taint {
4271                label: "PII".into(),
4272                scopes: vec![TaintScope::Session],
4273            }]
4274        );
4275        let p = parse_pipeline("taint(PII, message)").unwrap();
4276        assert_eq!(
4277            p.stages,
4278            vec![Stage::Taint {
4279                label: "PII".into(),
4280                scopes: vec![TaintScope::Message],
4281            }]
4282        );
4283        let p = parse_pipeline("taint(PII, [session, message])").unwrap();
4284        assert_eq!(
4285            p.stages,
4286            vec![Stage::Taint {
4287                label: "PII".into(),
4288                scopes: vec![TaintScope::Session, TaintScope::Message],
4289            }]
4290        );
4291    }
4292
4293    #[test]
4294    fn pipeline_unknown_stage_rejected() {
4295        let err = parse_pipeline("nonsense").unwrap_err();
4296        assert!(format!("{}", err).contains("unknown stage"));
4297    }
4298
4299    #[test]
4300    fn pipeline_omit_with_args_rejected() {
4301        // omit has no conditional form per DSL §4.1.
4302        let err = parse_pipeline("omit(!perm.x)").unwrap_err();
4303        assert!(format!("{}", err).contains("omit takes no arguments"));
4304    }
4305
4306    // ----- YAML compilation with pipelines -----
4307
4308    #[test]
4309    fn compile_route_with_args_and_result() {
4310        let yaml = r#"
4311routes:
4312  get_compensation:
4313    args:
4314      employee_id: "uuid"
4315      amount: "int | 0..1M"
4316    result:
4317      ssn: "str | redact(!perm.view_ssn)"
4318      employee_id: "str | mask(4)"
4319      internal_notes: "omit"
4320"#;
4321        let routes = compile_config(yaml).unwrap().routes;
4322        let route = routes.get("get_compensation").expect("missing route");
4323        assert_eq!(route.args.len(), 2);
4324        assert_eq!(route.result.len(), 3);
4325
4326        // Pull out the ssn pipeline and confirm shape.
4327        let ssn = route.result.iter().find(|f| f.field == "ssn").unwrap();
4328        assert_eq!(ssn.pipeline.stages.len(), 2);
4329        assert!(matches!(
4330            ssn.pipeline.stages[0],
4331            Stage::Type(TypeCheck::Str)
4332        ));
4333        assert!(matches!(
4334            ssn.pipeline.stages[1],
4335            Stage::Redact { condition: Some(_) }
4336        ));
4337
4338        // declared_phases should include Result and Args now.
4339        let phases = route.declared_phases();
4340        assert!(phases.contains(crate::rules::Phase::Args));
4341        assert!(phases.contains(crate::rules::Phase::Result));
4342    }
4343
4344    #[test]
4345    fn compile_route_with_only_args_still_compiles() {
4346        // A route with no authorization block but with `args:`
4347        // validators is still an APL route (declared_phases non-empty).
4348        let yaml = r#"
4349routes:
4350  validate_only:
4351    args:
4352      employee_id: "uuid"
4353"#;
4354        let routes = compile_config(yaml).unwrap().routes;
4355        assert!(routes.contains_key("validate_only"));
4356    }
4357
4358    #[test]
4359    fn compile_propagates_pipeline_parse_errors() {
4360        let yaml = r#"
4361routes:
4362  bad:
4363    result:
4364      x: "nonsense"
4365"#;
4366        let err = compile_config(yaml).unwrap_err();
4367        assert!(format!("{}", err).contains("unknown stage"));
4368    }
4369
4370    // ----- plugins: block + route-level overrides -----
4371
4372    #[test]
4373    fn compile_captures_root_plugins_block_into_registry() {
4374        let yaml = r#"
4375plugins:
4376  - name: rate_limiter
4377    kind: native
4378    hooks: [tool_pre_invoke]
4379    capabilities: [read_subject]
4380    config:
4381      max_requests: 100
4382  - name: audit
4383    kind: native
4384    hooks: [tool_post_invoke]
4385routes:
4386  get_compensation:
4387    pre_invocation:
4388      - "plugin(rate_limiter)"
4389"#;
4390        let cfg = compile_config(yaml).unwrap();
4391        assert_eq!(cfg.plugins.len(), 2);
4392        let rl = cfg.plugins.get("rate_limiter").unwrap();
4393        assert_eq!(rl.kind, "native");
4394        assert_eq!(rl.hooks, vec!["tool_pre_invoke".to_string()]);
4395        assert_eq!(rl.capabilities, vec!["read_subject".to_string()]);
4396        // The route should still compile (uses plugin(rate_limiter)).
4397        assert!(cfg.routes.contains_key("get_compensation"));
4398    }
4399
4400    #[test]
4401    fn compile_captures_route_level_plugin_overrides() {
4402        let yaml = r#"
4403plugins:
4404  - name: rate_limiter
4405    kind: native
4406    hooks: [tool_pre_invoke]
4407    config:
4408      max_requests: 100
4409routes:
4410  hot_path:
4411    pre_invocation:
4412      - "plugin(rate_limiter)"
4413    plugins:
4414      rate_limiter:
4415        config:
4416          max_requests: 10
4417        on_error: ignore
4418"#;
4419        let cfg = compile_config(yaml).unwrap();
4420        let route = cfg.routes.get("hot_path").unwrap();
4421        let ovr = route.plugin_overrides.get("rate_limiter").unwrap();
4422        assert_eq!(ovr.on_error.as_deref(), Some("ignore"));
4423        let cfg_yaml = ovr.config.as_ref().unwrap();
4424        assert_eq!(
4425            cfg_yaml["max_requests"],
4426            serde_yaml::from_str::<serde_yaml::Value>("10").unwrap()
4427        );
4428
4429        // Verify EffectivePlugin::resolve sees the override.
4430        let eff = crate::plugin_decl::EffectivePlugin::resolve(
4431            "rate_limiter",
4432            &cfg.plugins,
4433            &route.plugin_overrides,
4434        )
4435        .unwrap();
4436        assert_eq!(eff.on_error, Some("ignore"));
4437        // Hooks NOT overridable — still from the global declaration.
4438        assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]);
4439    }
4440
4441    // ----- compile_policy_block_value (single-block compiler for visitors) -----
4442
4443    #[test]
4444    fn compile_policy_block_value_parses_apl_body() {
4445        let yaml = r#"
4446pre_invocation:
4447  - "require(authenticated)"
4448result:
4449  ssn: "redact(!perm.view_ssn)"
4450"#;
4451        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4452        let compiled =
4453            compile_policy_block_value("global.policy.all", &value).expect("compile block");
4454        assert_eq!(compiled.route_key, "global.policy.all");
4455        assert_eq!(compiled.policy.len(), 1);
4456        assert_eq!(compiled.result.len(), 1);
4457        assert_eq!(compiled.result[0].field, "ssn");
4458    }
4459
4460    #[test]
4461    fn compile_policy_block_value_null_is_empty_route() {
4462        let value = serde_yaml::Value::Null;
4463        let compiled =
4464            compile_policy_block_value("global.defaults.tool", &value).expect("compile null");
4465        assert!(compiled.declared_phases().is_empty());
4466        assert_eq!(compiled.route_key, "global.defaults.tool");
4467    }
4468
4469    #[test]
4470    fn compile_policy_block_value_threads_source_into_rule_paths() {
4471        let yaml = r#"
4472pre_invocation:
4473  - "require(authenticated)"
4474"#;
4475        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4476        let compiled = compile_policy_block_value("global.policies.hr", &value).expect("compile");
4477        match &compiled.policy[0] {
4478            crate::rules::Effect::When { source, .. } => {
4479                assert_eq!(source, "global.policies.hr.pre_invocation[0]");
4480            },
4481            other => panic!("expected When, got {:?}", other),
4482        }
4483    }
4484
4485    // ----- delegate: step parsing -----
4486
4487    #[test]
4488    fn parse_delegate_step_with_only_plugin() {
4489        let yaml = r#"
4490- delegate:
4491    plugin: workday-oauth
4492"#;
4493        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4494        let entry = &value.as_sequence().unwrap()[0];
4495        let step = parse_step(entry, "test.policy[0]").expect("parse");
4496        let crate::step::Step::Delegate(ds) = step else {
4497            panic!("expected Delegate, got {step:?}");
4498        };
4499        assert_eq!(ds.plugin_name, "workday-oauth");
4500        assert!(ds.config_override.is_none());
4501        assert!(ds.on_error.is_none());
4502        assert_eq!(ds.source, "test.policy[0]");
4503    }
4504
4505    #[test]
4506    fn parse_delegate_step_with_config_and_on_error() {
4507        let yaml = r#"
4508- delegate:
4509    plugin: workday-oauth
4510    config:
4511      target: workday-api
4512      permissions: [read_compensation]
4513    on_error: deny
4514"#;
4515        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4516        let entry = &value.as_sequence().unwrap()[0];
4517        let step = parse_step(entry, "test.policy[1]").expect("parse");
4518        let crate::step::Step::Delegate(ds) = step else {
4519            panic!("expected Delegate, got {step:?}");
4520        };
4521        assert_eq!(ds.plugin_name, "workday-oauth");
4522        assert_eq!(ds.on_error.as_deref(), Some("deny"));
4523        let cfg = ds.config_override.as_ref().expect("config_override set");
4524        let target = cfg
4525            .as_mapping()
4526            .and_then(|m| m.get(serde_yaml::Value::String("target".into())))
4527            .and_then(|v| v.as_str());
4528        assert_eq!(target, Some("workday-api"));
4529    }
4530
4531    #[test]
4532    fn parse_delegate_step_missing_plugin_errors() {
4533        let yaml = r#"
4534- delegate:
4535    config: { target: workday-api }
4536"#;
4537        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4538        let entry = &value.as_sequence().unwrap()[0];
4539        let err = parse_step(entry, "test.policy[0]").expect_err("missing plugin");
4540        let msg = format!("{err}");
4541        assert!(msg.contains("requires `plugin:"), "got: {msg}");
4542    }
4543
4544    #[test]
4545    fn parse_delegate_step_empty_plugin_errors() {
4546        let yaml = r#"
4547- delegate:
4548    plugin: ""
4549"#;
4550        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4551        let entry = &value.as_sequence().unwrap()[0];
4552        let err = parse_step(entry, "test.policy[0]").expect_err("empty plugin");
4553        let msg = format!("{err}");
4554        assert!(msg.contains("cannot be empty"), "got: {msg}");
4555    }
4556
4557    #[test]
4558    fn parse_delegate_step_non_string_on_error_errors() {
4559        let yaml = r#"
4560- delegate:
4561    plugin: workday-oauth
4562    on_error: 42
4563"#;
4564        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4565        let entry = &value.as_sequence().unwrap()[0];
4566        let err = parse_step(entry, "test.policy[0]").expect_err("non-string on_error");
4567        let msg = format!("{err}");
4568        assert!(msg.contains("on_error"), "got: {msg}");
4569    }
4570
4571    #[test]
4572    fn parse_delegate_step_non_map_body_errors() {
4573        let yaml = r#"
4574- delegate: workday-oauth
4575"#;
4576        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4577        let entry = &value.as_sequence().unwrap()[0];
4578        let err = parse_step(entry, "test.policy[0]").expect_err("non-map delegate body");
4579        let msg = format!("{err}");
4580        assert!(msg.contains("must be a map"), "got: {msg}");
4581    }
4582
4583    // ----- delegate(...) function-call string form -----
4584
4585    #[test]
4586    fn parse_delegate_string_bare_plugin_name() {
4587        let yaml = r#"- "delegate(workday-oauth)""#;
4588        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4589        let entry = &value.as_sequence().unwrap()[0];
4590        let step = parse_step(entry, "test.policy[0]").expect("parse");
4591        let crate::step::Step::Delegate(ds) = step else {
4592            panic!("expected Delegate, got {step:?}");
4593        };
4594        assert_eq!(ds.plugin_name, "workday-oauth");
4595        assert!(ds.config_override.is_none());
4596        assert!(ds.on_error.is_none());
4597        assert_eq!(ds.source, "test.policy[0]");
4598    }
4599
4600    #[test]
4601    fn parse_delegate_string_with_string_kwargs() {
4602        let yaml =
4603            r#"- "delegate(workday-oauth, target: workday-api, audience: https://workday.com)""#;
4604        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4605        let entry = &value.as_sequence().unwrap()[0];
4606        let step = parse_step(entry, "test.policy[0]").expect("parse");
4607        let crate::step::Step::Delegate(ds) = step else {
4608            panic!("expected Delegate, got {step:?}");
4609        };
4610        assert_eq!(ds.plugin_name, "workday-oauth");
4611        let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4612        assert_eq!(
4613            cfg.get(serde_yaml::Value::String("target".into()))
4614                .and_then(|v| v.as_str()),
4615            Some("workday-api"),
4616        );
4617        assert_eq!(
4618            cfg.get(serde_yaml::Value::String("audience".into()))
4619                .and_then(|v| v.as_str()),
4620            Some("https://workday.com"),
4621        );
4622    }
4623
4624    #[test]
4625    fn parse_delegate_string_with_list_kwarg() {
4626        let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation, write_notes])""#;
4627        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4628        let entry = &value.as_sequence().unwrap()[0];
4629        let step = parse_step(entry, "test.policy[0]").expect("parse");
4630        let crate::step::Step::Delegate(ds) = step else {
4631            panic!("expected Delegate");
4632        };
4633        let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4634        let perms = cfg
4635            .get(serde_yaml::Value::String("permissions".into()))
4636            .and_then(|v| v.as_sequence())
4637            .expect("permissions sequence");
4638        let names: Vec<&str> = perms.iter().filter_map(|v| v.as_str()).collect();
4639        assert_eq!(names, vec!["read_compensation", "write_notes"]);
4640    }
4641
4642    #[test]
4643    fn parse_delegate_string_on_error_pulled_out() {
4644        let yaml = r#"- "delegate(workday-oauth, target: workday-api, on_error: continue)""#;
4645        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4646        let entry = &value.as_sequence().unwrap()[0];
4647        let step = parse_step(entry, "test.policy[0]").expect("parse");
4648        let crate::step::Step::Delegate(ds) = step else {
4649            panic!("expected Delegate");
4650        };
4651        assert_eq!(ds.on_error.as_deref(), Some("continue"));
4652        // on_error must NOT also leak into config_override.
4653        let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4654        assert!(
4655            cfg.get(serde_yaml::Value::String("on_error".into()))
4656                .is_none(),
4657            "on_error must not appear in config_override"
4658        );
4659    }
4660
4661    #[test]
4662    fn parse_delegate_string_quoted_plugin_name() {
4663        // Quoting the plugin name is harmless — the parser strips
4664        // the wrapping quotes. Useful when the name contains
4665        // characters the bare-ident reader doesn't like.
4666        let yaml = r#"- 'delegate("workday-oauth")'"#;
4667        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4668        let entry = &value.as_sequence().unwrap()[0];
4669        let step = parse_step(entry, "test.policy[0]").expect("parse");
4670        let crate::step::Step::Delegate(ds) = step else {
4671            panic!("expected Delegate");
4672        };
4673        assert_eq!(ds.plugin_name, "workday-oauth");
4674    }
4675
4676    #[test]
4677    fn parse_delegate_string_quoted_value_preserves_internal_commas() {
4678        let yaml =
4679            r#"- 'delegate(workday-oauth, audience: "https://workday.com,backup.workday.com")'"#;
4680        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4681        let entry = &value.as_sequence().unwrap()[0];
4682        let step = parse_step(entry, "test.policy[0]").expect("parse");
4683        let crate::step::Step::Delegate(ds) = step else {
4684            panic!("expected Delegate");
4685        };
4686        let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4687        assert_eq!(
4688            cfg.get(serde_yaml::Value::String("audience".into()))
4689                .and_then(|v| v.as_str()),
4690            Some("https://workday.com,backup.workday.com"),
4691        );
4692    }
4693
4694    #[test]
4695    fn parse_delegate_string_empty_args_errors() {
4696        let yaml = r#"- "delegate()""#;
4697        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4698        let entry = &value.as_sequence().unwrap()[0];
4699        let err = parse_step(entry, "test.policy[0]").expect_err("empty args");
4700        let msg = format!("{err}");
4701        assert!(msg.contains("plugin name"), "got: {msg}");
4702    }
4703
4704    #[test]
4705    fn parse_delegate_string_plugin_kwarg_rejected() {
4706        // `plugin:` as a kwarg is ambiguous when the plugin name is
4707        // also the positional first arg — reject loudly.
4708        let yaml = r#"- "delegate(workday-oauth, plugin: other-thing)""#;
4709        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4710        let entry = &value.as_sequence().unwrap()[0];
4711        let err = parse_step(entry, "test.policy[0]").expect_err("plugin kwarg");
4712        let msg = format!("{err}");
4713        assert!(msg.contains("positional"), "got: {msg}");
4714    }
4715
4716    #[test]
4717    fn parse_delegate_string_kwarg_missing_colon_errors() {
4718        let yaml = r#"- "delegate(workday-oauth, target workday-api)""#;
4719        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4720        let entry = &value.as_sequence().unwrap()[0];
4721        let err = parse_step(entry, "test.policy[0]").expect_err("missing colon");
4722        let msg = format!("{err}");
4723        assert!(msg.contains("key: value"), "got: {msg}");
4724    }
4725
4726    #[test]
4727    fn parse_delegate_string_unbalanced_brackets_errors() {
4728        let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation)""#;
4729        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4730        let entry = &value.as_sequence().unwrap()[0];
4731        let err = parse_step(entry, "test.policy[0]").expect_err("unbalanced");
4732        let msg = format!("{err}");
4733        assert!(
4734            msg.contains("unmatched") || msg.contains("unbalanced"),
4735            "got: {msg}"
4736        );
4737    }
4738
4739    #[test]
4740    fn compile_route_mixed_string_and_map_delegate_forms() {
4741        // Both forms coexist in the same policy block — string form
4742        // for the compact case, map form for richer config.
4743        let yaml = r#"
4744routes:
4745  get_compensation:
4746    pre_invocation:
4747      - "require(role.hr)"
4748      - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])"
4749      - delegate:
4750          plugin: audit-receipt
4751          on_error: continue
4752          config:
4753            mode: trace
4754"#;
4755        let cfg = compile_config(yaml).expect("compile");
4756        let route = cfg.routes.get("get_compensation").expect("route");
4757        assert_eq!(route.policy.len(), 3);
4758
4759        // Step [1] is the string-form delegate.
4760        let crate::rules::Effect::Delegate(s1) = &route.policy[1] else {
4761            panic!("expected Delegate at policy[1]");
4762        };
4763        assert_eq!(s1.plugin_name, "workday-oauth");
4764        assert!(s1.on_error.is_none());
4765
4766        // Step [2] is the map-form delegate.
4767        let crate::rules::Effect::Delegate(s2) = &route.policy[2] else {
4768            panic!("expected Delegate at policy[2]");
4769        };
4770        assert_eq!(s2.plugin_name, "audit-receipt");
4771        assert_eq!(s2.on_error.as_deref(), Some("continue"));
4772    }
4773
4774    #[test]
4775    fn compile_route_with_delegate_in_policy_and_post_policy() {
4776        // End-to-end: delegate() lands in the right phase with the
4777        // right source path for diagnostics. Mixed with normal rules
4778        // to prove it doesn't perturb existing step parsing.
4779        let yaml = r#"
4780routes:
4781  get_compensation:
4782    pre_invocation:
4783      - "require(role.hr)"
4784      - delegate:
4785          plugin: workday-oauth
4786          config:
4787            target: workday-api
4788            permissions: [read_compensation]
4789      - "require(authenticated)"
4790    post_invocation:
4791      - delegate:
4792          plugin: audit-biscuit
4793          on_error: continue
4794"#;
4795        let cfg = compile_config(yaml).expect("compile");
4796        let route = cfg.routes.get("get_compensation").expect("route present");
4797        assert_eq!(route.policy.len(), 3);
4798
4799        // Policy step [1] is the delegate.
4800        let crate::rules::Effect::Delegate(ds) = &route.policy[1] else {
4801            panic!("expected Delegate at policy[1], got {:?}", route.policy[1]);
4802        };
4803        assert_eq!(ds.plugin_name, "workday-oauth");
4804        assert_eq!(ds.source, "get_compensation.pre_invocation[1]");
4805
4806        // post_policy[0] is the audit-biscuit delegate.
4807        let crate::rules::Effect::Delegate(post_ds) = &route.post_policy[0] else {
4808            panic!("expected Delegate at post_policy[0]");
4809        };
4810        assert_eq!(post_ds.plugin_name, "audit-biscuit");
4811        assert_eq!(post_ds.on_error.as_deref(), Some("continue"));
4812        assert_eq!(post_ds.source, "get_compensation.post_invocation[0]");
4813    }
4814
4815    // ----- elicitation sugar verbs (require_approval, confirm, …) -----
4816
4817    fn parse_elicit_str(s: &str) -> crate::step::ElicitStep {
4818        let value = serde_yaml::Value::String(s.to_string());
4819        let step = parse_step(&value, "test.policy[0]").expect("parse");
4820        match step {
4821            crate::step::Step::Elicit(e) => e,
4822            other => panic!("expected Elicit, got {other:?}"),
4823        }
4824    }
4825
4826    #[test]
4827    fn parse_require_approval_full_kwargs() {
4828        let e = parse_elicit_str(
4829            "require_approval(manager-approver, from: user.manager, channel: \"ciba\", \
4830             scope: \"args.amount <= 25000\", \
4831             purpose: \"Approve salary change\", timeout: 24h)",
4832        );
4833        assert_eq!(e.kind, ElicitKind::Approval);
4834        assert_eq!(e.plugin_name, "manager-approver");
4835        assert_eq!(e.from, "user.manager");
4836        assert_eq!(e.channel.as_deref(), Some("ciba"));
4837        assert_eq!(e.scope.as_deref(), Some("args.amount <= 25000"));
4838        assert_eq!(e.purpose.as_deref(), Some("Approve salary change"));
4839        assert_eq!(e.timeout.as_deref(), Some("24h"));
4840        assert!(e.on_error.is_none());
4841        assert!(e.config_override.is_none());
4842        assert_eq!(e.source, "test.policy[0]");
4843    }
4844
4845    #[test]
4846    fn parse_channel_is_optional() {
4847        // No `channel:` — it's an audit label, not required for routing.
4848        let e = parse_elicit_str("require_approval(manager-approver, from: user.manager)");
4849        assert_eq!(e.plugin_name, "manager-approver");
4850        assert!(e.channel.is_none());
4851    }
4852
4853    #[test]
4854    fn parse_each_verb_maps_to_its_kind() {
4855        for (verb, want) in [
4856            ("require_approval", ElicitKind::Approval),
4857            ("confirm", ElicitKind::Confirm),
4858            ("require_step_up", ElicitKind::StepUp),
4859            ("require_attestation", ElicitKind::Attestation),
4860            ("request_info", ElicitKind::Info),
4861            ("require_review", ElicitKind::Review),
4862        ] {
4863            let e = parse_elicit_str(&format!("{verb}(inband-asker, from: user.sub)"));
4864            assert_eq!(e.kind, want, "verb `{verb}`");
4865            assert_eq!(e.plugin_name, "inband-asker");
4866            assert_eq!(e.from, "user.sub");
4867        }
4868    }
4869
4870    #[test]
4871    fn parse_confirm_prompt_aliases_purpose() {
4872        // The elicitation-hook doc uses `prompt` for confirm; it maps to
4873        // the same `purpose` field as `require_approval`.
4874        let e =
4875            parse_elicit_str("confirm(inband-asker, from: user.sub, prompt: \"Drop the table?\")");
4876        assert_eq!(e.kind, ElicitKind::Confirm);
4877        assert_eq!(e.purpose.as_deref(), Some("Drop the table?"));
4878    }
4879
4880    #[test]
4881    fn parse_unknown_kwarg_goes_to_config_override() {
4882        let e = parse_elicit_str(
4883            "require_approval(slack-approver, from: user.manager, \
4884             details_link: https://approvals.example.com/req)",
4885        );
4886        let cfg = e.config_override.as_ref().unwrap().as_mapping().unwrap();
4887        assert_eq!(
4888            cfg.get(serde_yaml::Value::String("details_link".into()))
4889                .and_then(|v| v.as_str()),
4890            Some("https://approvals.example.com/req"),
4891        );
4892        // Recognized keys must NOT leak into config_override.
4893        assert!(cfg.get(serde_yaml::Value::String("from".into())).is_none());
4894    }
4895
4896    #[test]
4897    fn parse_on_error_pulled_out() {
4898        let e = parse_elicit_str(
4899            "require_approval(manager-approver, from: user.manager, on_error: continue)",
4900        );
4901        assert_eq!(e.on_error.as_deref(), Some("continue"));
4902        assert!(e.config_override.is_none());
4903    }
4904
4905    #[test]
4906    fn parse_missing_plugin_name_errors() {
4907        let value = serde_yaml::Value::String("require_approval(from: user.manager)".into());
4908        let err = parse_step(&value, "test.policy[0]").expect_err("missing plugin name");
4909        // `from: user.manager` parses as the positional first arg, so the
4910        // missing piece surfaces as the required `from` kwarg.
4911        assert!(format!("{err}").contains("requires `from`"), "got: {err}");
4912    }
4913
4914    #[test]
4915    fn parse_empty_args_errors() {
4916        let value = serde_yaml::Value::String("require_approval()".into());
4917        let err = parse_step(&value, "test.policy[0]").expect_err("empty args");
4918        assert!(format!("{err}").contains("plugin name"), "got: {err}");
4919    }
4920
4921    #[test]
4922    fn parse_plugin_kwarg_rejected() {
4923        // Passing `plugin:` as a kwarg is ambiguous with the positional.
4924        let value = serde_yaml::Value::String(
4925            "require_approval(manager-approver, plugin: other, from: user.manager)".into(),
4926        );
4927        let err = parse_step(&value, "test.policy[0]").expect_err("plugin kwarg");
4928        assert!(format!("{err}").contains("first positional"), "got: {err}");
4929    }
4930
4931    #[test]
4932    fn parse_missing_from_errors() {
4933        let value = serde_yaml::Value::String("require_approval(manager-approver)".into());
4934        let err = parse_step(&value, "test.policy[0]").expect_err("missing from");
4935        assert!(format!("{err}").contains("requires `from`"));
4936    }
4937
4938    #[test]
4939    fn parse_require_prefixed_verbs_do_not_collide() {
4940        // `require_attestation` must not be swallowed by a `require_a*`
4941        // partial match — each verb is matched with its trailing `(`.
4942        let e = parse_elicit_str("require_attestation(inband-asker, from: user.sub)");
4943        assert_eq!(e.kind, ElicitKind::Attestation);
4944    }
4945
4946    #[test]
4947    fn compile_route_with_require_approval_in_policy() {
4948        // End-to-end: the sugar verb survives compile_config and lands as
4949        // an Effect::Elicit at the right phase/source. The `when`-gated
4950        // form mirrors the manager-approval design doc.
4951        let yaml = r#"
4952routes:
4953  payroll_adjust:
4954    pre_invocation:
4955      - "require(authenticated)"
4956      - when: "args.amount > 10000"
4957        do:
4958          - "require_approval(manager-approver, from: user.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve salary change\", timeout: 24h)"
4959"#;
4960        let cfg = compile_config(yaml).expect("compile");
4961        let route = cfg.routes.get("payroll_adjust").expect("route present");
4962        assert_eq!(route.policy.len(), 2);
4963
4964        // policy[1] is the `when` wrapper; its body[0] is the elicitation.
4965        let crate::rules::Effect::When { body, .. } = &route.policy[1] else {
4966            panic!("expected When at policy[1], got {:?}", route.policy[1]);
4967        };
4968        let crate::rules::Effect::Elicit(e) = &body[0] else {
4969            panic!("expected Elicit in when-body, got {:?}", body[0]);
4970        };
4971        assert_eq!(e.kind, ElicitKind::Approval);
4972        assert_eq!(e.plugin_name, "manager-approver");
4973        assert_eq!(e.from, "user.manager");
4974        assert_eq!(e.channel.as_deref(), Some("ciba"));
4975        assert_eq!(e.scope.as_deref(), Some("args.amount <= 25000"));
4976    }
4977
4978    // ----- validate(name) compile-time rejection (DSL spec §4.2) -----
4979
4980    #[test]
4981    fn parse_pipeline_rejects_validate_stage_at_compile_time() {
4982        // Named-validator dispatch isn't implemented; the parser
4983        // rejects `validate(...)` rather than letting it through to
4984        // a runtime stub that silently passes. Diagnostic points the
4985        // operator at the working alternatives.
4986        let err = parse_pipeline("str | validate(ssn_format) | mask(4)")
4987            .expect_err("validate(name) should fail to parse");
4988        let msg = format!("{err}");
4989        assert!(
4990            msg.contains("not implemented"),
4991            "diagnostic should explain that validate is unimplemented: {msg}",
4992        );
4993        assert!(
4994            msg.contains("regex") && msg.contains("plugin"),
4995            "diagnostic should suggest regex(...) and plugin(...): {msg}",
4996        );
4997        assert!(
4998            msg.contains("ssn_format"),
4999            "diagnostic should echo the rejected validator name: {msg}",
5000        );
5001    }
5002
5003    #[test]
5004    fn parse_pipeline_does_not_reject_other_stages() {
5005        // Sanity: the validate rejection doesn't catch unrelated
5006        // stages. A pipeline with no validate stage parses cleanly.
5007        let p = parse_pipeline("str | len(..100) | regex(\"^[A-Z]+$\") | mask(4)")
5008            .expect("non-validate pipeline parses");
5009        assert_eq!(p.stages.len(), 4);
5010    }
5011}