use serde_json::Value;
use crate::class::Class;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Approval {
Propose,
Each,
Batch,
Unattended,
}
impl Approval {
const ALL: [(&'static str, Approval); 4] = [
("propose", Approval::Propose),
("each", Approval::Each),
("batch", Approval::Batch),
("unattended", Approval::Unattended),
];
pub fn name(self) -> &'static str {
Self::ALL
.iter()
.find(|(_, a)| *a == self)
.map_or("", |(n, _)| n)
}
pub fn parse(s: &str) -> Option<Approval> {
Self::ALL.iter().find(|(n, _)| *n == s).map(|(_, a)| *a)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Escalate {
pub model: String,
pub attempts: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Route {
pub name: String,
pub classes: Option<Vec<Class>>,
pub nodes: Option<Vec<String>>,
pub lap: Option<(i64, i64)>,
pub complexity: Option<(i64, i64)>,
pub tier: Option<(i64, i64)>,
pub agent: Option<String>,
pub model: Option<String>,
pub escalate: Option<Escalate>,
pub scope: Option<Vec<String>>,
pub approval: Approval,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Subject<'a> {
pub class: Class,
pub complexity: i64,
pub tier: Option<u8>,
pub node: Option<&'a str>,
pub lap: i64,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Policy {
pub routes: Vec<Route>,
}
fn node_matches(pattern: &str, node: &str) -> bool {
match pattern.strip_prefix("*/") {
Some(leaf) => node.rsplit('/').next() == Some(leaf),
None => pattern == node,
}
}
impl Route {
fn matches(&self, s: &Subject<'_>) -> bool {
match (&self.nodes, s.node) {
(None, None) => {}
(None, Some(_)) | (Some(_), None) => return false,
(Some(names), Some(node)) => {
if !names.iter().any(|n| node_matches(n, node)) {
return false;
}
}
}
if let Some((lo, hi)) = self.lap
&& !(lo..=hi).contains(&s.lap)
{
return false;
}
if let Some(classes) = &self.classes
&& !classes.contains(&s.class)
{
return false;
}
if let Some((lo, hi)) = self.complexity
&& !(lo..=hi).contains(&s.complexity)
{
return false;
}
if let Some((lo, hi)) = self.tier {
match s.tier {
Some(t) => {
if !(lo..=hi).contains(&i64::from(t)) {
return false;
}
}
None => return false,
}
}
true
}
}
impl Policy {
pub fn route(&self, s: &Subject<'_>) -> Option<&Route> {
self.routes.iter().find(|r| r.matches(s))
}
pub fn parse(text: &str) -> Result<Policy, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
let list = v
.get("route")
.and_then(Value::as_array)
.ok_or("expected a `route` array")?;
if list.is_empty() {
return Err("a policy with no routes would refuse every dispatch".into());
}
let routes = list
.iter()
.enumerate()
.map(|(i, r)| parse_route(i, r))
.collect::<Result<Vec<_>, String>>()?;
Ok(Policy { routes })
}
pub fn to_json(&self) -> String {
let routes: Vec<Value> = self
.routes
.iter()
.map(|r| {
let mut m = serde_json::Map::new();
m.insert("name".into(), r.name.clone().into());
let mut cond = serde_json::Map::new();
if let Some(c) = &r.classes {
cond.insert(
"class".into(),
c.iter().map(|c| c.name()).collect::<Vec<_>>().into(),
);
}
if let Some(n) = &r.nodes {
cond.insert("node".into(), n.clone().into());
}
if let Some((lo, hi)) = r.lap {
cond.insert("lap".into(), format!("{lo}-{hi}").into());
}
if let Some((lo, hi)) = r.complexity {
cond.insert("complexity".into(), format!("{lo}-{hi}").into());
}
if let Some((lo, hi)) = r.tier {
cond.insert("tier".into(), format!("{lo}-{hi}").into());
}
m.insert("match".into(), Value::Object(cond));
if let Some(a) = &r.agent {
m.insert("agent".into(), a.clone().into());
}
if let Some(x) = &r.model {
m.insert("model".into(), x.clone().into());
}
if let Some(e) = &r.escalate {
m.insert(
"escalate".into(),
serde_json::json!({"model": e.model, "attempts": e.attempts}),
);
}
if let Some(s) = &r.scope {
m.insert("scope".into(), s.clone().into());
}
m.insert("approval".into(), r.approval.name().into());
Value::Object(m)
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({ "route": routes })).unwrap_or_default()
}
}
fn parse_route(i: usize, v: &Value) -> Result<Route, String> {
let at = |e: String| format!("route {}: {e}", i + 1);
let name = v["name"]
.as_str()
.map(String::from)
.unwrap_or_else(|| format!("#{}", i + 1));
let cond = &v["match"];
let classes = match &cond["class"] {
Value::Null => None,
Value::String(s) => Some(vec![
Class::parse(s).ok_or_else(|| at(format!("unknown class `{s}`")))?,
]),
Value::Array(a) => Some(
a.iter()
.map(|c| {
c.as_str()
.and_then(Class::parse)
.ok_or_else(|| at(format!("unknown class `{c}`")))
})
.collect::<Result<Vec<_>, String>>()?,
),
other => return Err(at(format!("class must be a name or a list, not {other}"))),
};
let nodes = match &cond["node"] {
Value::Null => None,
Value::String(s) => Some(vec![s.clone()]),
Value::Array(a) => {
let names = a
.iter()
.map(|n| {
n.as_str()
.map(String::from)
.ok_or_else(|| at("a node condition takes names".into()))
})
.collect::<Result<Vec<_>, String>>()?;
if names.is_empty() {
return Err(at("an empty node list matches nothing".into()));
}
Some(names)
}
other => return Err(at(format!("node must be a name or a list, not {other}"))),
};
let range = |key: &str| -> Result<Option<(i64, i64)>, String> {
match &cond[key] {
Value::Null => Ok(None),
Value::Number(n) => {
let n = n
.as_i64()
.ok_or_else(|| at(format!("{key} must be whole")))?;
Ok(Some((n, n)))
}
Value::String(s) => {
let (lo, hi) = s.split_once('-').unwrap_or((s, s));
let lo: i64 = lo
.trim()
.parse()
.map_err(|_| at(format!("{key} `{s}` is not a range")))?;
let hi: i64 = hi
.trim()
.parse()
.map_err(|_| at(format!("{key} `{s}` is not a range")))?;
if lo > hi {
return Err(at(format!("{key} `{s}` is empty")));
}
Ok(Some((lo, hi)))
}
other => Err(at(format!(
"{key} must be a number or a range, not {other}"
))),
}
};
let escalate = match &v["escalate"] {
Value::Null => None,
e => {
let model = e["model"]
.as_str()
.ok_or_else(|| at("escalate needs a model".into()))?;
Some(Escalate {
model: model.into(),
attempts: e["attempts"].as_i64().unwrap_or(1).clamp(0, 1),
})
}
};
let scope = match &v["scope"] {
Value::Null => None,
Value::Array(a) => Some(
a.iter()
.map(|g| {
g.as_str()
.map(String::from)
.ok_or_else(|| at("scope globs must be strings".into()))
})
.collect::<Result<Vec<_>, String>>()?,
),
_ => return Err(at("scope must be a list of globs".into())),
};
let approval = match v["approval"].as_str() {
None => return Err(at("no approval mode; state one explicitly".into())),
Some(s) => Approval::parse(s).ok_or_else(|| at(format!("unknown approval `{s}`")))?,
};
let route = Route {
name,
classes,
nodes,
lap: range("lap")?,
complexity: range("complexity")?,
tier: range("tier")?,
agent: v["agent"].as_str().map(String::from),
model: v["model"].as_str().map(String::from),
escalate,
scope,
approval,
};
if route.approval == Approval::Unattended
&& route.classes.as_ref().is_none_or(|c| {
c.iter()
.any(|c| matches!(c, Class::Privileged | Class::Judgment | Class::Never))
})
{
return Err(at(
"unattended needs an explicit class list, and never covers A-, C or D".into(),
));
}
Ok(route)
}
pub struct Difference {
pub run: i64,
pub was: String,
pub would_be: String,
}
pub fn replay(policy: &Policy, runs: &[crate::store::Run]) -> (Vec<Difference>, usize) {
let mut differences = Vec::new();
let mut compared = 0;
for run in runs {
let (Some(class), Some(complexity)) = (run.class, run.complexity) else {
continue;
};
compared += 1;
let subject = Subject {
class,
complexity,
tier: run.tier,
node: run.node.as_deref(),
lap: run.lap,
};
let was = describe(
run.route.as_deref(),
run.agent.as_str(),
run.model.as_deref(),
);
let would_be = match policy.route(&subject) {
None => "refused: no route matches".to_string(),
Some(r) => describe(
Some(&r.name),
r.agent.as_deref().unwrap_or(&run.agent),
r.model.as_deref().or(run.model.as_deref()),
),
};
if was != would_be {
differences.push(Difference {
run: run.id,
was,
would_be,
});
}
}
(differences, compared)
}
fn describe(route: Option<&str>, agent: &str, model: Option<&str>) -> String {
format!(
"{} {agent}{}",
route.unwrap_or("-"),
model.map_or(String::new(), |m| format!("/{m}"))
)
}
#[cfg(test)]
mod tests {
use super::*;
const DOC: &str = r#"{
"route": [
{"name": "chores", "match": {"class": "A", "complexity": "1-2"},
"agent": "claude", "model": "haiku",
"escalate": {"model": "sonnet", "attempts": 1},
"approval": "batch"},
{"name": "specified", "match": {"class": ["B"], "complexity": "3-4", "tier": "1-3"},
"model": "sonnet", "approval": "each"},
{"name": "rest", "match": {}, "approval": "propose"}
]
}"#;
fn subject(class: Class, complexity: i64, tier: Option<u8>) -> Subject<'static> {
Subject {
class,
complexity,
tier,
node: None,
lap: 0,
}
}
fn at_node<'a>(node: &'a str, lap: i64) -> Subject<'a> {
Subject {
node: Some(node),
lap,
..subject(Class::Specified, 3, Some(1))
}
}
#[test]
fn the_first_matching_route_wins() {
let p = Policy::parse(DOC).unwrap();
let named = |s| p.route(&s).unwrap().name.clone();
assert_eq!(named(subject(Class::Mechanical, 1, Some(4))), "chores");
assert_eq!(named(subject(Class::Specified, 3, Some(1))), "specified");
assert_eq!(named(subject(Class::Specified, 5, Some(1))), "rest");
assert_eq!(named(subject(Class::Specified, 3, Some(5))), "rest");
assert_eq!(named(subject(Class::Judgment, 5, None)), "rest");
}
#[test]
fn a_node_route_and_a_task_route_do_not_overlap() {
let doc = r#"{
"route": [
{"name": "audits", "match": {"node": "*/audit"}, "model": "opus", "approval": "each"},
{"name": "fixes", "match": {"node": ["repair/fix"], "lap": "1-2"},
"model": "sonnet", "approval": "batch"},
{"name": "tasks", "match": {}, "approval": "propose"}
]
}"#;
let p = Policy::parse(doc).unwrap();
let named = |s: Subject<'_>| p.route(&s).map(|r| r.name.clone());
assert_eq!(
named(subject(Class::Specified, 3, Some(1))).as_deref(),
Some("tasks")
);
assert_eq!(named(at_node("issues/audit", 0)).as_deref(), Some("audits"));
assert_eq!(named(at_node("repair/fix", 1)).as_deref(), Some("fixes"));
assert_eq!(named(at_node("other/audit", 0)).as_deref(), Some("audits"));
assert_eq!(named(at_node("other/fix", 1)), None, "no route serves it");
assert_eq!(named(at_node("repair/fix", 0)), None);
}
#[test]
fn an_empty_node_list_is_refused() {
let e =
Policy::parse(r#"{"route":[{"match":{"node":[]},"approval":"each"}]}"#).unwrap_err();
assert!(e.contains("matches nothing"), "{e}");
}
#[test]
fn an_untiered_project_matches_only_routes_that_do_not_ask() {
let p = Policy::parse(DOC).unwrap();
assert_eq!(
p.route(&subject(Class::Specified, 3, None)).unwrap().name,
"rest"
);
}
#[test]
fn a_policy_that_matches_nothing_is_refused_rather_than_empty() {
let p = Policy {
routes: vec![Route {
name: "narrow".into(),
classes: Some(vec![Class::Mechanical]),
nodes: None,
lap: None,
complexity: None,
tier: None,
agent: None,
model: None,
escalate: None,
scope: None,
approval: Approval::Each,
}],
};
assert!(p.route(&subject(Class::Specified, 3, Some(1))).is_none());
assert!(Policy::parse(r#"{"route": []}"#).is_err());
assert!(Policy::parse(r#"{"routes": []}"#).is_err());
}
#[test]
fn a_document_round_trips_through_its_stored_form() {
let p = Policy::parse(DOC).unwrap();
assert_eq!(Policy::parse(&p.to_json()), Ok(p));
}
#[test]
fn unreadable_routes_are_refused_with_their_position() {
for (doc, why) in [
(
r#"{"route":[{"match":{},"approval":"maybe"}]}"#,
"unknown approval",
),
(r#"{"route":[{"match":{}}]}"#, "no approval mode"),
(
r#"{"route":[{"match":{"class":"Z"},"approval":"each"}]}"#,
"unknown class",
),
(
r#"{"route":[{"match":{"complexity":"4-2"},"approval":"each"}]}"#,
"is empty",
),
(
r#"{"route":[{"match":{"complexity":"low"},"approval":"each"}]}"#,
"not a range",
),
(
r#"{"route":[{"match":{},"escalate":{"attempts":1},"approval":"each"}]}"#,
"escalate needs a model",
),
] {
let e = Policy::parse(doc).unwrap_err();
assert!(e.contains(why) && e.contains("route 1"), "{doc}: {e}");
}
}
#[test]
fn unattended_never_covers_a_privileged_or_judgment_class() {
for class in ["A-", "C", "D"] {
let doc = format!(
r#"{{"route":[{{"match":{{"class":"{class}"}},"approval":"unattended"}}]}}"#
);
assert!(Policy::parse(&doc).is_err(), "{class}");
}
assert!(Policy::parse(r#"{"route":[{"match":{},"approval":"unattended"}]}"#).is_err());
assert!(
Policy::parse(r#"{"route":[{"match":{"class":["A","B"]},"approval":"unattended"}]}"#)
.is_ok()
);
}
}