Skip to main content

kranz_engine/
routing_rules.rs

1//! Tracked routing rules (`.kranz/routing-rules.json`) — the base-branch-owned
2//! FILE surface that populates the engine's routing table (ticket
3//! `routing-rules-config`, the config-surface slice of KRZ-331).
4//!
5//! The routing floor ([`crate::routing`]) resolves a ticket's task class to
6//! an executor capability class deterministically. This module is how a repo
7//! declares that table as a tracked, reviewed artifact instead of layered
8//! config: two rule forms, mirroring the reference design's "complexity
9//! tiers + ordered rules" —
10//!
11//! ```json
12//! {
13//!   "taskClassRules": [
14//!     {"taskClass": "execution-class", "tier": "local"}
15//!   ],
16//!   "patternRules": [
17//!     {"pattern": "docs-*", "tier": "frontier"},
18//!     {"pattern": "*", "tier": "frontier"}
19//!   ]
20//! }
21//! ```
22//!
23//! - `taskClassRules` — the complexity-tier form: an exact task class
24//!   (trimmed, case-insensitive) routed to a tier.
25//! - `patternRules` — ordered pattern rules, consulted only when no exact
26//!   rule matched: `*` matches any run of characters, everything else is
27//!   literal. First match wins; no match anywhere falls through to
28//!   `frontier`.
29//!
30//! Tiers are capability classes ([`crate::types::ExecutorTier`]: `local` |
31//! `frontier`), NEVER model ids — the `tier` field deserializes the enum, so
32//! a model-id-shaped value is a parse error, not a route (pinned by a test
33//! below; the same no-model-id rule the floor's own grep test pins on
34//! [`crate::routing`]).
35//!
36//! Ownership is the merge-gates idiom ([`crate::merge_gate`],
37//! [`crate::merge`]): the bytes are read from the LIVE BASE BRANCH ref at
38//! mission creation, never from the working tree and never from the mission
39//! branch, so a mission cannot edit the rules that route it. A
40//! mission-branch edit is therefore structurally ignored — and surfaced:
41//! `run()` compares the mission branch's copy against the base's and records
42//! an operator-visible `orchestrator.decision` when they differ
43//! ([`crate::orchestrator::MissionEngine`]).
44//!
45//! Behavior contract, mirroring the workspace contract
46//! ([`crate::workspace_contract`]):
47//! - **Missing file ⇒ `Ok(None)`** — today's layered-config/per-role
48//!   behavior, byte-identical. Never an error.
49//! - **Present-but-empty ⇒ fail closed** the same way: a present file IS
50//!   the routing table, so an empty one (`{}`, or both rule lists empty)
51//!   would silently demote routing to the legacy task-class floor — delete
52//!   the file to keep the layered config instead.
53//! - **Present-but-invalid ⇒ fail closed** at draft (mission creation) and
54//!   at approve, naming the file, the rule index, and the field.
55//! - **Present and valid ⇒ the file IS the table**: it supersedes any
56//!   layered-config `routing` key wholesale (one source of truth, no
57//!   merge-order puzzle), and the supersession is recorded on the mission's
58//!   decision log.
59
60use crate::error::{EngineError, Result};
61use crate::git_ops::GitRepo;
62use crate::types::{PatternRoute, RoutingConfig, TaskClassRoute};
63use serde::Deserialize;
64
65pub const ROUTING_RULES_PATH: &str = ".kranz/routing-rules.json";
66
67/// The on-disk shape. Strict (`deny_unknown_fields`) at the top level — a
68/// typo'd key in a reviewed contract artifact is a mistake worth naming, the
69/// same posture as `.kranz/merge-gates.json` and `.kranz/workspace.json`.
70/// Rule-level keys reuse the engine's [`TaskClassRoute`]/[`PatternRoute`]
71/// shapes, so the file IS the table after conversion: no second schema to
72/// drift.
73#[derive(Debug, Deserialize)]
74#[serde(rename_all = "camelCase", deny_unknown_fields)]
75struct RoutingRulesFile {
76    #[serde(default)]
77    task_class_rules: Vec<TaskClassRoute>,
78    #[serde(default)]
79    pattern_rules: Vec<PatternRoute>,
80}
81
82/// Parse and validate routing-rules file bytes. Every validation failure
83/// names the file and the offending rule (index + field, via
84/// [`crate::routing::validate_table`]); parse failures carry the serde
85/// location. An EMPTY table is refused too — a present file IS the routing
86/// table, so `{}` would silently demote routing to the legacy task-class
87/// floor; the refusal names the file and points at the fix (delete the file
88/// to keep the layered config). A valid file converts into the engine's
89/// [`RoutingConfig`] verbatim — both rule forms, order preserved.
90pub fn parse_routing_rules(bytes: &[u8]) -> std::result::Result<RoutingConfig, String> {
91    let file: RoutingRulesFile = serde_json::from_slice(bytes)
92        .map_err(|e| format!("invalid JSON in {ROUTING_RULES_PATH}: {e}"))?;
93    let routing = RoutingConfig {
94        task_class_rules: file.task_class_rules,
95        pattern_rules: file.pattern_rules,
96    };
97    if routing.is_empty() {
98        return Err(format!(
99            "{ROUTING_RULES_PATH}: the rules table is empty: a present file IS the routing \
100             table, so an empty one would silently demote routing to the legacy task-class \
101             floor — delete the file to keep the layered config, or declare at least one rule"
102        ));
103    }
104    crate::routing::validate_table(&routing)
105        .map_err(|violation| format!("{ROUTING_RULES_PATH}: {violation}"))?;
106    Ok(routing)
107}
108
109/// Load the rules as COMMITTED on `ref_name` (the live base branch at
110/// mission-creation time — merge.rs's `live_base_sha` idiom): committed
111/// bytes only, so an uncommitted working-tree edit or a mission-branch edit
112/// can never re-route a mission. Missing ⇒ `Ok(None)` (the no-file
113/// regression: today's behavior, byte-identical); present-but-invalid or
114/// present-but-empty ⇒ the fail-closed [`EngineError`] shape the workspace
115/// contract uses, owner repo-setup.
116pub fn load_routing_rules_at_ref(repo: &GitRepo, ref_name: &str) -> Result<Option<RoutingConfig>> {
117    match repo.show_file(ref_name, ROUTING_RULES_PATH)? {
118        None => Ok(None),
119        Some(bytes) => parse_routing_rules(&bytes).map(Some).map_err(|violation| {
120            EngineError::Config(format!(
121                "routing rules {ROUTING_RULES_PATH} at {ref_name} is invalid (owner: repo-setup): {violation}"
122            ))
123        }),
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::types::ExecutorTier;
131
132    fn git_repo() -> Option<(tempfile::TempDir, GitRepo)> {
133        // Mirror the engine test idiom: skip cleanly when git is unavailable.
134        let dir = tempfile::tempdir().unwrap();
135        let init = std::process::Command::new("git")
136            .args(["init", "-b", "main"])
137            .current_dir(dir.path())
138            .output()
139            .ok()?;
140        if !init.status.success() {
141            return None;
142        }
143        for args in [
144            &["config", "user.name", "test"][..],
145            &["config", "user.email", "test@example.com"][..],
146        ] {
147            assert!(std::process::Command::new("git")
148                .args(args)
149                .current_dir(dir.path())
150                .output()
151                .unwrap()
152                .status
153                .success());
154        }
155        std::fs::write(dir.path().join("README.md"), "seed\n").unwrap();
156        for args in [&["add", "-A"][..], &["commit", "-m", "seed"][..]] {
157            assert!(std::process::Command::new("git")
158                .args(args)
159                .current_dir(dir.path())
160                .output()
161                .unwrap()
162                .status
163                .success());
164        }
165        let root = std::fs::canonicalize(dir.path()).unwrap();
166        Some((dir, GitRepo::open(&root).unwrap()))
167    }
168
169    fn commit_rules(repo_root: &std::path::Path, branch: &str, bytes: &str) {
170        let run = |args: &[&str]| {
171            assert!(std::process::Command::new("git")
172                .args(args)
173                .current_dir(repo_root)
174                .output()
175                .unwrap()
176                .status
177                .success());
178        };
179        run(&["checkout", branch]);
180        let path = repo_root.join(ROUTING_RULES_PATH);
181        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
182        std::fs::write(&path, bytes).unwrap();
183        run(&["add", "-A"]);
184        run(&["commit", "-m", "rules"]);
185        run(&["checkout", "main"]);
186    }
187
188    const VALID: &str = r#"{
189        "taskClassRules": [
190            {"taskClass": "execution-class", "tier": "local"}
191        ],
192        "patternRules": [
193            {"pattern": "docs-*", "tier": "frontier"},
194            {"pattern": "*", "tier": "frontier"}
195        ]
196    }"#;
197
198    #[test]
199    fn routing_rules_config_parses_both_rule_forms() {
200        let routing = parse_routing_rules(VALID.as_bytes()).expect("a valid file parses");
201        assert_eq!(routing.task_class_rules.len(), 1);
202        assert_eq!(routing.task_class_rules[0].task_class, "execution-class");
203        assert_eq!(routing.task_class_rules[0].tier, ExecutorTier::Local);
204        assert_eq!(routing.pattern_rules.len(), 2);
205        assert_eq!(routing.pattern_rules[0].pattern, "docs-*");
206        assert!(!routing.is_empty());
207    }
208
209    #[test]
210    fn routing_rules_config_invalid_rule_fails_closed_naming_index_and_field() {
211        // Not JSON: parse failure, file named, serde location carried.
212        let err = parse_routing_rules(b"not json").unwrap_err();
213        assert!(err.contains(ROUTING_RULES_PATH), "{err}");
214
215        // Blank task class: rule index + field named.
216        let err = parse_routing_rules(
217            br#"{"taskClassRules": [{"taskClass": "ok-class", "tier": "local"},
218                                      {"taskClass": "  ", "tier": "frontier"}]}"#,
219        )
220        .unwrap_err();
221        assert!(err.contains(ROUTING_RULES_PATH), "{err}");
222        assert!(err.contains("taskClassRules[1].taskClass"), "{err}");
223
224        // Duplicate pattern after normalization: rule index + field named.
225        let err = parse_routing_rules(
226            br#"{"patternRules": [{"pattern": "docs-*", "tier": "local"},
227                                  {"pattern": " DOCS-* ", "tier": "frontier"}]}"#,
228        )
229        .unwrap_err();
230        assert!(err.contains("patternRules[1].pattern"), "{err}");
231        assert!(err.contains("duplicates rule 0"), "{err}");
232
233        // Unknown top-level key: strict schema refuses the typo.
234        let err = parse_routing_rules(br#"{"taskclassRules": []}"#).unwrap_err();
235        assert!(err.contains(ROUTING_RULES_PATH), "{err}");
236    }
237
238    /// The capability-classes-only rule on the FILE schema: where the field
239    /// means a tier, a model-id-shaped value is a parse error — the enum has
240    /// no variant for it, so no rule can ever route to a hardcoded model.
241    #[test]
242    fn routing_rules_config_schema_rejects_model_id_shaped_tiers() {
243        for shape in [
244            br#"{"taskClassRules": [{"taskClass": "execution-class", "tier": "claude-opus-4-1"}]}"#
245                .as_slice(),
246            br#"{"patternRules": [{"pattern": "*", "tier": "gpt-5-codex"}]}"#.as_slice(),
247            br#"{"patternRules": [{"pattern": "*", "tier": "sonnet"}]}"#.as_slice(),
248        ] {
249            let err = parse_routing_rules(shape).unwrap_err();
250            assert!(
251                err.contains("unknown variant") || err.contains("tier"),
252                "a model-id-shaped tier must fail the schema: {err}"
253            );
254        }
255    }
256
257    #[test]
258    fn routing_rules_config_reads_committed_base_bytes_only() {
259        let Some((dir, repo)) = git_repo() else {
260            crate::test_capability::skip(
261                crate::test_capability::capability::GIT,
262                "git is not on PATH",
263            );
264            return;
265        };
266        commit_rules(dir.path(), "main", VALID);
267
268        // Present on the base ref ⇒ Some, regardless of the working tree...
269        let routing = load_routing_rules_at_ref(&repo, "main")
270            .expect("load must not error")
271            .expect("committed rules load");
272        assert_eq!(routing.task_class_rules.len(), 1);
273
274        // ...including when the working-tree copy was edited AFTER the commit
275        // (uncommitted operator edits never reach a mission).
276        std::fs::write(dir.path().join(ROUTING_RULES_PATH), "not json").unwrap();
277        assert!(
278            load_routing_rules_at_ref(&repo, "main").unwrap().is_some(),
279            "the committed bytes govern, not the dirty working tree"
280        );
281
282        // A branch WITHOUT the file ⇒ None: the no-file regression.
283        assert!(load_routing_rules_at_ref(&repo, "HEAD~1")
284            .unwrap()
285            .is_none());
286    }
287
288    #[test]
289    fn routing_rules_config_mission_branch_edit_is_ignored_at_read() {
290        let Some((dir, repo)) = git_repo() else {
291            crate::test_capability::skip(
292                crate::test_capability::capability::GIT,
293                "git is not on PATH",
294            );
295            return;
296        };
297        commit_rules(dir.path(), "main", VALID);
298        // A mission branch weakens the rules: its copy never governs the read.
299        let run = |args: &[&str]| {
300            assert!(std::process::Command::new("git")
301                .args(args)
302                .current_dir(dir.path())
303                .output()
304                .unwrap()
305                .status
306                .success());
307        };
308        run(&["checkout", "-b", "kranz/mission-x"]);
309        commit_rules(
310            dir.path(),
311            "kranz/mission-x",
312            r#"{"patternRules": [{"pattern": "*", "tier": "local"}]}"#,
313        );
314
315        let routing = load_routing_rules_at_ref(&repo, "main")
316            .unwrap()
317            .expect("base rules load");
318        assert!(
319            routing
320                .pattern_rules
321                .iter()
322                .all(|r| r.pattern != "*" || r.tier == ExecutorTier::Frontier),
323            "the mission branch's weakened copy must never reach the base read"
324        );
325        // And the base read differs from the mission branch's — the drift the
326        // run-time note surfaces.
327        let mission = load_routing_rules_at_ref(&repo, "kranz/mission-x")
328            .unwrap()
329            .unwrap();
330        assert_ne!(
331            routing, mission,
332            "fixture: the branches' copies must differ"
333        );
334    }
335
336    #[test]
337    fn routing_rules_config_invalid_at_ref_fails_closed_owner_repo_setup() {
338        let Some((dir, repo)) = git_repo() else {
339            crate::test_capability::skip(
340                crate::test_capability::capability::GIT,
341                "git is not on PATH",
342            );
343            return;
344        };
345        commit_rules(
346            dir.path(),
347            "main",
348            r#"{"taskClassRules": [{"taskClass": "", "tier": "local"}]}"#,
349        );
350        let err = load_routing_rules_at_ref(&repo, "main").unwrap_err();
351        let text = format!("{err}");
352        assert!(text.contains(ROUTING_RULES_PATH), "{text}");
353        assert!(text.contains("owner: repo-setup"), "{text}");
354        assert!(text.contains("taskClassRules[0].taskClass"), "{text}");
355    }
356
357    /// The empty-table wipe (ticket routing-rules-empty-table-wipe): a
358    /// PRESENT empty file must fail closed exactly like an invalid one —
359    /// `Some(empty)` would replace a non-empty layered table and silently
360    /// demote routing to the legacy task-class floor.
361    #[test]
362    fn routing_rules_config_empty_table_fails_closed_at_parse() {
363        for shape in [
364            b"{}".as_slice(),
365            br#"{"taskClassRules": [], "patternRules": []}"#.as_slice(),
366            br#"{"taskClassRules": []}"#.as_slice(),
367            br#"{"patternRules": []}"#.as_slice(),
368        ] {
369            let err = parse_routing_rules(shape).unwrap_err();
370            assert!(err.contains(ROUTING_RULES_PATH), "{err}");
371            assert!(err.contains("empty"), "{err}");
372        }
373        // And a non-empty valid file is unchanged.
374        assert!(parse_routing_rules(VALID.as_bytes()).is_ok());
375    }
376
377    /// Same posture at the load seam: a committed empty file fails closed
378    /// (owner: repo-setup) at the same point an invalid file does, while a
379    /// MISSING file still loads as `None` — the layered-config behavior,
380    /// byte-identical.
381    #[test]
382    fn routing_rules_config_empty_table_at_ref_fails_closed_missing_stays_none() {
383        let Some((dir, repo)) = git_repo() else {
384            crate::test_capability::skip(
385                crate::test_capability::capability::GIT,
386                "git is not on PATH",
387            );
388            return;
389        };
390        commit_rules(dir.path(), "main", "{}");
391        let err = load_routing_rules_at_ref(&repo, "main").unwrap_err();
392        let text = format!("{err}");
393        assert!(text.contains(ROUTING_RULES_PATH), "{text}");
394        assert!(text.contains("owner: repo-setup"), "{text}");
395        assert!(text.contains("empty"), "{text}");
396
397        // The seed commit carries no file: missing ⇒ None, never an error.
398        assert!(load_routing_rules_at_ref(&repo, "HEAD~1")
399            .unwrap()
400            .is_none());
401    }
402
403    /// The no-hardcoded-model-ids rule, pinned as a grep over this module's
404    /// NON-TEST source — the same discipline [`crate::routing`]'s own test
405    /// pins on the floor: the file surface resolves capability classes only,
406    /// so no model-id literal may appear in its logic or docs. (The needles
407    /// live down here in the test module, which the split excludes.)
408    #[test]
409    fn routing_rules_config_file_surface_carries_no_model_id_literals() {
410        let source = include_str!("routing_rules.rs");
411        let logic = source
412            .split("#[cfg(test)]")
413            .next()
414            .expect("the test module marker exists");
415        for needle in [
416            "\"opus\"",
417            "\"sonnet\"",
418            "\"haiku\"",
419            "\"fable\"",
420            "gpt-",
421            "glm-",
422            "kimi-code",
423            "claude-",
424            "fireworks",
425            "qwen",
426            "mistral",
427            "deepseek",
428        ] {
429            assert!(
430                !logic.contains(needle),
431                "model-id literal {needle:?} must never appear in the routing \
432                 rules file surface — route capability classes (ExecutorTier), \
433                 not models"
434            );
435        }
436    }
437}