1use serde_json::Value;
16
17use crate::class::Class;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Approval {
23 Propose,
25 Each,
27 Batch,
29 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#[derive(Debug, Clone, PartialEq)]
55pub struct Escalate {
56 pub model: String,
57 pub attempts: i64,
59}
60
61#[derive(Debug, Clone, PartialEq)]
62pub struct Route {
63 pub name: String,
65 pub classes: Option<Vec<Class>>,
66 pub nodes: Option<Vec<String>>,
71 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 pub scope: Option<Vec<String>>,
80 pub approval: Approval,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct Subject<'a> {
88 pub class: Class,
89 pub complexity: i64,
90 pub tier: Option<u8>,
91 pub node: Option<&'a str>,
94 pub lap: i64,
96}
97
98#[derive(Debug, Clone, PartialEq, Default)]
99pub struct Policy {
100 pub routes: Vec<Route>,
101}
102
103fn 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 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 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 pub fn route(&self, s: &Subject<'_>) -> Option<&Route> {
159 self.routes.iter().find(|r| r.matches(s))
160 }
161
162 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 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 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 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
262fn 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
277fn 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 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 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
447pub struct Difference {
451 pub run: i64,
452 pub was: String,
453 pub would_be: String,
454}
455
456pub 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 #[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 assert_eq!(named(subject(Class::Specified, 5, Some(1))), "rest");
590 assert_eq!(named(subject(Class::Specified, 3, Some(5))), "rest");
592 assert_eq!(named(subject(Class::Judgment, 5, None)), "rest");
594 }
595
596 #[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 assert_eq!(
614 named(subject(Class::Specified, 3, Some(1))).as_deref(),
615 Some("tasks")
616 );
617 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 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 assert_eq!(named(at_node("repair/fix", 0)), None);
626 }
627
628 #[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 #[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 (
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 #[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 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}