Skip to main content

pma_core/
route.rs

1//! Routing policy: which worker and model take a task, and how much autonomy
2//! the result gets. The policy is an artifact, not a judgment per task. One
3//! decision per revision, applied deterministically to every dispatch.
4//!
5//! Everything a match reads is computed without a model: class from the
6//! signal type or the item's tags, complexity from the versioned rule, tier
7//! from the project. The document is JSON because that is what this crate
8//! already parses; the design called it `routing.toml`, and adding a TOML
9//! parser plus `serde` derive for one file is a larger change than the
10//! syntax is worth.
11//!
12//! Nothing here approves, publishes or merges. A route names an approval mode;
13//! phases 4b and 4c are what act on one.
14
15use serde_json::Value;
16
17use crate::class::Class;
18
19/// How much autonomy a route's runs get. `pma` records the mode; acting on
20/// it belongs to the approval work of phases 4b and 4c.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Approval {
23    /// A patch and a summary; nothing is published.
24    Propose,
25    /// The user approves each run.
26    Each,
27    /// The user approves a filtered list.
28    Batch,
29    /// The tool publishes clean runs and reports afterwards.
30    Unattended,
31}
32
33impl Approval {
34    const ALL: [(&'static str, Approval); 4] = [
35        ("propose", Approval::Propose),
36        ("each", Approval::Each),
37        ("batch", Approval::Batch),
38        ("unattended", Approval::Unattended),
39    ];
40
41    pub fn name(self) -> &'static str {
42        Self::ALL
43            .iter()
44            .find(|(_, a)| *a == self)
45            .map_or("", |(n, _)| n)
46    }
47
48    pub fn parse(s: &str) -> Option<Approval> {
49        Self::ALL.iter().find(|(n, _)| *n == s).map(|(_, a)| *a)
50    }
51}
52
53/// A second attempt at a stronger model after the check refused the first.
54#[derive(Debug, Clone, PartialEq)]
55pub struct Escalate {
56    pub model: String,
57    /// Attempts beyond the first. Only 1 is honoured today.
58    pub attempts: i64,
59}
60
61#[derive(Debug, Clone, PartialEq)]
62pub struct Route {
63    /// For reading a replay. Defaults to the route's position.
64    pub name: String,
65    pub classes: Option<Vec<Class>>,
66    /// Workflow nodes this route serves: a qualified name, or `*/leaf` to
67    /// match one node wherever it was called from. `None` matches only a
68    /// dispatch that names no node, so an existing policy keeps its behaviour
69    /// exactly rather than absorbing every node into its catch-all.
70    pub nodes: Option<Vec<String>>,
71    /// Laps the unit has completed, as an inclusive range.
72    pub lap: Option<(i64, i64)>,
73    pub complexity: Option<(i64, i64)>,
74    pub tier: Option<(i64, i64)>,
75    pub agent: Option<String>,
76    pub model: Option<String>,
77    pub escalate: Option<Escalate>,
78    /// Overrides the class's own globs. Empty means no bound.
79    pub scope: Option<Vec<String>>,
80    pub approval: Approval,
81}
82
83/// What a dispatch knows before it chooses. Every field is recorded on the
84/// run, so a replay reads these back rather than re-deriving them from a
85/// task that may since have been edited or rescanned.
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct Subject<'a> {
88    pub class: Class,
89    pub complexity: i64,
90    pub tier: Option<u8>,
91    /// The qualified workflow node this dispatch serves, or `None` for a task
92    /// dispatched on its own.
93    pub node: Option<&'a str>,
94    /// Laps the unit had completed. 0 outside a workflow.
95    pub lap: i64,
96}
97
98#[derive(Debug, Clone, PartialEq, Default)]
99pub struct Policy {
100    pub routes: Vec<Route>,
101}
102
103/// A node condition matches an exact qualified name, or `*/leaf` against the
104/// name's last segment. Two forms, both readable; no glob language.
105fn node_matches(pattern: &str, node: &str) -> bool {
106    match pattern.strip_prefix("*/") {
107        Some(leaf) => node.rsplit('/').next() == Some(leaf),
108        None => pattern == node,
109    }
110}
111
112impl Route {
113    fn matches(&self, s: &Subject<'_>) -> bool {
114        // Node is a partition rather than a filter: a route that states none
115        // serves task dispatch alone.
116        match (&self.nodes, s.node) {
117            (None, None) => {}
118            (None, Some(_)) | (Some(_), None) => return false,
119            (Some(names), Some(node)) => {
120                if !names.iter().any(|n| node_matches(n, node)) {
121                    return false;
122                }
123            }
124        }
125        if let Some((lo, hi)) = self.lap
126            && !(lo..=hi).contains(&s.lap)
127        {
128            return false;
129        }
130        if let Some(classes) = &self.classes
131            && !classes.contains(&s.class)
132        {
133            return false;
134        }
135        if let Some((lo, hi)) = self.complexity
136            && !(lo..=hi).contains(&s.complexity)
137        {
138            return false;
139        }
140        if let Some((lo, hi)) = self.tier {
141            // An untiered project matches only a route that does not ask.
142            match s.tier {
143                Some(t) => {
144                    if !(lo..=hi).contains(&i64::from(t)) {
145                        return false;
146                    }
147                }
148                None => return false,
149            }
150        }
151        true
152    }
153}
154
155impl Policy {
156    /// The first route whose every stated condition holds. Order is the
157    /// policy: a document is read top to bottom, like the file it came from.
158    pub fn route(&self, s: &Subject<'_>) -> Option<&Route> {
159        self.routes.iter().find(|r| r.matches(s))
160    }
161
162    /// Whether any route names this node. A read node needs one that states
163    /// no class or complexity; an `edit` needs one of any kind. Tier and lap
164    /// are left out: whether a route can serve a node at all is the question.
165    pub fn names_node(&self, node: &str, edits: bool) -> bool {
166        self.routes.iter().any(|r| {
167            r.nodes
168                .as_ref()
169                .is_some_and(|names| names.iter().any(|n| node_matches(n, node)))
170                && (edits || (r.classes.is_none() && r.complexity.is_none()))
171        })
172    }
173
174    /// The first route serving a workflow node that changes no repository.
175    /// Class and complexity describe a change, so a route stating either
176    /// serves only `edit` nodes and tasks.
177    pub fn route_read(&self, node: &str, lap: i64, tier: Option<u8>) -> Option<&Route> {
178        let subject = Subject {
179            class: Class::Specified,
180            complexity: 0,
181            tier,
182            node: Some(node),
183            lap,
184        };
185        self.routes
186            .iter()
187            .filter(|r| r.classes.is_none() && r.complexity.is_none())
188            .find(|r| r.matches(&subject))
189    }
190
191    /// Reads a policy document, refusing anything it cannot apply exactly.
192    /// A route that silently does nothing is worse than one that is refused.
193    pub fn parse(text: &str) -> Result<Policy, String> {
194        let v: Value = serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
195        let list = v
196            .get("route")
197            .and_then(Value::as_array)
198            .ok_or("expected a `route` array")?;
199        if list.is_empty() {
200            return Err("a policy with no routes would refuse every dispatch".into());
201        }
202        let routes = list
203            .iter()
204            .enumerate()
205            .map(|(i, r)| parse_route(i, r))
206            .collect::<Result<Vec<_>, String>>()?;
207        Ok(Policy { routes })
208    }
209
210    /// The document as stored, so an activation and a replay read the same
211    /// text the author wrote.
212    pub fn to_json(&self) -> String {
213        let routes: Vec<Value> = self
214            .routes
215            .iter()
216            .map(|r| {
217                let mut m = serde_json::Map::new();
218                m.insert("name".into(), r.name.clone().into());
219                let mut cond = serde_json::Map::new();
220                if let Some(c) = &r.classes {
221                    cond.insert(
222                        "class".into(),
223                        c.iter().map(|c| c.name()).collect::<Vec<_>>().into(),
224                    );
225                }
226                if let Some(n) = &r.nodes {
227                    cond.insert("node".into(), n.clone().into());
228                }
229                if let Some((lo, hi)) = r.lap {
230                    cond.insert("lap".into(), format!("{lo}-{hi}").into());
231                }
232                if let Some((lo, hi)) = r.complexity {
233                    cond.insert("complexity".into(), format!("{lo}-{hi}").into());
234                }
235                if let Some((lo, hi)) = r.tier {
236                    cond.insert("tier".into(), format!("{lo}-{hi}").into());
237                }
238                m.insert("match".into(), Value::Object(cond));
239                if let Some(a) = &r.agent {
240                    m.insert("agent".into(), a.clone().into());
241                }
242                if let Some(x) = &r.model {
243                    m.insert("model".into(), x.clone().into());
244                }
245                if let Some(e) = &r.escalate {
246                    m.insert(
247                        "escalate".into(),
248                        serde_json::json!({"model": e.model, "attempts": e.attempts}),
249                    );
250                }
251                if let Some(s) = &r.scope {
252                    m.insert("scope".into(), s.clone().into());
253                }
254                m.insert("approval".into(), r.approval.name().into());
255                Value::Object(m)
256            })
257            .collect();
258        serde_json::to_string_pretty(&serde_json::json!({ "route": routes })).unwrap_or_default()
259    }
260}
261
262/// Refuses a key outside `known`. A misspelt condition would otherwise read
263/// as absent, which widens the route to match everything.
264fn known_keys(v: &Value, what: &str, known: &[&str]) -> Result<(), String> {
265    let Value::Object(m) = v else {
266        return Err(format!("{what} must be an object, not {v}"));
267    };
268    match m.keys().find(|k| !known.contains(&k.as_str())) {
269        Some(k) => Err(format!(
270            "unknown key `{k}` in {what}; expected one of {}",
271            known.join(", ")
272        )),
273        None => Ok(()),
274    }
275}
276
277/// A string field, or `None` when absent. Any other type is refused rather
278/// than dropped.
279fn text(v: &Value, key: &str) -> Result<Option<String>, String> {
280    match &v[key] {
281        Value::Null => Ok(None),
282        Value::String(s) => Ok(Some(s.clone())),
283        other => Err(format!("{key} must be a string, not {other}")),
284    }
285}
286
287fn parse_route(i: usize, v: &Value) -> Result<Route, String> {
288    let at = |e: String| format!("route {}: {e}", i + 1);
289    known_keys(
290        v,
291        "a route",
292        &[
293            "name", "match", "agent", "model", "escalate", "scope", "approval",
294        ],
295    )
296    .map_err(at)?;
297    let name = text(v, "name")
298        .map_err(at)?
299        .unwrap_or_else(|| format!("#{}", i + 1));
300    let cond = &v["match"];
301    if !cond.is_null() {
302        known_keys(
303            cond,
304            "match",
305            &["class", "node", "lap", "complexity", "tier"],
306        )
307        .map_err(at)?;
308    }
309    let classes = match &cond["class"] {
310        Value::Null => None,
311        Value::String(s) => Some(vec![
312            Class::parse(s).ok_or_else(|| at(format!("unknown class `{s}`")))?,
313        ]),
314        Value::Array(a) => Some(
315            a.iter()
316                .map(|c| {
317                    c.as_str()
318                        .and_then(Class::parse)
319                        .ok_or_else(|| at(format!("unknown class `{c}`")))
320                })
321                .collect::<Result<Vec<_>, String>>()?,
322        ),
323        other => return Err(at(format!("class must be a name or a list, not {other}"))),
324    };
325    let nodes = match &cond["node"] {
326        Value::Null => None,
327        Value::String(s) => Some(vec![s.clone()]),
328        Value::Array(a) => {
329            let names = a
330                .iter()
331                .map(|n| {
332                    n.as_str()
333                        .map(String::from)
334                        .ok_or_else(|| at("a node condition takes names".into()))
335                })
336                .collect::<Result<Vec<_>, String>>()?;
337            if names.is_empty() {
338                return Err(at("an empty node list matches nothing".into()));
339            }
340            Some(names)
341        }
342        other => return Err(at(format!("node must be a name or a list, not {other}"))),
343    };
344    // `bounds` is what the value can take, so a range outside it, which
345    // would match nothing, is refused.
346    let range = |key: &str, (min, max): (i64, i64)| -> Result<Option<(i64, i64)>, String> {
347        let (lo, hi) = match &cond[key] {
348            Value::Null => return Ok(None),
349            Value::Number(n) => {
350                let n = n
351                    .as_i64()
352                    .ok_or_else(|| at(format!("{key} must be whole")))?;
353                (n, n)
354            }
355            Value::String(s) => {
356                let (lo, hi) = s.split_once('-').unwrap_or((s, s));
357                let lo: i64 = lo
358                    .trim()
359                    .parse()
360                    .map_err(|_| at(format!("{key} `{s}` is not a range")))?;
361                let hi: i64 = hi
362                    .trim()
363                    .parse()
364                    .map_err(|_| at(format!("{key} `{s}` is not a range")))?;
365                if lo > hi {
366                    return Err(at(format!("{key} `{s}` is empty")));
367                }
368                (lo, hi)
369            }
370            other => {
371                return Err(at(format!(
372                    "{key} must be a number or a range, not {other}"
373                )));
374            }
375        };
376        if lo < min || hi > max {
377            let bound = match max {
378                i64::MAX => format!("at least {min}"),
379                _ => format!("{min} to {max}"),
380            };
381            return Err(at(format!("{key} must be {bound}, not {}", cond[key])));
382        }
383        Ok(Some((lo, hi)))
384    };
385    let escalate = match &v["escalate"] {
386        Value::Null => None,
387        e => {
388            known_keys(e, "escalate", &["model", "attempts"]).map_err(at)?;
389            let model = text(e, "model")
390                .map_err(at)?
391                .ok_or_else(|| at("escalate needs a model".into()))?;
392            let attempts = match &e["attempts"] {
393                Value::Null => 1,
394                a => a
395                    .as_i64()
396                    .filter(|n| (0..=1).contains(n))
397                    .ok_or_else(|| at(format!("escalate attempts must be 0 or 1, not {a}")))?,
398            };
399            Some(Escalate { model, attempts })
400        }
401    };
402    let scope = match &v["scope"] {
403        Value::Null => None,
404        Value::Array(a) => Some(
405            a.iter()
406                .map(|g| {
407                    g.as_str()
408                        .map(String::from)
409                        .ok_or_else(|| at("scope globs must be strings".into()))
410                })
411                .collect::<Result<Vec<_>, String>>()?,
412        ),
413        _ => return Err(at("scope must be a list of globs".into())),
414    };
415    let approval = match v["approval"].as_str() {
416        None => return Err(at("no approval mode; state one explicitly".into())),
417        Some(s) => Approval::parse(s).ok_or_else(|| at(format!("unknown approval `{s}`")))?,
418    };
419    let route = Route {
420        name,
421        classes,
422        nodes,
423        lap: range("lap", (0, i64::MAX))?,
424        complexity: range("complexity", (1, 5))?,
425        tier: range("tier", (1, 5))?,
426        agent: text(v, "agent").map_err(at)?,
427        model: text(v, "model").map_err(at)?,
428        escalate,
429        scope,
430        approval,
431    };
432    // A limit no policy may raise. Checked when the document is read, so an
433    // unacceptable route cannot be stored, let alone activated.
434    if route.approval == Approval::Unattended
435        && route.classes.as_ref().is_none_or(|c| {
436            c.iter()
437                .any(|c| matches!(c, Class::Privileged | Class::Judgment | Class::Never))
438        })
439    {
440        return Err(at(
441            "unattended needs an explicit class list, and never covers A-, C or D".into(),
442        ));
443    }
444    Ok(route)
445}
446
447/// What a candidate policy would have done to the runs already recorded.
448/// The comparison reads each run's own snapshot, so a task edited, retiered,
449/// rescanned or reworked since does not change the answer.
450pub struct Difference {
451    pub run: i64,
452    pub was: String,
453    pub would_be: String,
454}
455
456/// `runs` are compared by the route each would match now. A run dispatched
457/// before any policy existed has no recorded route; it is reported as `-`,
458/// which is a difference worth seeing rather than a match.
459pub fn replay(policy: &Policy, runs: &[crate::store::Run]) -> (Vec<Difference>, usize) {
460    let mut differences = Vec::new();
461    let mut compared = 0;
462    for run in runs {
463        let (Some(class), Some(complexity)) = (run.class, run.complexity) else {
464            continue;
465        };
466        compared += 1;
467        let subject = Subject {
468            class,
469            complexity,
470            tier: run.tier,
471            node: run.node.as_deref(),
472            lap: run.lap,
473        };
474        let was = describe(
475            run.route.as_deref(),
476            run.agent.as_str(),
477            run.model.as_deref(),
478            run.approval,
479            &run.scope,
480        );
481        let would_be = match policy.route(&subject) {
482            None => "refused: no route matches".to_string(),
483            Some(r) => describe(
484                Some(&r.name),
485                r.agent.as_deref().unwrap_or(&run.agent),
486                r.model.as_deref().or(run.model.as_deref()),
487                Some(r.approval),
488                r.scope.as_ref().unwrap_or(&class.scope()),
489            ),
490        };
491        if was != would_be {
492            differences.push(Difference {
493                run: run.id,
494                was,
495                would_be,
496            });
497        }
498    }
499    (differences, compared)
500}
501
502fn describe(
503    route: Option<&str>,
504    agent: &str,
505    model: Option<&str>,
506    approval: Option<Approval>,
507    scope: &[String],
508) -> String {
509    format!(
510        "{} {agent}{} {}{}",
511        route.unwrap_or("-"),
512        model.map_or(String::new(), |m| format!("/{m}")),
513        approval.map_or("-", Approval::name),
514        match scope {
515            [] => String::new(),
516            globs => format!(" in {}", globs.join(",")),
517        }
518    )
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    /// A change to approval or scope changes what happens to a run, so a
526    /// replay that saw only the route, agent and model would call it a match.
527    #[test]
528    fn replay_compares_approval_and_scope() {
529        let policy = |approval: &str, scope: &str| {
530            Policy::parse(&format!(
531                r#"{{"route":[{{"name":"r","match":{{}},"approval":"{approval}"{scope}}}]}}"#
532            ))
533            .unwrap()
534        };
535        let run = crate::store::Run {
536            id: 1,
537            agent: "claude".into(),
538            class: Some(Class::Specified),
539            complexity: Some(3),
540            route: Some("r".into()),
541            approval: Some(Approval::Each),
542            scope: Class::Specified.scope(),
543            ..crate::store::Run::blank()
544        };
545        let runs = [run];
546        assert!(replay(&policy("each", ""), &runs).0.is_empty());
547        assert_eq!(replay(&policy("batch", ""), &runs).0.len(), 1);
548        let (d, _) = replay(&policy("each", r#","scope":["src/**"]"#), &runs);
549        assert!(d[0].would_be.ends_with("in src/**"), "{}", d[0].would_be);
550    }
551
552    const DOC: &str = r#"{
553      "route": [
554        {"name": "chores", "match": {"class": "A", "complexity": "1-2"},
555         "agent": "claude", "model": "haiku",
556         "escalate": {"model": "sonnet", "attempts": 1},
557         "approval": "batch"},
558        {"name": "specified", "match": {"class": ["B"], "complexity": "3-4", "tier": "1-3"},
559         "model": "sonnet", "approval": "each"},
560        {"name": "rest", "match": {}, "approval": "propose"}
561      ]
562    }"#;
563
564    fn subject(class: Class, complexity: i64, tier: Option<u8>) -> Subject<'static> {
565        Subject {
566            class,
567            complexity,
568            tier,
569            node: None,
570            lap: 0,
571        }
572    }
573
574    fn at_node<'a>(node: &'a str, lap: i64) -> Subject<'a> {
575        Subject {
576            node: Some(node),
577            lap,
578            ..subject(Class::Specified, 3, Some(1))
579        }
580    }
581
582    #[test]
583    fn the_first_matching_route_wins() {
584        let p = Policy::parse(DOC).unwrap();
585        let named = |s| p.route(&s).unwrap().name.clone();
586        assert_eq!(named(subject(Class::Mechanical, 1, Some(4))), "chores");
587        assert_eq!(named(subject(Class::Specified, 3, Some(1))), "specified");
588        // Right class, complexity outside the range.
589        assert_eq!(named(subject(Class::Specified, 5, Some(1))), "rest");
590        // Right class and complexity, tier outside it.
591        assert_eq!(named(subject(Class::Specified, 3, Some(5))), "rest");
592        // A condition that is not stated does not narrow anything.
593        assert_eq!(named(subject(Class::Judgment, 5, None)), "rest");
594    }
595
596    /// Node is a partition, not a filter: an existing policy keeps serving
597    /// task dispatch and does not absorb a workflow's nodes into its
598    /// catch-all, which would route them at whatever approval it names.
599    #[test]
600    fn a_node_route_and_a_task_route_do_not_overlap() {
601        let doc = r#"{
602          "route": [
603            {"name": "audits", "match": {"node": "*/audit"}, "model": "opus", "approval": "each"},
604            {"name": "fixes", "match": {"node": ["repair/fix"], "lap": "1-2"},
605             "model": "sonnet", "approval": "batch"},
606            {"name": "tasks", "match": {}, "approval": "propose"}
607          ]
608        }"#;
609        let p = Policy::parse(doc).unwrap();
610        let named = |s: Subject<'_>| p.route(&s).map(|r| r.name.clone());
611
612        // A task names no node, so only the node-less route serves it.
613        assert_eq!(
614            named(subject(Class::Specified, 3, Some(1))).as_deref(),
615            Some("tasks")
616        );
617        // A node is served by a node route, and never by the catch-all.
618        assert_eq!(named(at_node("issues/audit", 0)).as_deref(), Some("audits"));
619        assert_eq!(named(at_node("repair/fix", 1)).as_deref(), Some("fixes"));
620        // `*/audit` matches the leaf wherever it was called from; an exact
621        // name does not.
622        assert_eq!(named(at_node("other/audit", 0)).as_deref(), Some("audits"));
623        assert_eq!(named(at_node("other/fix", 1)), None, "no route serves it");
624        // Lap narrows: the first attempt at `repair/fix` matches nothing.
625        assert_eq!(named(at_node("repair/fix", 0)), None);
626    }
627
628    /// A node that edits nothing has no class or complexity, so only a node
629    /// route that states neither serves it, and a task route never does.
630    #[test]
631    fn a_read_node_takes_a_node_route_that_states_no_class() {
632        let policy = Policy::parse(
633            r#"{"route": [
634            {"name": "edits", "match": {"node": "*/review", "class": "B"}, "model": "opus", "approval": "each"},
635            {"name": "reads", "match": {"node": "*/review", "lap": "0"}, "model": "haiku", "approval": "each"},
636            {"name": "tasks", "approval": "each"}
637        ]}"#,
638        )
639        .unwrap();
640        let named = |node: &str, lap: i64| {
641            policy
642                .route_read(node, lap, Some(1))
643                .map(|r| r.name.clone())
644        };
645        assert_eq!(named("look/review", 0).as_deref(), Some("reads"));
646        assert_eq!(named("look/review", 1), None, "outside the lap range");
647        assert_eq!(
648            named("look/confirm", 0),
649            None,
650            "the catch-all serves tasks only"
651        );
652    }
653
654    #[test]
655    fn a_node_is_named_by_a_route_that_can_serve_it() {
656        let policy = Policy::parse(
657            r#"{"route": [
658            {"match": {"node": "*/fix", "class": "B"}, "approval": "each"},
659            {"match": {"node": "look/review"}, "approval": "each"},
660            {"approval": "each"}
661        ]}"#,
662        )
663        .unwrap();
664        assert!(policy.names_node("look/review", false));
665        assert!(
666            policy.names_node("repair/fix", true),
667            "an edit takes a class route"
668        );
669        assert!(
670            !policy.names_node("repair/fix", false),
671            "a read node does not"
672        );
673        assert!(
674            !policy.names_node("look/confirm", false),
675            "the catch-all names no node"
676        );
677    }
678
679    #[test]
680    fn an_empty_node_list_is_refused() {
681        let e =
682            Policy::parse(r#"{"route":[{"match":{"node":[]},"approval":"each"}]}"#).unwrap_err();
683        assert!(e.contains("matches nothing"), "{e}");
684    }
685
686    /// A route that asks about tier cannot match a project without one.
687    #[test]
688    fn an_untiered_project_matches_only_routes_that_do_not_ask() {
689        let p = Policy::parse(DOC).unwrap();
690        assert_eq!(
691            p.route(&subject(Class::Specified, 3, None)).unwrap().name,
692            "rest"
693        );
694    }
695
696    #[test]
697    fn a_policy_that_matches_nothing_is_refused_rather_than_empty() {
698        let p = Policy {
699            routes: vec![Route {
700                name: "narrow".into(),
701                classes: Some(vec![Class::Mechanical]),
702                nodes: None,
703                lap: None,
704                complexity: None,
705                tier: None,
706                agent: None,
707                model: None,
708                escalate: None,
709                scope: None,
710                approval: Approval::Each,
711            }],
712        };
713        assert!(p.route(&subject(Class::Specified, 3, Some(1))).is_none());
714        assert!(Policy::parse(r#"{"route": []}"#).is_err());
715        assert!(Policy::parse(r#"{"routes": []}"#).is_err());
716    }
717
718    #[test]
719    fn a_document_round_trips_through_its_stored_form() {
720        let p = Policy::parse(DOC).unwrap();
721        assert_eq!(Policy::parse(&p.to_json()), Ok(p));
722    }
723
724    #[test]
725    fn unreadable_routes_are_refused_with_their_position() {
726        for (doc, why) in [
727            (
728                r#"{"route":[{"match":{},"approval":"maybe"}]}"#,
729                "unknown approval",
730            ),
731            (r#"{"route":[{"match":{}}]}"#, "no approval mode"),
732            (
733                r#"{"route":[{"match":{"class":"Z"},"approval":"each"}]}"#,
734                "unknown class",
735            ),
736            (
737                r#"{"route":[{"match":{"complexity":"4-2"},"approval":"each"}]}"#,
738                "is empty",
739            ),
740            (
741                r#"{"route":[{"match":{"complexity":"low"},"approval":"each"}]}"#,
742                "not a range",
743            ),
744            (
745                r#"{"route":[{"match":{},"escalate":{"attempts":1},"approval":"each"}]}"#,
746                "escalate needs a model",
747            ),
748            // A misspelt key would read as absent and match everything.
749            (
750                r#"{"route":[{"match":{"clas":"A"},"approval":"each"}]}"#,
751                "unknown key `clas` in match",
752            ),
753            (
754                r#"{"route":[{"match":{"complexty":"1-2"},"approval":"each"}]}"#,
755                "unknown key `complexty`",
756            ),
757            (
758                r#"{"route":[{"match":{},"modle":"haiku","approval":"each"}]}"#,
759                "unknown key `modle` in a route",
760            ),
761            (
762                r#"{"route":[{"match":{},"escalate":{"model":"opus","attempt":1},"approval":"each"}]}"#,
763                "unknown key `attempt` in escalate",
764            ),
765            (
766                r#"{"route":[{"match":"A","approval":"each"}]}"#,
767                "match must be an object",
768            ),
769            (r#"{"route":["A"]}"#, "a route must be an object"),
770            (
771                r#"{"route":[{"match":{},"model":4,"approval":"each"}]}"#,
772                "model must be a string",
773            ),
774            (
775                r#"{"route":[{"match":{},"agent":["claude"],"approval":"each"}]}"#,
776                "agent must be a string",
777            ),
778            (
779                r#"{"route":[{"name":7,"match":{},"approval":"each"}]}"#,
780                "name must be a string",
781            ),
782            (
783                r#"{"route":[{"match":{},"escalate":{"model":"opus","attempts":3},"approval":"each"}]}"#,
784                "attempts must be 0 or 1",
785            ),
786            (
787                r#"{"route":[{"match":{"complexity":"4-9"},"approval":"each"}]}"#,
788                "complexity must be 1 to 5",
789            ),
790            (
791                r#"{"route":[{"match":{"tier":0},"approval":"each"}]}"#,
792                "tier must be 1 to 5",
793            ),
794            (
795                r#"{"route":[{"match":{"lap":-1},"approval":"each"}]}"#,
796                "lap must be at least 0",
797            ),
798        ] {
799            let e = Policy::parse(doc).unwrap_err();
800            assert!(e.contains(why) && e.contains("route 1"), "{doc}: {e}");
801        }
802    }
803
804    /// A limit no document may raise, refused where it is read rather than
805    /// where it would be acted on.
806    #[test]
807    fn unattended_never_covers_a_privileged_or_judgment_class() {
808        for class in ["A-", "C", "D"] {
809            let doc = format!(
810                r#"{{"route":[{{"match":{{"class":"{class}"}},"approval":"unattended"}}]}}"#
811            );
812            assert!(Policy::parse(&doc).is_err(), "{class}");
813        }
814        // An unstated class list would cover them all.
815        assert!(Policy::parse(r#"{"route":[{"match":{},"approval":"unattended"}]}"#).is_err());
816        assert!(
817            Policy::parse(r#"{"route":[{"match":{"class":["A","B"]},"approval":"unattended"}]}"#)
818                .is_ok()
819        );
820    }
821}