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