Skip to main content

clawft_core/
routing_validation.rs

1//! Routing configuration validation.
2//!
3//! Post-deserialization validation for [`RoutingConfig`] and its nested types.
4//! Serde handles structural correctness (JSON types, field names), but cannot
5//! enforce semantic constraints like "tier names must be unique" or "complexity
6//! min must be less than max." This module fills that gap.
7//!
8//! Validation runs after deserialization, before `TieredRouter` construction.
9//! When `routing.mode` is `"static"`, validation is skipped entirely.
10
11use std::collections::HashSet;
12
13use clawft_types::routing::{ModelTierConfig, PermissionLevelConfig, RoutingConfig};
14
15// ── ValidationSeverity ──────────────────────────────────────────────────
16
17/// Severity level for a validation diagnostic.
18#[non_exhaustive]
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ValidationSeverity {
21    /// Fatal: the config is invalid and the system cannot start.
22    Error,
23    /// Non-fatal: the config is technically valid but may indicate a
24    /// misconfiguration. Logged at WARN level during startup.
25    Warning,
26}
27
28// ── ValidationError ─────────────────────────────────────────────────────
29
30/// A single validation diagnostic for the routing config.
31///
32/// Collects a human-readable field path, a descriptive message, and a
33/// severity level. All diagnostics are gathered before returning so
34/// operators see every issue at once.
35#[derive(Debug, Clone)]
36pub struct ValidationError {
37    /// Dotted field path (e.g., `"routing.tiers[1].complexity_range"`).
38    pub field: String,
39    /// Human-readable description of the problem.
40    pub message: String,
41    /// Whether this is a fatal error or a non-fatal warning.
42    pub severity: ValidationSeverity,
43}
44
45impl std::fmt::Display for ValidationError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        let level = match self.severity {
48            ValidationSeverity::Error => "ERROR",
49            ValidationSeverity::Warning => "WARN",
50        };
51        write!(f, "[{}] {}: {}", level, self.field, self.message)
52    }
53}
54
55impl std::error::Error for ValidationError {}
56
57// ── Main validation entry point ─────────────────────────────────────────
58
59/// Validate a [`RoutingConfig`] and return all errors/warnings found.
60///
61/// When `mode` is `"static"`, validation is skipped entirely (static mode
62/// ignores routing config). Validation only applies when `mode = "tiered"`.
63///
64/// All diagnostics are collected in a single pass -- the function does not
65/// short-circuit on the first error.
66pub fn validate_routing_config(config: &RoutingConfig) -> Vec<ValidationError> {
67    let mut errors = Vec::new();
68
69    // Static mode skips all routing validation (backward compatibility).
70    if config.mode == "static" {
71        return errors;
72    }
73
74    validate_mode(config, &mut errors);
75    validate_tiers(config, &mut errors);
76    validate_permissions(config, &mut errors);
77    validate_escalation(config, &mut errors);
78    validate_cost_budgets(config, &mut errors);
79    validate_rate_limiting(config, &mut errors);
80    validate_fallback_model(config, &mut errors);
81
82    errors
83}
84
85// ── Mode validation ─────────────────────────────────────────────────────
86
87/// Rule 1: `mode` must be `"static"` or `"tiered"`.
88fn validate_mode(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
89    if config.mode != "static" && config.mode != "tiered" {
90        errors.push(ValidationError {
91            field: "routing.mode".into(),
92            message: format!(
93                "unknown mode '{}', expected 'static' or 'tiered'",
94                config.mode
95            ),
96            severity: ValidationSeverity::Error,
97        });
98    }
99}
100
101// ── Tier validation ─────────────────────────────────────────────────────
102
103/// Rules 2-8: tier-level validations.
104fn validate_tiers(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
105    // Rule 2: if mode is "tiered", at least one tier must be defined.
106    if config.mode == "tiered" && config.tiers.is_empty() {
107        errors.push(ValidationError {
108            field: "routing.tiers".into(),
109            message: "tiered mode requires at least one tier definition".into(),
110            severity: ValidationSeverity::Error,
111        });
112    }
113
114    // Rule 3: no duplicate tier names.
115    let mut seen_names: HashSet<&str> = HashSet::new();
116    for (i, tier) in config.tiers.iter().enumerate() {
117        if !seen_names.insert(&tier.name) {
118            errors.push(ValidationError {
119                field: format!("routing.tiers[{}].name", i),
120                message: format!("duplicate tier name '{}'", tier.name),
121                severity: ValidationSeverity::Error,
122            });
123        }
124
125        validate_single_tier(i, tier, errors);
126    }
127
128    // Warning: overlapping complexity ranges (intentional by design but
129    // worth logging for operator awareness).
130    for i in 0..config.tiers.len() {
131        for j in (i + 1)..config.tiers.len() {
132            let a = &config.tiers[i];
133            let b = &config.tiers[j];
134            let overlap_min = a.complexity_range[0].max(b.complexity_range[0]);
135            let overlap_max = a.complexity_range[1].min(b.complexity_range[1]);
136            if overlap_min < overlap_max {
137                errors.push(ValidationError {
138                    field: format!("routing.tiers[{}]+[{}]", i, j),
139                    message: format!(
140                        "tiers '{}' and '{}' have overlapping complexity ranges \
141                         [{:.2}, {:.2}]",
142                        a.name, b.name, overlap_min, overlap_max
143                    ),
144                    severity: ValidationSeverity::Warning,
145                });
146            }
147        }
148    }
149}
150
151/// Validate a single tier's fields.
152fn validate_single_tier(index: usize, tier: &ModelTierConfig, errors: &mut Vec<ValidationError>) {
153    let prefix = format!("routing.tiers[{}]", index);
154
155    // Rule 4: each tier must have at least one model (warning).
156    if tier.models.is_empty() {
157        errors.push(ValidationError {
158            field: format!("{}.models", prefix),
159            message: format!(
160                "tier '{}' has no models -- it is defined but unusable",
161                tier.name
162            ),
163            severity: ValidationSeverity::Warning,
164        });
165    }
166
167    // Rule 5: complexity_range[0] <= complexity_range[1].
168    let [min, max] = tier.complexity_range;
169    if min > max {
170        errors.push(ValidationError {
171            field: format!("{}.complexity_range", prefix),
172            message: format!(
173                "complexity_range min ({}) > max ({}) for tier '{}'",
174                min, max, tier.name
175            ),
176            severity: ValidationSeverity::Error,
177        });
178    }
179
180    // Rule 6: complexity_range values in [0.0, 1.0].
181    if !(0.0..=1.0).contains(&min) {
182        errors.push(ValidationError {
183            field: format!("{}.complexity_range[0]", prefix),
184            message: format!(
185                "complexity_range[0] = {} is outside [0.0, 1.0] for tier '{}'",
186                min, tier.name
187            ),
188            severity: ValidationSeverity::Error,
189        });
190    }
191    if !(0.0..=1.0).contains(&max) {
192        errors.push(ValidationError {
193            field: format!("{}.complexity_range[1]", prefix),
194            message: format!(
195                "complexity_range[1] = {} is outside [0.0, 1.0] for tier '{}'",
196                max, tier.name
197            ),
198            severity: ValidationSeverity::Error,
199        });
200    }
201
202    // Rule 7: cost_per_1k_tokens >= 0.0.
203    if tier.cost_per_1k_tokens < 0.0 {
204        errors.push(ValidationError {
205            field: format!("{}.cost_per_1k_tokens", prefix),
206            message: format!(
207                "cost_per_1k_tokens = {} must be non-negative for tier '{}'",
208                tier.cost_per_1k_tokens, tier.name
209            ),
210            severity: ValidationSeverity::Error,
211        });
212    }
213
214    // Rule 8: max_context_tokens > 0.
215    if tier.max_context_tokens == 0 {
216        errors.push(ValidationError {
217            field: format!("{}.max_context_tokens", prefix),
218            message: format!("max_context_tokens must be > 0 for tier '{}'", tier.name),
219            severity: ValidationSeverity::Error,
220        });
221    }
222
223    // Warning: model format should contain '/' (provider/model).
224    for (j, model) in tier.models.iter().enumerate() {
225        if !model.contains('/') {
226            errors.push(ValidationError {
227                field: format!("{}.models[{}]", prefix, j),
228                message: format!(
229                    "model '{}' does not contain '/' (expected 'provider/model' format)",
230                    model
231                ),
232                severity: ValidationSeverity::Warning,
233            });
234        }
235    }
236}
237
238// ── Permission validation ───────────────────────────────────────────────
239
240/// Rules 9-14: permission-level validations.
241fn validate_permissions(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
242    let valid_tier_names: HashSet<&str> = config.tiers.iter().map(|t| t.name.as_str()).collect();
243
244    // Validate the three named permission levels.
245    validate_permission_level(
246        &config.permissions.zero_trust,
247        "routing.permissions.zero_trust",
248        &valid_tier_names,
249        errors,
250    );
251    validate_permission_level(
252        &config.permissions.user,
253        "routing.permissions.user",
254        &valid_tier_names,
255        errors,
256    );
257    validate_permission_level(
258        &config.permissions.admin,
259        "routing.permissions.admin",
260        &valid_tier_names,
261        errors,
262    );
263
264    // Validate per-user overrides.
265    for (user_id, plc) in &config.permissions.users {
266        validate_permission_level(
267            plc,
268            &format!("routing.permissions.users.{}", user_id),
269            &valid_tier_names,
270            errors,
271        );
272    }
273
274    // Validate per-channel overrides.
275    for (channel, plc) in &config.permissions.channels {
276        validate_permission_level(
277            plc,
278            &format!("routing.permissions.channels.{}", channel),
279            &valid_tier_names,
280            errors,
281        );
282    }
283}
284
285/// Validate a single [`PermissionLevelConfig`].
286fn validate_permission_level(
287    plc: &PermissionLevelConfig,
288    field_prefix: &str,
289    valid_tiers: &HashSet<&str>,
290    errors: &mut Vec<ValidationError>,
291) {
292    // Rule 9: level values are 0, 1, or 2 (warn on others).
293    if let Some(level) = plc.level
294        && level > 2 {
295            errors.push(ValidationError {
296                field: format!("{}.level", field_prefix),
297                message: format!(
298                    "permission level {} is not recognized (expected 0, 1, or 2)",
299                    level
300                ),
301                severity: ValidationSeverity::Warning,
302            });
303        }
304
305    // Rule 10: escalation_threshold in [0.0, 1.0].
306    if let Some(threshold) = plc.escalation_threshold
307        && (!(0.0..=1.0).contains(&threshold)) {
308            errors.push(ValidationError {
309                field: format!("{}.escalation_threshold", field_prefix),
310                message: format!("escalation_threshold {} must be in [0.0, 1.0]", threshold),
311                severity: ValidationSeverity::Error,
312            });
313        }
314
315    // Rule 11: cost_budget_daily_usd >= 0.0.
316    if let Some(daily) = plc.cost_budget_daily_usd
317        && daily < 0.0 {
318            errors.push(ValidationError {
319                field: format!("{}.cost_budget_daily_usd", field_prefix),
320                message: format!("cost_budget_daily_usd {} must be non-negative", daily),
321                severity: ValidationSeverity::Error,
322            });
323        }
324
325    // Rule 12: cost_budget_monthly_usd >= 0.0.
326    if let Some(monthly) = plc.cost_budget_monthly_usd
327        && monthly < 0.0 {
328            errors.push(ValidationError {
329                field: format!("{}.cost_budget_monthly_usd", field_prefix),
330                message: format!("cost_budget_monthly_usd {} must be non-negative", monthly),
331                severity: ValidationSeverity::Error,
332            });
333        }
334
335    // Rule 13: max_tier references an existing tier name (warning only).
336    if let Some(ref max_tier) = plc.max_tier
337        && !valid_tiers.is_empty() && !valid_tiers.contains(max_tier.as_str()) {
338            errors.push(ValidationError {
339                field: format!("{}.max_tier", field_prefix),
340                message: format!(
341                    "max_tier '{}' does not match any defined tier (available: {})",
342                    max_tier,
343                    valid_tiers.iter().copied().collect::<Vec<_>>().join(", ")
344                ),
345                severity: ValidationSeverity::Warning,
346            });
347        }
348
349    // Rule 14: tool access patterns with `*` in middle generate warning.
350    if let Some(ref tool_access) = plc.tool_access {
351        for entry in tool_access {
352            if entry.contains('*') && entry != "*" {
353                errors.push(ValidationError {
354                    field: format!("{}.tool_access", field_prefix),
355                    message: format!(
356                        "glob-like pattern '{}' detected; ensure pattern matching is intended",
357                        entry
358                    ),
359                    severity: ValidationSeverity::Warning,
360                });
361            }
362        }
363    }
364}
365
366// ── Escalation validation ───────────────────────────────────────────────
367
368/// Rules 15-16: escalation validations.
369fn validate_escalation(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
370    // Rule 15: max_escalation_tiers > 0 when escalation is enabled.
371    if config.escalation.enabled && config.escalation.max_escalation_tiers == 0 {
372        errors.push(ValidationError {
373            field: "routing.escalation.max_escalation_tiers".into(),
374            message: "max_escalation_tiers must be > 0 when escalation is enabled".into(),
375            severity: ValidationSeverity::Error,
376        });
377    }
378
379    // Rule 16: escalation threshold in [0.0, 1.0].
380    if config.escalation.threshold < 0.0 || config.escalation.threshold > 1.0 {
381        errors.push(ValidationError {
382            field: "routing.escalation.threshold".into(),
383            message: format!(
384                "escalation threshold {} must be in [0.0, 1.0]",
385                config.escalation.threshold
386            ),
387            severity: ValidationSeverity::Error,
388        });
389    }
390
391    // Warning: max_escalation_tiers exceeds defined tier count.
392    if config.escalation.max_escalation_tiers > config.tiers.len() as u32 {
393        errors.push(ValidationError {
394            field: "routing.escalation.max_escalation_tiers".into(),
395            message: format!(
396                "max_escalation_tiers ({}) exceeds the number of defined tiers ({})",
397                config.escalation.max_escalation_tiers,
398                config.tiers.len()
399            ),
400            severity: ValidationSeverity::Warning,
401        });
402    }
403}
404
405// ── Cost budget validation ──────────────────────────────────────────────
406
407/// Cost budget validations.
408fn validate_cost_budgets(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
409    if config.cost_budgets.global_daily_limit_usd < 0.0 {
410        errors.push(ValidationError {
411            field: "routing.cost_budgets.global_daily_limit_usd".into(),
412            message: format!(
413                "global_daily_limit_usd {} must be non-negative",
414                config.cost_budgets.global_daily_limit_usd
415            ),
416            severity: ValidationSeverity::Error,
417        });
418    }
419
420    if config.cost_budgets.global_monthly_limit_usd < 0.0 {
421        errors.push(ValidationError {
422            field: "routing.cost_budgets.global_monthly_limit_usd".into(),
423            message: format!(
424                "global_monthly_limit_usd {} must be non-negative",
425                config.cost_budgets.global_monthly_limit_usd
426            ),
427            severity: ValidationSeverity::Error,
428        });
429    }
430
431    if config.cost_budgets.reset_hour_utc > 23 {
432        errors.push(ValidationError {
433            field: "routing.cost_budgets.reset_hour_utc".into(),
434            message: format!(
435                "reset_hour_utc {} must be 0-23",
436                config.cost_budgets.reset_hour_utc
437            ),
438            severity: ValidationSeverity::Error,
439        });
440    }
441}
442
443// ── Rate limiting validation ────────────────────────────────────────────
444
445/// Rate limiting validations.
446fn validate_rate_limiting(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
447    if config.rate_limiting.window_seconds == 0 {
448        errors.push(ValidationError {
449            field: "routing.rate_limiting.window_seconds".into(),
450            message: "window_seconds must be > 0".into(),
451            severity: ValidationSeverity::Error,
452        });
453    }
454
455    let valid_strategies = ["sliding_window", "fixed_window"];
456    if !valid_strategies.contains(&config.rate_limiting.strategy.as_str()) {
457        errors.push(ValidationError {
458            field: "routing.rate_limiting.strategy".into(),
459            message: format!(
460                "unknown strategy '{}', expected 'sliding_window' or 'fixed_window'",
461                config.rate_limiting.strategy
462            ),
463            severity: ValidationSeverity::Error,
464        });
465    }
466}
467
468// ── Fallback model validation ───────────────────────────────────────────
469
470/// Rule 17: fallback model format.
471fn validate_fallback_model(config: &RoutingConfig, errors: &mut Vec<ValidationError>) {
472    if let Some(ref model) = config.fallback_model
473        && !model.contains('/') {
474            errors.push(ValidationError {
475                field: "routing.fallback_model".into(),
476                message: format!(
477                    "fallback_model '{}' should match 'provider/model' format",
478                    model
479                ),
480                severity: ValidationSeverity::Warning,
481            });
482        }
483}
484
485// ── Workspace ceiling enforcement (FIX-04) ──────────────────────────────
486
487/// Default maximum grantable permission level for workspace configs.
488///
489/// Until `max_grantable_level` is added to [`RoutingConfig`], this constant
490/// provides the default ceiling. Workspaces cannot grant levels above this.
491const DEFAULT_MAX_GRANTABLE_LEVEL: u8 = 1;
492
493/// Validate that a workspace config does not exceed the global config ceiling.
494///
495/// Returns a [`Vec`] of validation errors for any ceiling violations found.
496/// An empty [`Vec`] means the workspace is within bounds.
497///
498/// Security-sensitive ceiling fields:
499/// - `level`: cannot exceed `DEFAULT_MAX_GRANTABLE_LEVEL` (1)
500/// - `escalation_allowed`: cannot be true if global is false
501/// - `tool_access`: cannot add tools not in global allowlist
502/// - `rate_limit`: workspace cannot increase beyond global
503/// - `cost_budget_daily_usd`: workspace cannot increase
504/// - `cost_budget_monthly_usd`: workspace cannot increase
505/// - `max_tier`: cannot upgrade beyond global's max_tier
506pub fn validate_workspace_ceiling(
507    global: &RoutingConfig,
508    workspace: &RoutingConfig,
509) -> Vec<ValidationError> {
510    let mut errors = Vec::new();
511    let max_grantable = DEFAULT_MAX_GRANTABLE_LEVEL;
512
513    // Check the three named permission levels.
514    check_level_ceiling(
515        &workspace.permissions.zero_trust,
516        "routing.permissions.zero_trust",
517        &global.permissions.zero_trust,
518        max_grantable,
519        &mut errors,
520    );
521    check_level_ceiling(
522        &workspace.permissions.user,
523        "routing.permissions.user",
524        &global.permissions.user,
525        max_grantable,
526        &mut errors,
527    );
528    check_level_ceiling(
529        &workspace.permissions.admin,
530        "routing.permissions.admin",
531        &global.permissions.admin,
532        max_grantable,
533        &mut errors,
534    );
535
536    // Check per-user overrides.
537    for (user_id, ws_plc) in &workspace.permissions.users {
538        let global_plc = global
539            .permissions
540            .users
541            .get(user_id)
542            .unwrap_or(&global.permissions.user);
543        check_level_ceiling(
544            ws_plc,
545            &format!("routing.permissions.users.{}", user_id),
546            global_plc,
547            max_grantable,
548            &mut errors,
549        );
550    }
551
552    // Check per-channel overrides.
553    for (channel, ws_plc) in &workspace.permissions.channels {
554        let global_plc = global
555            .permissions
556            .channels
557            .get(channel)
558            .unwrap_or(&global.permissions.user);
559        check_level_ceiling(
560            ws_plc,
561            &format!("routing.permissions.channels.{}", channel),
562            global_plc,
563            max_grantable,
564            &mut errors,
565        );
566    }
567
568    errors
569}
570
571/// Check a single workspace permission level against the global ceiling.
572fn check_level_ceiling(
573    ws_plc: &PermissionLevelConfig,
574    field_prefix: &str,
575    global_plc: &PermissionLevelConfig,
576    max_grantable: u8,
577    errors: &mut Vec<ValidationError>,
578) {
579    // Level ceiling: workspace cannot grant level above max_grantable_level.
580    if let Some(ws_level) = ws_plc.level
581        && ws_level > max_grantable {
582            errors.push(ValidationError {
583                field: format!("{}.level", field_prefix),
584                message: format!(
585                    "workspace level {} exceeds max_grantable_level {}",
586                    ws_level, max_grantable
587                ),
588                severity: ValidationSeverity::Error,
589            });
590        }
591
592    // Escalation ceiling: workspace cannot enable escalation if global disables it.
593    if let (Some(true), Some(false)) = (ws_plc.escalation_allowed, global_plc.escalation_allowed) {
594        errors.push(ValidationError {
595            field: format!("{}.escalation_allowed", field_prefix),
596            message: "workspace cannot enable escalation when global disables it".into(),
597            severity: ValidationSeverity::Error,
598        });
599    }
600
601    // Tool access ceiling: workspace cannot add tools not in global allowlist.
602    if let (Some(ws_tools), Some(global_tools)) =
603        (&ws_plc.tool_access, &global_plc.tool_access)
604    {
605        // If global is ["*"], anything goes.
606        if !global_tools.iter().any(|s| s == "*") {
607            for ws_tool in ws_tools {
608                if ws_tool == "*" {
609                    errors.push(ValidationError {
610                        field: format!("{}.tool_access", field_prefix),
611                        message: "workspace cannot grant wildcard tool access \
612                                  when global does not"
613                            .into(),
614                        severity: ValidationSeverity::Error,
615                    });
616                } else if !global_tools.contains(ws_tool) {
617                    errors.push(ValidationError {
618                        field: format!("{}.tool_access", field_prefix),
619                        message: format!("workspace tool '{}' not in global tool_access", ws_tool),
620                        severity: ValidationSeverity::Error,
621                    });
622                }
623            }
624        }
625    }
626
627    // Rate limit ceiling: workspace cannot increase beyond global.
628    if let (Some(ws_rate), Some(global_rate)) = (ws_plc.rate_limit, global_plc.rate_limit)
629        && global_rate > 0 && (ws_rate > global_rate || ws_rate == 0) {
630            errors.push(ValidationError {
631                field: format!("{}.rate_limit", field_prefix),
632                message: format!(
633                    "workspace rate_limit {} exceeds global ceiling {}",
634                    ws_rate, global_rate
635                ),
636                severity: ValidationSeverity::Error,
637            });
638        }
639
640    // Cost budget daily ceiling.
641    if let (Some(ws_daily), Some(global_daily)) = (
642        ws_plc.cost_budget_daily_usd,
643        global_plc.cost_budget_daily_usd,
644    )
645        && global_daily > 0.0 && (ws_daily > global_daily || ws_daily == 0.0) {
646            errors.push(ValidationError {
647                field: format!("{}.cost_budget_daily_usd", field_prefix),
648                message: format!(
649                    "workspace daily budget {} exceeds global ceiling {}",
650                    ws_daily, global_daily
651                ),
652                severity: ValidationSeverity::Error,
653            });
654        }
655
656    // Cost budget monthly ceiling.
657    if let (Some(ws_monthly), Some(global_monthly)) = (
658        ws_plc.cost_budget_monthly_usd,
659        global_plc.cost_budget_monthly_usd,
660    )
661        && global_monthly > 0.0 && (ws_monthly > global_monthly || ws_monthly == 0.0) {
662            errors.push(ValidationError {
663                field: format!("{}.cost_budget_monthly_usd", field_prefix),
664                message: format!(
665                    "workspace monthly budget {} exceeds global ceiling {}",
666                    ws_monthly, global_monthly
667                ),
668                severity: ValidationSeverity::Error,
669            });
670        }
671}
672
673// ── Default tiers ───────────────────────────────────────────────────────
674
675/// Generate a set of default tiers when mode is `"tiered"` but no tiers
676/// are configured.
677///
678/// This enables the minimal config migration path:
679/// ```json
680/// { "routing": { "mode": "tiered" } }
681/// ```
682///
683/// Returns three standard tiers: free, standard, and premium.
684pub fn default_tiers() -> Vec<ModelTierConfig> {
685    vec![
686        ModelTierConfig {
687            name: "free".into(),
688            models: vec![
689                "openrouter/meta-llama/llama-3.1-8b-instruct:free".into(),
690                "groq/llama-3.1-8b".into(),
691            ],
692            complexity_range: [0.0, 0.3],
693            cost_per_1k_tokens: 0.0,
694            max_context_tokens: 8192,
695        },
696        ModelTierConfig {
697            name: "standard".into(),
698            models: vec![
699                "anthropic/claude-haiku-3.5".into(),
700                "openai/gpt-4o-mini".into(),
701                "groq/llama-3.3-70b".into(),
702            ],
703            complexity_range: [0.0, 0.7],
704            cost_per_1k_tokens: 0.001,
705            max_context_tokens: 16384,
706        },
707        ModelTierConfig {
708            name: "premium".into(),
709            models: vec![
710                "anthropic/claude-sonnet-4-20250514".into(),
711                "openai/gpt-4o".into(),
712            ],
713            complexity_range: [0.3, 1.0],
714            cost_per_1k_tokens: 0.01,
715            max_context_tokens: 200_000,
716        },
717    ]
718}
719
720// ── Tests ───────────────────────────────────────────────────────────────
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725
726    use clawft_types::routing::{EscalationConfig, ModelTierConfig, RateLimitConfig};
727
728    /// Build a minimal valid tiered config for testing.
729    fn valid_tiered_config() -> RoutingConfig {
730        RoutingConfig {
731            mode: "tiered".into(),
732            tiers: vec![
733                ModelTierConfig {
734                    name: "free".into(),
735                    models: vec!["groq/llama-3.1-8b".into()],
736                    complexity_range: [0.0, 0.5],
737                    cost_per_1k_tokens: 0.0,
738                    max_context_tokens: 8192,
739                },
740                ModelTierConfig {
741                    name: "standard".into(),
742                    models: vec!["anthropic/claude-haiku-3.5".into()],
743                    complexity_range: [0.3, 1.0],
744                    cost_per_1k_tokens: 0.001,
745                    max_context_tokens: 16384,
746                },
747            ],
748            ..RoutingConfig::default()
749        }
750    }
751
752    /// Build a minimal valid static config for testing.
753    fn valid_static_config() -> RoutingConfig {
754        RoutingConfig::default()
755    }
756
757    /// Count errors at a given severity level.
758    fn count_severity(errors: &[ValidationError], severity: ValidationSeverity) -> usize {
759        errors.iter().filter(|e| e.severity == severity).count()
760    }
761
762    /// Check if any error matches a field prefix and severity.
763    fn has_error(
764        errors: &[ValidationError],
765        field_prefix: &str,
766        severity: ValidationSeverity,
767    ) -> bool {
768        errors
769            .iter()
770            .any(|e| e.field.starts_with(field_prefix) && e.severity == severity)
771    }
772
773    // ── Test 1: valid static config passes ──────────────────────────
774
775    #[test]
776    fn valid_static_config_passes() {
777        let config = valid_static_config();
778        let errors = validate_routing_config(&config);
779        assert!(errors.is_empty(), "static config should produce no errors");
780    }
781
782    // ── Test 2: valid tiered config passes ──────────────────────────
783
784    #[test]
785    fn valid_tiered_config_passes() {
786        let config = valid_tiered_config();
787        let errors = validate_routing_config(&config);
788        let fatal = count_severity(&errors, ValidationSeverity::Error);
789        assert_eq!(
790            fatal, 0,
791            "valid tiered config should have no fatal errors, got: {:?}",
792            errors
793        );
794    }
795
796    // ── Test 3: invalid mode rejected ───────────────────────────────
797
798    #[test]
799    fn invalid_mode_rejected() {
800        let mut config = valid_tiered_config();
801        config.mode = "hybrid".into();
802        let errors = validate_routing_config(&config);
803        assert!(
804            has_error(&errors, "routing.mode", ValidationSeverity::Error),
805            "unknown mode should produce an error"
806        );
807    }
808
809    // ── Test 4: tiered with no tiers gives error ────────────────────
810
811    #[test]
812    fn tiered_with_no_tiers_gives_error() {
813        let config = RoutingConfig {
814            mode: "tiered".into(),
815            tiers: vec![],
816            ..RoutingConfig::default()
817        };
818        let errors = validate_routing_config(&config);
819        assert!(
820            has_error(&errors, "routing.tiers", ValidationSeverity::Error),
821            "tiered mode with no tiers should be an error"
822        );
823    }
824
825    // ── Test 5: duplicate tier names detected ───────────────────────
826
827    #[test]
828    fn duplicate_tier_names_detected() {
829        let mut config = valid_tiered_config();
830        config.tiers[1].name = "free".into(); // duplicate
831        let errors = validate_routing_config(&config);
832        assert!(
833            has_error(&errors, "routing.tiers[1].name", ValidationSeverity::Error),
834            "duplicate tier names should produce an error"
835        );
836    }
837
838    // ── Test 6: empty tier models detected ──────────────────────────
839
840    #[test]
841    fn empty_tier_models_warning() {
842        let mut config = valid_tiered_config();
843        config.tiers[0].models.clear();
844        let errors = validate_routing_config(&config);
845        assert!(
846            has_error(
847                &errors,
848                "routing.tiers[0].models",
849                ValidationSeverity::Warning
850            ),
851            "empty models should produce a warning"
852        );
853    }
854
855    // ── Test 7: invalid complexity range (min > max) ────────────────
856
857    #[test]
858    fn complexity_range_inverted() {
859        let mut config = valid_tiered_config();
860        config.tiers[0].complexity_range = [0.8, 0.3];
861        let errors = validate_routing_config(&config);
862        assert!(
863            has_error(
864                &errors,
865                "routing.tiers[0].complexity_range",
866                ValidationSeverity::Error
867            ),
868            "inverted complexity range should produce an error"
869        );
870    }
871
872    // ── Test 8: complexity out of [0, 1] range ──────────────────────
873
874    #[test]
875    fn complexity_out_of_range() {
876        let mut config = valid_tiered_config();
877        config.tiers[0].complexity_range = [-0.1, 1.5];
878        let errors = validate_routing_config(&config);
879        assert!(
880            has_error(
881                &errors,
882                "routing.tiers[0].complexity_range[0]",
883                ValidationSeverity::Error
884            ),
885            "negative min should produce an error"
886        );
887        assert!(
888            has_error(
889                &errors,
890                "routing.tiers[0].complexity_range[1]",
891                ValidationSeverity::Error
892            ),
893            "max > 1.0 should produce an error"
894        );
895    }
896
897    // ── Test 9: negative cost rejected ──────────────────────────────
898
899    #[test]
900    fn negative_cost_rejected() {
901        let mut config = valid_tiered_config();
902        config.tiers[0].cost_per_1k_tokens = -0.001;
903        let errors = validate_routing_config(&config);
904        assert!(
905            has_error(
906                &errors,
907                "routing.tiers[0].cost_per_1k_tokens",
908                ValidationSeverity::Error
909            ),
910            "negative cost should produce an error"
911        );
912    }
913
914    // ── Test 10: zero max_context_tokens rejected ───────────────────
915
916    #[test]
917    fn zero_max_context_tokens_rejected() {
918        let mut config = valid_tiered_config();
919        config.tiers[0].max_context_tokens = 0;
920        let errors = validate_routing_config(&config);
921        assert!(
922            has_error(
923                &errors,
924                "routing.tiers[0].max_context_tokens",
925                ValidationSeverity::Error
926            ),
927            "zero max_context_tokens should produce an error"
928        );
929    }
930
931    // ── Test 11: invalid level warning ──────────────────────────────
932
933    #[test]
934    fn invalid_permission_level_warning() {
935        let mut config = valid_tiered_config();
936        config.permissions.zero_trust.level = Some(5);
937        let errors = validate_routing_config(&config);
938        assert!(
939            has_error(
940                &errors,
941                "routing.permissions.zero_trust.level",
942                ValidationSeverity::Warning
943            ),
944            "invalid level should produce a warning"
945        );
946    }
947
948    // ── Test 12: invalid escalation threshold ───────────────────────
949
950    #[test]
951    fn invalid_escalation_threshold() {
952        let mut config = valid_tiered_config();
953        config.permissions.user.escalation_threshold = Some(1.5);
954        let errors = validate_routing_config(&config);
955        assert!(
956            has_error(
957                &errors,
958                "routing.permissions.user.escalation_threshold",
959                ValidationSeverity::Error
960            ),
961            "escalation_threshold > 1.0 should produce an error"
962        );
963    }
964
965    // ── Test 13: negative budget rejected ───────────────────────────
966
967    #[test]
968    fn negative_budget_rejected() {
969        let mut config = valid_tiered_config();
970        config.permissions.user.cost_budget_daily_usd = Some(-1.0);
971        let errors = validate_routing_config(&config);
972        assert!(
973            has_error(
974                &errors,
975                "routing.permissions.user.cost_budget_daily_usd",
976                ValidationSeverity::Error
977            ),
978            "negative daily budget should produce an error"
979        );
980    }
981
982    // ── Test 14: max tier referencing non-existent tier warns ────────
983
984    #[test]
985    fn max_tier_nonexistent_warns() {
986        let mut config = valid_tiered_config();
987        config.permissions.user.max_tier = Some("mythical".into());
988        let errors = validate_routing_config(&config);
989        assert!(
990            has_error(
991                &errors,
992                "routing.permissions.user.max_tier",
993                ValidationSeverity::Warning
994            ),
995            "undefined max_tier should produce a warning"
996        );
997    }
998
999    // ── Test 15: glob-like tool pattern warning ─────────────────────
1000
1001    #[test]
1002    fn glob_like_tool_pattern_warning() {
1003        let mut config = valid_tiered_config();
1004        config.permissions.user.tool_access = Some(vec!["file_*".into()]);
1005        let errors = validate_routing_config(&config);
1006        assert!(
1007            has_error(
1008                &errors,
1009                "routing.permissions.user.tool_access",
1010                ValidationSeverity::Warning
1011            ),
1012            "glob-like tool_access should produce a warning"
1013        );
1014    }
1015
1016    // ── Test 16: exact wildcard "*" does not warn ───────────────────
1017
1018    #[test]
1019    fn exact_wildcard_no_warning() {
1020        let mut config = valid_tiered_config();
1021        config.permissions.admin.tool_access = Some(vec!["*".into()]);
1022        let errors = validate_routing_config(&config);
1023        let glob_warnings = errors.iter().any(|e| {
1024            e.field.contains("tool_access")
1025                && e.severity == ValidationSeverity::Warning
1026                && e.message.contains("glob")
1027        });
1028        assert!(
1029            !glob_warnings,
1030            "exact '*' should not produce a glob pattern warning"
1031        );
1032    }
1033
1034    // ── Test 17: fallback model format validation ───────────────────
1035
1036    #[test]
1037    fn fallback_model_format_warning() {
1038        let mut config = valid_tiered_config();
1039        config.fallback_model = Some("gpt-4o".into());
1040        let errors = validate_routing_config(&config);
1041        assert!(
1042            has_error(
1043                &errors,
1044                "routing.fallback_model",
1045                ValidationSeverity::Warning
1046            ),
1047            "fallback model without '/' should produce a warning"
1048        );
1049    }
1050
1051    // ── Test 18: workspace ceiling -- level escalation blocked ──────
1052
1053    #[test]
1054    fn workspace_ceiling_level_escalation_blocked() {
1055        let global = valid_tiered_config();
1056        let mut workspace = RoutingConfig::default();
1057        workspace.permissions.user.level = Some(2); // admin
1058        let errors = validate_workspace_ceiling(&global, &workspace);
1059        assert!(
1060            has_error(
1061                &errors,
1062                "routing.permissions.user.level",
1063                ValidationSeverity::Error
1064            ),
1065            "workspace level exceeding max_grantable should produce an error"
1066        );
1067    }
1068
1069    // ── Test 19: workspace ceiling -- rate limit increase blocked ───
1070
1071    #[test]
1072    fn workspace_ceiling_rate_limit_increase_blocked() {
1073        let mut global = valid_tiered_config();
1074        global.permissions.user.rate_limit = Some(60);
1075        let mut workspace = RoutingConfig::default();
1076        workspace.permissions.user.rate_limit = Some(120);
1077        let errors = validate_workspace_ceiling(&global, &workspace);
1078        assert!(
1079            has_error(
1080                &errors,
1081                "routing.permissions.user.rate_limit",
1082                ValidationSeverity::Error
1083            ),
1084            "workspace rate_limit exceeding global should produce an error"
1085        );
1086    }
1087
1088    // ── Test 20: workspace ceiling -- budget increase blocked ───────
1089
1090    #[test]
1091    fn workspace_ceiling_budget_increase_blocked() {
1092        let mut global = valid_tiered_config();
1093        global.permissions.user.cost_budget_daily_usd = Some(5.0);
1094        let mut workspace = RoutingConfig::default();
1095        workspace.permissions.user.cost_budget_daily_usd = Some(10.0);
1096        let errors = validate_workspace_ceiling(&global, &workspace);
1097        assert!(
1098            has_error(
1099                &errors,
1100                "routing.permissions.user.cost_budget_daily_usd",
1101                ValidationSeverity::Error
1102            ),
1103            "workspace daily budget exceeding global should produce an error"
1104        );
1105    }
1106
1107    // ── Test 21: workspace ceiling -- tool access expansion blocked ─
1108
1109    #[test]
1110    fn workspace_ceiling_tool_access_expansion_blocked() {
1111        let mut global = valid_tiered_config();
1112        global.permissions.user.tool_access = Some(vec!["read_file".into(), "list_dir".into()]);
1113        let mut workspace = RoutingConfig::default();
1114        workspace.permissions.user.tool_access = Some(vec!["exec_shell".into()]);
1115        let errors = validate_workspace_ceiling(&global, &workspace);
1116        assert!(
1117            has_error(
1118                &errors,
1119                "routing.permissions.user.tool_access",
1120                ValidationSeverity::Error
1121            ),
1122            "workspace adding tool not in global should produce an error"
1123        );
1124    }
1125
1126    // ── Test 22: workspace within bounds passes ─────────────────────
1127
1128    #[test]
1129    fn workspace_within_bounds_passes() {
1130        let mut global = valid_tiered_config();
1131        global.permissions.user.level = Some(1);
1132        global.permissions.user.rate_limit = Some(60);
1133        global.permissions.user.cost_budget_daily_usd = Some(5.0);
1134
1135        let mut workspace = RoutingConfig::default();
1136        workspace.permissions.user.level = Some(1);
1137        workspace.permissions.user.rate_limit = Some(30);
1138        workspace.permissions.user.cost_budget_daily_usd = Some(3.0);
1139
1140        let errors = validate_workspace_ceiling(&global, &workspace);
1141        let fatal = count_severity(&errors, ValidationSeverity::Error);
1142        assert_eq!(
1143            fatal, 0,
1144            "workspace within bounds should have no errors, got: {:?}",
1145            errors
1146        );
1147    }
1148
1149    // ── Test 23: default tiers generation ───────────────────────────
1150
1151    #[test]
1152    fn default_tiers_generation() {
1153        let tiers = default_tiers();
1154        assert_eq!(tiers.len(), 3, "should produce 3 default tiers");
1155        assert_eq!(tiers[0].name, "free");
1156        assert_eq!(tiers[1].name, "standard");
1157        assert_eq!(tiers[2].name, "premium");
1158
1159        // Each tier should have at least one model.
1160        for tier in &tiers {
1161            assert!(
1162                !tier.models.is_empty(),
1163                "tier '{}' should have models",
1164                tier.name
1165            );
1166        }
1167
1168        // Free tier should have zero cost.
1169        assert_eq!(tiers[0].cost_per_1k_tokens, 0.0);
1170
1171        // Complexity ranges should be within [0.0, 1.0].
1172        for tier in &tiers {
1173            assert!(tier.complexity_range[0] >= 0.0);
1174            assert!(tier.complexity_range[1] <= 1.0);
1175            assert!(tier.complexity_range[0] <= tier.complexity_range[1]);
1176        }
1177    }
1178
1179    // ── Test 24: default tiers pass validation ──────────────────────
1180
1181    #[test]
1182    fn default_tiers_pass_validation() {
1183        let config = RoutingConfig {
1184            mode: "tiered".into(),
1185            tiers: default_tiers(),
1186            ..RoutingConfig::default()
1187        };
1188        let errors = validate_routing_config(&config);
1189        let fatal = count_severity(&errors, ValidationSeverity::Error);
1190        assert_eq!(
1191            fatal, 0,
1192            "default tiers should pass validation, got: {:?}",
1193            errors
1194        );
1195    }
1196
1197    // ── Test 25: escalation threshold out of range ──────────────────
1198
1199    #[test]
1200    fn escalation_global_threshold_out_of_range() {
1201        let mut config = valid_tiered_config();
1202        config.escalation = EscalationConfig {
1203            enabled: true,
1204            threshold: 1.5,
1205            max_escalation_tiers: 1,
1206        };
1207        let errors = validate_routing_config(&config);
1208        assert!(
1209            has_error(
1210                &errors,
1211                "routing.escalation.threshold",
1212                ValidationSeverity::Error
1213            ),
1214            "escalation threshold > 1.0 should produce an error"
1215        );
1216    }
1217
1218    // ── Test 26: zero window_seconds rejected ───────────────────────
1219
1220    #[test]
1221    fn zero_window_seconds_rejected() {
1222        let mut config = valid_tiered_config();
1223        config.rate_limiting.window_seconds = 0;
1224        let errors = validate_routing_config(&config);
1225        assert!(
1226            has_error(
1227                &errors,
1228                "routing.rate_limiting.window_seconds",
1229                ValidationSeverity::Error
1230            ),
1231            "zero window_seconds should produce an error"
1232        );
1233    }
1234
1235    // ── Test 27: unknown rate limit strategy rejected ────────────────
1236
1237    #[test]
1238    fn unknown_rate_limit_strategy_rejected() {
1239        let mut config = valid_tiered_config();
1240        config.rate_limiting.strategy = "token_bucket".into();
1241        let errors = validate_routing_config(&config);
1242        assert!(
1243            has_error(
1244                &errors,
1245                "routing.rate_limiting.strategy",
1246                ValidationSeverity::Error
1247            ),
1248            "unknown rate limit strategy should produce an error"
1249        );
1250    }
1251
1252    // ── Test 28: reset_hour_utc out of range ────────────────────────
1253
1254    #[test]
1255    fn reset_hour_out_of_range() {
1256        let mut config = valid_tiered_config();
1257        config.cost_budgets.reset_hour_utc = 25;
1258        let errors = validate_routing_config(&config);
1259        assert!(
1260            has_error(
1261                &errors,
1262                "routing.cost_budgets.reset_hour_utc",
1263                ValidationSeverity::Error
1264            ),
1265            "reset_hour_utc > 23 should produce an error"
1266        );
1267    }
1268
1269    // ── Test 29: negative global daily limit ────────────────────────
1270
1271    #[test]
1272    fn negative_global_daily_limit() {
1273        let mut config = valid_tiered_config();
1274        config.cost_budgets.global_daily_limit_usd = -10.0;
1275        let errors = validate_routing_config(&config);
1276        assert!(
1277            has_error(
1278                &errors,
1279                "routing.cost_budgets.global_daily_limit_usd",
1280                ValidationSeverity::Error
1281            ),
1282            "negative global daily limit should produce an error"
1283        );
1284    }
1285
1286    // ── Test 30: negative global monthly limit ──────────────────────
1287
1288    #[test]
1289    fn negative_global_monthly_limit() {
1290        let mut config = valid_tiered_config();
1291        config.cost_budgets.global_monthly_limit_usd = -100.0;
1292        let errors = validate_routing_config(&config);
1293        assert!(
1294            has_error(
1295                &errors,
1296                "routing.cost_budgets.global_monthly_limit_usd",
1297                ValidationSeverity::Error
1298            ),
1299            "negative global monthly limit should produce an error"
1300        );
1301    }
1302
1303    // ── Test 31: multiple errors collected ──────────────────────────
1304
1305    #[test]
1306    fn multiple_errors_collected() {
1307        let config = RoutingConfig {
1308            mode: "tiered".into(),
1309            tiers: vec![
1310                ModelTierConfig {
1311                    name: "bad".into(),
1312                    models: vec![],
1313                    complexity_range: [0.8, 0.3], // inverted
1314                    cost_per_1k_tokens: -1.0,     // negative
1315                    max_context_tokens: 0,        // zero
1316                },
1317                ModelTierConfig {
1318                    name: "bad".into(), // duplicate
1319                    models: vec![],
1320                    complexity_range: [0.0, 1.0],
1321                    cost_per_1k_tokens: 0.0,
1322                    max_context_tokens: 8192,
1323                },
1324            ],
1325            rate_limiting: RateLimitConfig {
1326                window_seconds: 0, // invalid
1327                strategy: "sliding_window".into(),
1328                ..RateLimitConfig::default()
1329            },
1330            ..RoutingConfig::default()
1331        };
1332        let errors = validate_routing_config(&config);
1333        let fatal_count = count_severity(&errors, ValidationSeverity::Error);
1334        assert!(
1335            fatal_count >= 4,
1336            "should collect at least 4 errors, got {}: {:?}",
1337            fatal_count,
1338            errors
1339        );
1340    }
1341
1342    // ── Test 32: static mode skips validation of bad data ───────────
1343
1344    #[test]
1345    fn static_mode_skips_validation() {
1346        let config = RoutingConfig {
1347            mode: "static".into(),
1348            tiers: vec![ModelTierConfig {
1349                name: "broken".into(),
1350                models: vec![],
1351                complexity_range: [0.8, 0.3], // would be invalid
1352                cost_per_1k_tokens: -1.0,
1353                max_context_tokens: 0,
1354            }],
1355            ..RoutingConfig::default()
1356        };
1357        let errors = validate_routing_config(&config);
1358        assert!(
1359            errors.is_empty(),
1360            "static mode should skip all validation, got: {:?}",
1361            errors
1362        );
1363    }
1364
1365    // ── Test 33: overlapping complexity ranges warning ───────────────
1366
1367    #[test]
1368    fn overlapping_complexity_ranges_warning() {
1369        let config = RoutingConfig {
1370            mode: "tiered".into(),
1371            tiers: vec![
1372                ModelTierConfig {
1373                    name: "a".into(),
1374                    models: vec!["provider/model-a".into()],
1375                    complexity_range: [0.0, 0.7],
1376                    cost_per_1k_tokens: 0.0,
1377                    max_context_tokens: 8192,
1378                },
1379                ModelTierConfig {
1380                    name: "b".into(),
1381                    models: vec!["provider/model-b".into()],
1382                    complexity_range: [0.3, 1.0],
1383                    cost_per_1k_tokens: 0.01,
1384                    max_context_tokens: 16384,
1385                },
1386            ],
1387            ..RoutingConfig::default()
1388        };
1389        let errors = validate_routing_config(&config);
1390        let overlap_warnings = errors.iter().any(|e| {
1391            e.severity == ValidationSeverity::Warning && e.message.contains("overlapping")
1392        });
1393        assert!(
1394            overlap_warnings,
1395            "overlapping ranges should produce a warning"
1396        );
1397    }
1398
1399    // ── Test 34: model format warning ───────────────────────────────
1400
1401    #[test]
1402    fn model_format_warning() {
1403        let mut config = valid_tiered_config();
1404        config.tiers[0].models = vec!["gpt-4o".into()]; // no slash
1405        let errors = validate_routing_config(&config);
1406        assert!(
1407            has_error(
1408                &errors,
1409                "routing.tiers[0].models[0]",
1410                ValidationSeverity::Warning
1411            ),
1412            "model without '/' should produce a warning"
1413        );
1414    }
1415
1416    // ── Test 35: per-user override validation ───────────────────────
1417
1418    #[test]
1419    fn per_user_override_validated() {
1420        let mut config = valid_tiered_config();
1421        config.permissions.users.insert(
1422            "alice".into(),
1423            PermissionLevelConfig {
1424                cost_budget_monthly_usd: Some(-5.0),
1425                ..PermissionLevelConfig::default()
1426            },
1427        );
1428        let errors = validate_routing_config(&config);
1429        assert!(
1430            has_error(
1431                &errors,
1432                "routing.permissions.users.alice.cost_budget_monthly_usd",
1433                ValidationSeverity::Error
1434            ),
1435            "per-user negative budget should produce an error"
1436        );
1437    }
1438
1439    // ── Test 36: per-channel override validation ────────────────────
1440
1441    #[test]
1442    fn per_channel_override_validated() {
1443        let mut config = valid_tiered_config();
1444        config.permissions.channels.insert(
1445            "telegram".into(),
1446            PermissionLevelConfig {
1447                escalation_threshold: Some(-0.5),
1448                ..PermissionLevelConfig::default()
1449            },
1450        );
1451        let errors = validate_routing_config(&config);
1452        assert!(
1453            has_error(
1454                &errors,
1455                "routing.permissions.channels.telegram.escalation_threshold",
1456                ValidationSeverity::Error
1457            ),
1458            "per-channel invalid threshold should produce an error"
1459        );
1460    }
1461
1462    // ── Test 37: escalation max tiers exceeds defined count warns ───
1463
1464    #[test]
1465    fn escalation_max_tiers_exceeds_defined_count_warns() {
1466        let mut config = valid_tiered_config();
1467        config.escalation = EscalationConfig {
1468            enabled: true,
1469            threshold: 0.6,
1470            max_escalation_tiers: 10, // only 2 tiers defined
1471        };
1472        let errors = validate_routing_config(&config);
1473        assert!(
1474            has_error(
1475                &errors,
1476                "routing.escalation.max_escalation_tiers",
1477                ValidationSeverity::Warning
1478            ),
1479            "max_escalation_tiers > tier count should warn"
1480        );
1481    }
1482
1483    // ── Test 38: workspace ceiling -- monthly budget increase ───────
1484
1485    #[test]
1486    fn workspace_ceiling_monthly_budget_increase_blocked() {
1487        let mut global = valid_tiered_config();
1488        global.permissions.user.cost_budget_monthly_usd = Some(50.0);
1489        let mut workspace = RoutingConfig::default();
1490        workspace.permissions.user.cost_budget_monthly_usd = Some(100.0);
1491        let errors = validate_workspace_ceiling(&global, &workspace);
1492        assert!(
1493            has_error(
1494                &errors,
1495                "routing.permissions.user.cost_budget_monthly_usd",
1496                ValidationSeverity::Error
1497            ),
1498            "workspace monthly budget exceeding global should produce an error"
1499        );
1500    }
1501
1502    // ── Test 39: workspace ceiling -- escalation enable blocked ──────
1503
1504    #[test]
1505    fn workspace_ceiling_escalation_enable_blocked() {
1506        let mut global = valid_tiered_config();
1507        global.permissions.user.escalation_allowed = Some(false);
1508        let mut workspace = RoutingConfig::default();
1509        workspace.permissions.user.escalation_allowed = Some(true);
1510        let errors = validate_workspace_ceiling(&global, &workspace);
1511        assert!(
1512            has_error(
1513                &errors,
1514                "routing.permissions.user.escalation_allowed",
1515                ValidationSeverity::Error
1516            ),
1517            "workspace enabling escalation when global disables should error"
1518        );
1519    }
1520
1521    // ── Test 40: workspace wildcard tool access blocked ──────────────
1522
1523    #[test]
1524    fn workspace_wildcard_tool_access_blocked() {
1525        let mut global = valid_tiered_config();
1526        global.permissions.user.tool_access = Some(vec!["read_file".into()]);
1527        let mut workspace = RoutingConfig::default();
1528        workspace.permissions.user.tool_access = Some(vec!["*".into()]);
1529        let errors = validate_workspace_ceiling(&global, &workspace);
1530        assert!(
1531            has_error(
1532                &errors,
1533                "routing.permissions.user.tool_access",
1534                ValidationSeverity::Error
1535            ),
1536            "workspace wildcard when global does not should error"
1537        );
1538    }
1539
1540    // ── Test 41: workspace per-user override ceiling ────────────────
1541
1542    #[test]
1543    fn workspace_per_user_override_ceiling() {
1544        let global = valid_tiered_config();
1545        let mut workspace = RoutingConfig::default();
1546        workspace.permissions.users.insert(
1547            "attacker".into(),
1548            PermissionLevelConfig {
1549                level: Some(2), // admin -- exceeds max_grantable=1
1550                ..PermissionLevelConfig::default()
1551            },
1552        );
1553        let errors = validate_workspace_ceiling(&global, &workspace);
1554        assert!(
1555            has_error(
1556                &errors,
1557                "routing.permissions.users.attacker.level",
1558                ValidationSeverity::Error
1559            ),
1560            "workspace per-user admin level should be blocked"
1561        );
1562    }
1563
1564    // ── Test 42: Display impl ───────────────────────────────────────
1565
1566    #[test]
1567    fn display_impl_formats_correctly() {
1568        let err = ValidationError {
1569            field: "routing.tiers[0].name".into(),
1570            message: "duplicate tier name 'free'".into(),
1571            severity: ValidationSeverity::Error,
1572        };
1573        let displayed = format!("{}", err);
1574        assert!(displayed.contains("[ERROR]"));
1575        assert!(displayed.contains("routing.tiers[0].name"));
1576        assert!(displayed.contains("duplicate tier name 'free'"));
1577
1578        let warn = ValidationError {
1579            field: "routing.fallback_model".into(),
1580            message: "missing provider prefix".into(),
1581            severity: ValidationSeverity::Warning,
1582        };
1583        let displayed = format!("{}", warn);
1584        assert!(displayed.contains("[WARN]"));
1585    }
1586
1587    // ── Test 43: escalation enabled with zero max tiers ─────────────
1588
1589    #[test]
1590    fn escalation_enabled_zero_max_tiers() {
1591        let mut config = valid_tiered_config();
1592        config.escalation = EscalationConfig {
1593            enabled: true,
1594            threshold: 0.6,
1595            max_escalation_tiers: 0,
1596        };
1597        let errors = validate_routing_config(&config);
1598        assert!(
1599            has_error(
1600                &errors,
1601                "routing.escalation.max_escalation_tiers",
1602                ValidationSeverity::Error
1603            ),
1604            "max_escalation_tiers=0 with enabled escalation should error"
1605        );
1606    }
1607
1608    // ── Test 44: valid fallback model format ────────────────────────
1609
1610    #[test]
1611    fn valid_fallback_model_no_warning() {
1612        let mut config = valid_tiered_config();
1613        config.fallback_model = Some("groq/llama-3.1-8b".into());
1614        let errors = validate_routing_config(&config);
1615        let fb_warnings = errors.iter().any(|e| {
1616            e.field.contains("fallback_model") && e.severity == ValidationSeverity::Warning
1617        });
1618        assert!(!fb_warnings, "valid fallback model should not warn");
1619    }
1620
1621    // ── Test 45: workspace within global wildcard tool access ok ─────
1622
1623    #[test]
1624    fn workspace_tools_within_global_wildcard_ok() {
1625        let mut global = valid_tiered_config();
1626        global.permissions.user.tool_access = Some(vec!["*".into()]);
1627        let mut workspace = RoutingConfig::default();
1628        workspace.permissions.user.tool_access =
1629            Some(vec!["read_file".into(), "exec_shell".into()]);
1630        let errors = validate_workspace_ceiling(&global, &workspace);
1631        let tool_errors = errors
1632            .iter()
1633            .any(|e| e.field.contains("tool_access") && e.severity == ValidationSeverity::Error);
1634        assert!(
1635            !tool_errors,
1636            "workspace tools under global wildcard should pass"
1637        );
1638    }
1639}