Skip to main content

vfa_tui/policy/
engine.rs

1//! Policy evaluation engine — deterministic pass/fail for every workspace.
2//!
3//! Req 11.1–11.7.
4
5#![deny(warnings)]
6
7use std::collections::HashSet;
8
9use crate::catalog::store::CatalogStore;
10use crate::federation::scanner::InstalledAsset;
11use crate::models::agent::Lifecycle;
12use crate::models::policy::{PolicyEvaluation, PolicyRuleType, PolicyScope, RuleResult};
13use crate::models::workspace::ResolvedWorkspace;
14
15use super::parser::PolicyConfig;
16
17// ---------------------------------------------------------------------------
18// Local glob_match
19// ---------------------------------------------------------------------------
20
21/// Match `text` against a glob `pattern` supporting `*` (any run) and `?`
22/// (any single char). Case-sensitive.
23pub fn glob_match(pattern: &str, text: &str) -> bool {
24    let pat: Vec<char> = pattern.chars().collect();
25    let txt: Vec<char> = text.chars().collect();
26    glob_inner(&pat, &txt)
27}
28
29fn glob_inner(pat: &[char], txt: &[char]) -> bool {
30    match (pat.first(), txt.first()) {
31        (None, None) => true,
32        (Some(&'*'), _) => {
33            glob_inner(&pat[1..], txt) || (!txt.is_empty() && glob_inner(pat, &txt[1..]))
34        }
35        (Some(&'?'), Some(_)) => glob_inner(&pat[1..], &txt[1..]),
36        (Some(p), Some(t)) if p == t => glob_inner(&pat[1..], &txt[1..]),
37        _ => false,
38    }
39}
40
41// ---------------------------------------------------------------------------
42// Round-half-up helper
43// ---------------------------------------------------------------------------
44
45/// Round `v` to 1 decimal place using half-up (0.5 rounds away from zero).
46pub fn round_half_up_1dp(v: f64) -> f64 {
47    (v * 10.0 + 0.5).floor() / 10.0
48}
49
50// ---------------------------------------------------------------------------
51// Lifecycle ordering
52// ---------------------------------------------------------------------------
53
54/// Maps a lifecycle stage to a numeric rank for comparison.
55/// Lower rank = earlier in the lifecycle progression.
56/// Deprecated (rank 3) is past Stable (rank 2); a min_stage=Stable gate PASSES
57/// for Deprecated assets.
58pub fn lifecycle_rank(lc: Lifecycle) -> u8 {
59    match lc {
60        Lifecycle::Experimental => 0,
61        Lifecycle::Beta => 1,
62        Lifecycle::Stable => 2,
63        Lifecycle::Deprecated => 3,
64    }
65}
66
67/// Returns `true` when `lc` is strictly below `min_stage` in maturity order.
68pub fn lifecycle_below(lc: Lifecycle, min_stage: Lifecycle) -> bool {
69    lifecycle_rank(lc) < lifecycle_rank(min_stage)
70}
71
72// ---------------------------------------------------------------------------
73// PolicyEngine
74// ---------------------------------------------------------------------------
75
76/// Evaluates declarative policy rules against workspace state (Req 11.1–11.7).
77pub struct PolicyEngine;
78
79impl PolicyEngine {
80    /// Evaluate all rules in `config` against `workspace` / `installed` / `catalog`.
81    ///
82    /// Only rules whose scope matches the workspace and that are not suppressed
83    /// contribute to the result. Deterministic: same inputs → identical output
84    /// (Req 11.3).
85    pub fn evaluate(
86        config: &PolicyConfig,
87        workspace: &ResolvedWorkspace,
88        installed: &[InstalledAsset],
89        catalog: &CatalogStore,
90        now_date: &str,
91    ) -> PolicyEvaluation {
92        let mut results: Vec<RuleResult> = Vec::new();
93
94        let installed_ids: HashSet<&str> = installed
95            .iter()
96            .filter(|a| a.confirmed)
97            .map(|a| a.asset_id.as_str())
98            .collect();
99
100        for rule in &config.rules {
101            if !Self::rule_applies(rule, workspace) {
102                continue;
103            }
104            if Self::is_suppressed(rule, workspace, config, now_date) {
105                continue;
106            }
107
108            let (passed, details) =
109                Self::evaluate_rule_type(&rule.rule_type, installed, &installed_ids, catalog);
110
111            results.push(RuleResult {
112                rule_id: rule.id.clone(),
113                passed,
114                details: if passed { None } else { details },
115            });
116        }
117
118        let total = results.len();
119        let passed = results.iter().filter(|r| r.passed).count();
120        let compliance_score = Self::compliance_score(passed, total);
121
122        PolicyEvaluation {
123            workspace: workspace.name.clone(),
124            results,
125            compliance_score,
126        }
127    }
128
129    /// Return `true` when `rule.scope` matches `workspace` (Req 11.6).
130    pub fn rule_applies(
131        rule: &crate::models::policy::PolicyRule,
132        workspace: &ResolvedWorkspace,
133    ) -> bool {
134        match &rule.scope {
135            PolicyScope::All => true,
136            PolicyScope::NamePattern(pat) => glob_match(pat, &workspace.name),
137            PolicyScope::Team(team) => workspace
138                .team
139                .as_deref()
140                .map(|t| t == team.as_str())
141                .unwrap_or(false),
142        }
143    }
144
145    /// Return `true` when a suppression covers `rule` for `workspace` and has
146    /// not expired as of `now_date` (YYYY-MM-DD, Req 15.6).
147    ///
148    /// Lexicographic comparison is safe for ISO dates in this format.
149    pub fn is_suppressed(
150        rule: &crate::models::policy::PolicyRule,
151        workspace: &ResolvedWorkspace,
152        config: &PolicyConfig,
153        now_date: &str,
154    ) -> bool {
155        config.suppressions.iter().any(|s| {
156            s.rule_id == rule.id
157                && s.workspace == workspace.name
158                && (s.expires.is_empty() || s.expires.as_str() >= now_date)
159        })
160    }
161
162    /// Compute compliance score: `(passed / total) * 100`, rounded half-up to
163    /// 1 decimal place (Req 15.3). Returns 100.0 when `total == 0`.
164    pub fn compliance_score(passed: usize, total: usize) -> f64 {
165        if total == 0 {
166            return 100.0;
167        }
168        let raw = (passed as f64 / total as f64) * 100.0;
169        round_half_up_1dp(raw)
170    }
171
172    // -----------------------------------------------------------------------
173    // Per-rule evaluation (private)
174    // -----------------------------------------------------------------------
175
176    fn evaluate_rule_type(
177        rule_type: &PolicyRuleType,
178        installed: &[InstalledAsset],
179        installed_ids: &HashSet<&str>,
180        catalog: &CatalogStore,
181    ) -> (bool, Option<String>) {
182        match rule_type {
183            PolicyRuleType::RequireAsset { asset_id } => {
184                if installed_ids.contains(asset_id.as_str()) {
185                    (true, None)
186                } else {
187                    (
188                        false,
189                        Some(format!("asset '{}' is not installed", asset_id)),
190                    )
191                }
192            }
193
194            PolicyRuleType::RequireRole { role_id } => {
195                let role_agents = catalog.agents_for_role(role_id);
196                if role_agents.is_empty() {
197                    return (true, None);
198                }
199                let missing: Vec<&str> = role_agents
200                    .iter()
201                    .filter(|a| !installed_ids.contains(a.id.as_str()))
202                    .map(|a| a.id.as_str())
203                    .collect();
204                if missing.is_empty() {
205                    (true, None)
206                } else {
207                    (
208                        false,
209                        Some(format!(
210                            "role '{}' agents not installed: {}",
211                            role_id,
212                            missing.join(", ")
213                        )),
214                    )
215                }
216            }
217
218            PolicyRuleType::MaxStale { threshold } => {
219                let stale_count: usize = installed
220                    .iter()
221                    .filter(|a| is_asset_stale(a, catalog))
222                    .count();
223                let thr = *threshold as usize;
224                if stale_count <= thr {
225                    (true, None)
226                } else {
227                    (
228                        false,
229                        Some(format!(
230                            "{} stale asset(s) exceed threshold of {}",
231                            stale_count, thr
232                        )),
233                    )
234                }
235            }
236
237            PolicyRuleType::TrustBoundary {
238                max_mutation,
239                max_egress,
240                max_credentials,
241            } => {
242                let violations: Vec<String> = catalog
243                    .mcp_refs
244                    .iter()
245                    .filter(|mcp| installed_ids.contains(mcp.id.as_str()))
246                    .filter_map(|mcp| {
247                        let tm = mcp.trust_matrix.as_ref()?;
248                        let mut problems = Vec::new();
249                        if !max_mutation && tm.mutation_capable {
250                            problems.push("mutation_capable");
251                        }
252                        if !max_egress && tm.requires_egress {
253                            problems.push("requires_egress");
254                        }
255                        if !max_credentials && tm.requires_credentials {
256                            problems.push("requires_credentials");
257                        }
258                        if problems.is_empty() {
259                            None
260                        } else {
261                            Some(format!("{}: {}", mcp.id, problems.join(",")))
262                        }
263                    })
264                    .collect();
265
266                if violations.is_empty() {
267                    (true, None)
268                } else {
269                    (
270                        false,
271                        Some(format!(
272                            "trust boundary violated by MCP refs: {}",
273                            violations.join("; ")
274                        )),
275                    )
276                }
277            }
278
279            PolicyRuleType::LifecycleGate { min_stage } => {
280                let violations: Vec<String> = installed
281                    .iter()
282                    .filter_map(|a| {
283                        let agent = catalog.agent_by_id(&a.asset_id)?;
284                        let lc = agent.lifecycle?;
285                        if lifecycle_below(lc, *min_stage) {
286                            Some(format!("{} ({})", a.asset_id, lc))
287                        } else {
288                            None
289                        }
290                    })
291                    .collect();
292
293                if violations.is_empty() {
294                    (true, None)
295                } else {
296                    (
297                        false,
298                        Some(format!(
299                            "assets below min lifecycle '{}': {}",
300                            min_stage,
301                            violations.join(", ")
302                        )),
303                    )
304                }
305            }
306        }
307    }
308}
309
310// ---------------------------------------------------------------------------
311// Stale-asset helper
312// ---------------------------------------------------------------------------
313
314fn is_asset_stale(installed: &InstalledAsset, catalog: &CatalogStore) -> bool {
315    let agent = match catalog.agent_by_id(&installed.asset_id) {
316        Some(a) => a,
317        None => return false,
318    };
319    let canonical_ver = match &agent.version {
320        Some(v) => v,
321        None => return false,
322    };
323    let installed_ver = match &installed.installed_version {
324        Some(v) => v,
325        None => return false,
326    };
327    installed_ver != canonical_ver
328}
329
330// ---------------------------------------------------------------------------
331// Tests
332// ---------------------------------------------------------------------------
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::federation::scanner::InstalledAsset;
338    use crate::models::agent::{AgentType, Lifecycle};
339    use crate::models::harness::{Harness, SourceType};
340    use crate::models::policy::{PolicyRule, PolicyScope, Severity, Suppression};
341    use crate::models::provider::Provider;
342    use crate::models::workspace::{ResolvedWorkspace, WorkspaceStatus};
343    use std::collections::HashMap;
344    use std::path::PathBuf;
345
346    fn make_workspace(name: &str, team: Option<&str>) -> ResolvedWorkspace {
347        ResolvedWorkspace {
348            canonical_path: PathBuf::from(format!("/workspaces/{}", name)),
349            name: name.to_string(),
350            team: team.map(|t| t.to_string()),
351            tags: vec![],
352            status: WorkspaceStatus::Available,
353        }
354    }
355
356    fn make_rule(id: &str, rule_type: PolicyRuleType, scope: PolicyScope) -> PolicyRule {
357        PolicyRule {
358            id: id.to_string(),
359            rule_type,
360            severity: Severity::Warning,
361            scope,
362            description: "test".to_string(),
363        }
364    }
365
366    fn make_installed(asset_id: &str) -> InstalledAsset {
367        InstalledAsset {
368            workspace_path: PathBuf::from(format!("/workspace/.claude/{}.md", asset_id)),
369            asset_id: asset_id.to_string(),
370            installed_version: Some("1.0.0".to_string()),
371            content_hash: "abc".to_string(),
372            detection_methods: vec![],
373            confirmed: true,
374            harness: ".claude".to_string(),
375        }
376    }
377
378    fn make_agent(id: &str, lc: Lifecycle) -> crate::models::agent::Agent {
379        crate::models::agent::Agent {
380            id: id.to_string(),
381            name: id.to_string(),
382            entity_type: AgentType::Agent,
383            provider: Provider::Aws,
384            harnesses: vec![Harness::ClaudeCode],
385            summary: String::new(),
386            source_type: SourceType::Original,
387            official_docs: vec![],
388            security_notes: String::new(),
389            last_verified: "2024-01-01".to_string(),
390            path: format!("agents/{}.md", id),
391            companion_skills: vec![],
392            execution_tier: None,
393            lifecycle: Some(lc),
394            harness_variants: HashMap::new(),
395            author: None,
396            version: Some("1.0.0".to_string()),
397            provider_coverage: None,
398        }
399    }
400
401    fn empty_config() -> PolicyConfig {
402        PolicyConfig::default()
403    }
404
405    fn config_with_rule(rule: PolicyRule) -> PolicyConfig {
406        PolicyConfig {
407            rules: vec![rule],
408            ..Default::default()
409        }
410    }
411
412    // glob_match tests
413    #[test]
414    fn glob_match_exact() {
415        assert!(glob_match("production-infra", "production-infra"));
416        assert!(!glob_match("production-infra", "staging-infra"));
417    }
418
419    #[test]
420    fn glob_match_star() {
421        assert!(glob_match("production-*", "production-infra"));
422        assert!(!glob_match("production-*", "staging-infra"));
423        assert!(glob_match("*", "anything"));
424        assert!(glob_match("*", ""));
425    }
426
427    #[test]
428    fn glob_match_question() {
429        assert!(glob_match("prod?", "proda"));
430        assert!(!glob_match("prod?", "prod"));
431    }
432
433    // compliance_score tests
434    #[test]
435    fn compliance_score_all_pass() {
436        assert!((PolicyEngine::compliance_score(3, 3) - 100.0).abs() < 1e-9);
437    }
438
439    #[test]
440    fn compliance_score_none_pass() {
441        assert!((PolicyEngine::compliance_score(0, 3) - 0.0).abs() < 1e-9);
442    }
443
444    #[test]
445    fn compliance_score_zero_total() {
446        assert!((PolicyEngine::compliance_score(0, 0) - 100.0).abs() < 1e-9);
447    }
448
449    #[test]
450    fn compliance_score_rounds_half_up() {
451        let s = PolicyEngine::compliance_score(1, 3);
452        assert!((s - 33.3).abs() < 1e-9, "got {}", s);
453        let s2 = PolicyEngine::compliance_score(2, 3);
454        assert!((s2 - 66.7).abs() < 1e-9, "got {}", s2);
455    }
456
457    // rule_applies tests
458    #[test]
459    fn rule_applies_all() {
460        let ws = make_workspace("any-workspace", None);
461        let rule = make_rule(
462            "r1",
463            PolicyRuleType::MaxStale { threshold: 5 },
464            PolicyScope::All,
465        );
466        assert!(PolicyEngine::rule_applies(&rule, &ws));
467    }
468
469    #[test]
470    fn rule_applies_name_pattern_match() {
471        let ws = make_workspace("production-infra", None);
472        let rule = make_rule(
473            "r1",
474            PolicyRuleType::MaxStale { threshold: 5 },
475            PolicyScope::NamePattern("production-*".to_string()),
476        );
477        assert!(PolicyEngine::rule_applies(&rule, &ws));
478    }
479
480    #[test]
481    fn rule_applies_name_pattern_no_match() {
482        let ws = make_workspace("staging-infra", None);
483        let rule = make_rule(
484            "r1",
485            PolicyRuleType::MaxStale { threshold: 5 },
486            PolicyScope::NamePattern("production-*".to_string()),
487        );
488        assert!(!PolicyEngine::rule_applies(&rule, &ws));
489    }
490
491    #[test]
492    fn rule_applies_team_match() {
493        let ws = make_workspace("ws", Some("platform"));
494        let rule = make_rule(
495            "r1",
496            PolicyRuleType::MaxStale { threshold: 5 },
497            PolicyScope::Team("platform".to_string()),
498        );
499        assert!(PolicyEngine::rule_applies(&rule, &ws));
500    }
501
502    #[test]
503    fn rule_applies_team_no_match() {
504        let ws = make_workspace("ws", Some("devex"));
505        let rule = make_rule(
506            "r1",
507            PolicyRuleType::MaxStale { threshold: 5 },
508            PolicyScope::Team("platform".to_string()),
509        );
510        assert!(!PolicyEngine::rule_applies(&rule, &ws));
511    }
512
513    // is_suppressed tests
514    #[test]
515    fn is_suppressed_active() {
516        let ws = make_workspace("staging-infra", None);
517        let rule = make_rule(
518            "no-mutation",
519            PolicyRuleType::MaxStale { threshold: 0 },
520            PolicyScope::All,
521        );
522        let mut cfg = empty_config();
523        cfg.suppressions.push(Suppression {
524            rule_id: "no-mutation".to_string(),
525            workspace: "staging-infra".to_string(),
526            reason: "approved".to_string(),
527            approver: "lead@example.com".to_string(),
528            expires: "2099-12-31".to_string(),
529        });
530        assert!(PolicyEngine::is_suppressed(&rule, &ws, &cfg, "2026-06-13"));
531    }
532
533    #[test]
534    fn is_suppressed_expired() {
535        let ws = make_workspace("staging-infra", None);
536        let rule = make_rule(
537            "no-mutation",
538            PolicyRuleType::MaxStale { threshold: 0 },
539            PolicyScope::All,
540        );
541        let mut cfg = empty_config();
542        cfg.suppressions.push(Suppression {
543            rule_id: "no-mutation".to_string(),
544            workspace: "staging-infra".to_string(),
545            reason: "approved".to_string(),
546            approver: "lead@example.com".to_string(),
547            expires: "2020-01-01".to_string(),
548        });
549        assert!(!PolicyEngine::is_suppressed(&rule, &ws, &cfg, "2026-06-13"));
550    }
551
552    // evaluate tests
553    #[test]
554    fn evaluate_require_asset_pass() {
555        let ws = make_workspace("prod", None);
556        let installed = vec![make_installed("aws-iam-scanner")];
557        let rule = make_rule(
558            "r1",
559            PolicyRuleType::RequireAsset {
560                asset_id: "aws-iam-scanner".to_string(),
561            },
562            PolicyScope::All,
563        );
564        let cfg = config_with_rule(rule);
565        let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
566        let eval = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
567        assert_eq!(eval.results.len(), 1);
568        assert!(eval.results[0].passed);
569        assert!((eval.compliance_score - 100.0).abs() < 1e-9);
570    }
571
572    #[test]
573    fn evaluate_require_asset_fail() {
574        let ws = make_workspace("prod", None);
575        let installed: Vec<InstalledAsset> = vec![];
576        let rule = make_rule(
577            "r1",
578            PolicyRuleType::RequireAsset {
579                asset_id: "aws-iam-scanner".to_string(),
580            },
581            PolicyScope::All,
582        );
583        let cfg = config_with_rule(rule);
584        let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
585        let eval = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
586        assert!(!eval.results[0].passed);
587        assert!((eval.compliance_score - 0.0).abs() < 1e-9);
588    }
589
590    #[test]
591    fn evaluate_lifecycle_gate_fail_experimental() {
592        let ws = make_workspace("prod", None);
593        let installed = vec![make_installed("my-agent")];
594        let agent = make_agent("my-agent", Lifecycle::Experimental);
595        let rule = make_rule(
596            "lc",
597            PolicyRuleType::LifecycleGate {
598                min_stage: Lifecycle::Stable,
599            },
600            PolicyScope::All,
601        );
602        let cfg = config_with_rule(rule);
603        let store = CatalogStore::from_parts(vec![agent], vec![], HashMap::new(), vec![], vec![]);
604        let eval = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
605        assert!(!eval.results[0].passed);
606    }
607
608    #[test]
609    fn evaluate_lifecycle_gate_pass_deprecated() {
610        // Deprecated > Stable in rank, so it should pass min_stage=Stable.
611        let ws = make_workspace("prod", None);
612        let installed = vec![make_installed("my-agent")];
613        let agent = make_agent("my-agent", Lifecycle::Deprecated);
614        let rule = make_rule(
615            "lc",
616            PolicyRuleType::LifecycleGate {
617                min_stage: Lifecycle::Stable,
618            },
619            PolicyScope::All,
620        );
621        let cfg = config_with_rule(rule);
622        let store = CatalogStore::from_parts(vec![agent], vec![], HashMap::new(), vec![], vec![]);
623        let eval = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
624        assert!(eval.results[0].passed);
625    }
626
627    #[test]
628    fn evaluate_scope_filters_non_applicable() {
629        let ws = make_workspace("staging-infra", None);
630        let installed: Vec<InstalledAsset> = vec![];
631        let rule = make_rule(
632            "r1",
633            PolicyRuleType::RequireAsset {
634                asset_id: "scanner".to_string(),
635            },
636            PolicyScope::NamePattern("production-*".to_string()),
637        );
638        let cfg = config_with_rule(rule);
639        let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
640        let eval = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
641        assert!(eval.results.is_empty());
642        assert!((eval.compliance_score - 100.0).abs() < 1e-9);
643    }
644
645    // -----------------------------------------------------------------------
646    // Property 22 (Req 11.3): determinism
647    // -----------------------------------------------------------------------
648
649    proptest::proptest! {
650        #![proptest_config(proptest::prelude::ProptestConfig::with_cases(256))]
651
652        #[test]
653        fn prop22_evaluate_deterministic(
654            ws_name in "[a-z][a-z0-9-]{1,15}",
655            asset_id in "[a-z][a-z0-9-]{1,10}",
656            installed_count in 0usize..5,
657        ) {
658            let ws = make_workspace(&ws_name, None);
659            let installed: Vec<InstalledAsset> = (0..installed_count)
660                .map(|i| make_installed(&format!("{}-{}", asset_id, i)))
661                .collect();
662            let rule = make_rule(
663                "r1",
664                PolicyRuleType::RequireAsset { asset_id: asset_id.clone() },
665                PolicyScope::All,
666            );
667            let cfg = config_with_rule(rule);
668            let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
669
670            let eval1 = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
671            let eval2 = PolicyEngine::evaluate(&cfg, &ws, &installed, &store, "2026-06-13");
672
673            proptest::prop_assert_eq!(eval1.workspace.clone(), eval2.workspace.clone());
674            proptest::prop_assert_eq!(eval1.results.len(), eval2.results.len());
675            for (r1, r2) in eval1.results.iter().zip(eval2.results.iter()) {
676                proptest::prop_assert_eq!(r1.rule_id.clone(), r2.rule_id.clone());
677                proptest::prop_assert_eq!(r1.passed, r2.passed);
678            }
679            proptest::prop_assert_eq!(
680                (eval1.compliance_score * 10.0).round() as i64,
681                (eval2.compliance_score * 10.0).round() as i64
682            );
683        }
684
685        // -----------------------------------------------------------------------
686        // Property 23 (Req 11.6): scope matching correctness
687        // -----------------------------------------------------------------------
688
689        #[test]
690        fn prop23_scope_matching(
691            ws_name in "[a-z][a-z0-9-]{1,12}",
692            team in proptest::option::of("[a-z][a-z0-9-]{1,8}"),
693        ) {
694            let ws = ResolvedWorkspace {
695                canonical_path: std::path::PathBuf::from("/ws"),
696                name: ws_name.clone(),
697                team: team.clone(),
698                tags: vec![],
699                status: WorkspaceStatus::Available,
700            };
701
702            // Scope::All always applies
703            let rule_all = make_rule("all", PolicyRuleType::MaxStale { threshold: 99 }, PolicyScope::All);
704            proptest::prop_assert!(PolicyEngine::rule_applies(&rule_all, &ws));
705
706            // Scope::NamePattern with exact name always applies
707            let rule_exact = make_rule(
708                "exact",
709                PolicyRuleType::MaxStale { threshold: 99 },
710                PolicyScope::NamePattern(ws_name.clone()),
711            );
712            proptest::prop_assert!(PolicyEngine::rule_applies(&rule_exact, &ws));
713
714            // Scope::NamePattern "*" always applies
715            let rule_star = make_rule(
716                "star",
717                PolicyRuleType::MaxStale { threshold: 99 },
718                PolicyScope::NamePattern("*".to_string()),
719            );
720            proptest::prop_assert!(PolicyEngine::rule_applies(&rule_star, &ws));
721
722            // Scope::Team matches only if workspace has that exact team
723            let sentinel = "sentinel-team-zzz";
724            let rule_team = make_rule(
725                "team",
726                PolicyRuleType::MaxStale { threshold: 99 },
727                PolicyScope::Team(sentinel.to_string()),
728            );
729            let expected = ws.team.as_deref() == Some(sentinel);
730            proptest::prop_assert_eq!(PolicyEngine::rule_applies(&rule_team, &ws), expected);
731        }
732    }
733}