Skip to main content

harn_vm/flow/predicates/
compose.rs

1//! Hierarchical composition for Flow predicates.
2//!
3//! Discovery finds `invariants.harn` files. This module turns those files into
4//! applicable predicate declarations and composes their evaluated verdicts
5//! without letting a deeper directory relax a shallower rule.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use super::discovery::{DiscoveredInvariantFile, DiscoveredPredicate};
12use super::result::{Approver, InvariantBlockError, InvariantResult, Verdict};
13use crate::flow::{PredicateHash, PredicateKind};
14
15/// Stable error code attached to a `Block` produced by ceiling enforcement.
16pub const PREDICATE_COUNT_EXPLOSION_CODE: &str = "predicate_count_explosion";
17
18/// Source location of a predicate within the directory hierarchy.
19#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
20pub struct PredicateSource {
21    /// Directory relative to the discovery root. The root is represented as
22    /// `"."`, matching [`DiscoveredInvariantFile::relative_dir`].
23    pub relative_dir: String,
24    /// Root is depth 0; each descendant component increments the depth.
25    pub depth: usize,
26}
27
28impl PredicateSource {
29    pub fn new(relative_dir: impl Into<String>) -> Self {
30        let relative_dir = normalize_relative_dir(relative_dir.into());
31        let depth = directory_depth(&relative_dir);
32        Self {
33            relative_dir,
34            depth,
35        }
36    }
37
38    fn is_ancestor_of_or_same(&self, other: &Self) -> bool {
39        is_ancestor_dir(&self.relative_dir, &other.relative_dir)
40    }
41}
42
43/// One predicate declaration after hierarchical resolution.
44#[derive(Clone, Debug)]
45pub struct ResolvedPredicate {
46    /// Stable UI/logging name, e.g. `services/api::no_pii`.
47    pub qualified_name: String,
48    /// Function name used to identify ancestor/child override lineages.
49    pub logical_name: String,
50    pub source: PredicateSource,
51    /// Stable source-order index within the resolved set.
52    pub source_order: usize,
53    /// Resolved source hash of a semantic predicate's deterministic fallback.
54    pub fallback_hash: Option<PredicateHash>,
55    pub predicate: DiscoveredPredicate,
56}
57
58/// Strictness rank for merging verdicts.
59#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
60pub enum VerdictStrictness {
61    Allow,
62    Warn,
63    RequireApproval,
64    Block,
65}
66
67/// A predicate evaluation stamped with its source depth.
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69pub struct PredicateEvaluation {
70    pub qualified_name: String,
71    pub logical_name: String,
72    pub source: PredicateSource,
73    pub result: InvariantResult,
74}
75
76impl PredicateEvaluation {
77    pub fn new(resolved: &ResolvedPredicate, result: InvariantResult) -> Self {
78        Self {
79            qualified_name: resolved.qualified_name.clone(),
80            logical_name: resolved.logical_name.clone(),
81            source: resolved.source.clone(),
82            result,
83        }
84    }
85}
86
87/// Effective verdict for one predicate evaluation after ancestor composition.
88#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
89pub struct ComposedPredicateEvaluation {
90    pub qualified_name: String,
91    pub logical_name: String,
92    pub source: PredicateSource,
93    /// The predicate declaration whose verdict governs this evaluation after
94    /// applying strictness and shallower-tie rules.
95    pub selected_qualified_name: String,
96    pub selected_source: PredicateSource,
97    pub result: InvariantResult,
98}
99
100/// Resolve the applicable predicates for a single root-to-leaf discovery chain.
101///
102/// Unlike the older override resolver, this keeps ancestor and child
103/// declarations. Both must be evaluated so a child can tighten a parent but
104/// cannot relax an ancestor's blocking verdict.
105pub fn resolve_predicates(files: &[DiscoveredInvariantFile]) -> Vec<ResolvedPredicate> {
106    let mut resolved = Vec::new();
107    let mut visible_deterministic = BTreeMap::<String, PredicateHash>::new();
108    for file in files {
109        let source = PredicateSource::new(&file.relative_dir);
110        for predicate in &file.predicates {
111            if predicate.kind == PredicateKind::Deterministic {
112                visible_deterministic.insert(predicate.name.clone(), predicate.source_hash.clone());
113            }
114        }
115        for predicate in &file.predicates {
116            let fallback_hash = predicate
117                .fallback
118                .as_ref()
119                .and_then(|fallback| visible_deterministic.get(fallback))
120                .cloned();
121            resolved.push(ResolvedPredicate {
122                qualified_name: qualified_name(&file.relative_dir, &predicate.name),
123                logical_name: predicate.name.clone(),
124                source: source.clone(),
125                source_order: resolved.len(),
126                fallback_hash,
127                predicate: predicate.clone(),
128            });
129        }
130    }
131    resolved
132}
133
134/// Resolve predicate declarations for every touched directory and union them.
135///
136/// Shared ancestors are de-duplicated by `(source_dir, predicate_name)`, while
137/// sibling directories keep same-named predicates as independent declarations.
138pub fn resolve_predicates_for_touched_directories(
139    chains: &[Vec<DiscoveredInvariantFile>],
140) -> Vec<ResolvedPredicate> {
141    let mut by_source_and_name: BTreeMap<(String, String), ResolvedPredicate> = BTreeMap::new();
142
143    for chain in chains {
144        for resolved in resolve_predicates(chain) {
145            let key = (
146                resolved.source.relative_dir.clone(),
147                resolved.logical_name.clone(),
148            );
149            let source_order = by_source_and_name.len();
150            by_source_and_name
151                .entry(key)
152                .or_insert_with(|| ResolvedPredicate {
153                    source_order,
154                    ..resolved
155                });
156        }
157    }
158
159    let mut resolved = by_source_and_name.into_values().collect::<Vec<_>>();
160    resolved.sort_by(|left, right| {
161        left.source
162            .depth
163            .cmp(&right.source.depth)
164            .then_with(|| left.source.relative_dir.cmp(&right.source.relative_dir))
165            .then_with(|| left.source_order.cmp(&right.source_order))
166            .then_with(|| left.logical_name.cmp(&right.logical_name))
167    });
168    resolved
169}
170
171/// Predicate-count explosion limits applied to a slice's resolved predicate
172/// union before evaluation begins.
173///
174/// Cross-directory union semantics make it cheap to accidentally pull a
175/// pathological number of predicates into one slice — touch ten leaf
176/// directories that each declare a dozen sibling-specific invariants and
177/// suddenly Ship Captain is paying serial wall-clock for every one of them.
178/// The ceiling makes that cost visible:
179///
180/// - Below `require_approval_threshold` evaluation proceeds without ceremony.
181/// - Between `require_approval_threshold` and `block_threshold` the slice
182///   still ships, but only after a named approver co-signs (`RequireApproval`).
183/// - At or above `block_threshold` Flow refuses to evaluate the union and
184///   returns `Block` so a human can split the slice or prune predicates.
185#[derive(Clone, Debug)]
186pub struct PredicateCeiling {
187    pub require_approval_threshold: usize,
188    pub block_threshold: usize,
189    /// Approver routed when only the soft ceiling is breached.
190    pub approver: Approver,
191}
192
193impl PredicateCeiling {
194    /// Default soft ceiling. Slices with this many predicates are large enough
195    /// to warrant a human glance even when every predicate passes.
196    pub const DEFAULT_REQUIRE_APPROVAL_THRESHOLD: usize = 256;
197    /// Default hard ceiling. Beyond this the union is almost always a
198    /// misconfigured `invariants.harn` tree, not a legitimate slice.
199    pub const DEFAULT_BLOCK_THRESHOLD: usize = 1024;
200    /// Default approver role for soft-ceiling escalations.
201    pub const DEFAULT_APPROVER_ROLE: &'static str = "flow-platform";
202}
203
204impl Default for PredicateCeiling {
205    fn default() -> Self {
206        Self {
207            require_approval_threshold: Self::DEFAULT_REQUIRE_APPROVAL_THRESHOLD,
208            block_threshold: Self::DEFAULT_BLOCK_THRESHOLD,
209            approver: Approver::role(Self::DEFAULT_APPROVER_ROLE),
210        }
211    }
212}
213
214/// One directory's contribution to a predicate-count explosion.
215#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
216pub struct DirectoryContribution {
217    pub relative_dir: String,
218    pub count: usize,
219}
220
221/// Severity tier of a [`PredicateCeilingViolation`].
222#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum PredicateCeilingLevel {
225    RequireApproval,
226    Block,
227}
228
229/// Structured detail emitted when a slice's predicate union exceeds the
230/// ceiling. Callers may render it directly or convert it into the canonical
231/// [`InvariantResult`] via [`PredicateCeilingViolation::to_invariant_result`].
232#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
233pub struct PredicateCeilingViolation {
234    pub level: PredicateCeilingLevel,
235    pub count: usize,
236    pub threshold: usize,
237    /// Directories that contributed the most predicates, sorted by count
238    /// descending. Truncated to keep messages readable.
239    pub top_contributors: Vec<DirectoryContribution>,
240}
241
242impl PredicateCeilingViolation {
243    /// Maximum number of contributing directories surfaced in messages and
244    /// reports. Beyond this the breakdown is more noise than signal.
245    pub const MAX_TOP_CONTRIBUTORS: usize = 5;
246
247    /// Render the violation as the canonical [`InvariantResult`] used by
248    /// callers that fold the explosion limit into the same verdict pipeline as
249    /// real predicates.
250    pub fn to_invariant_result(&self, approver: &Approver) -> InvariantResult {
251        match self.level {
252            PredicateCeilingLevel::Block => InvariantResult::block(InvariantBlockError::new(
253                PREDICATE_COUNT_EXPLOSION_CODE,
254                self.message(),
255            )),
256            PredicateCeilingLevel::RequireApproval => {
257                InvariantResult::require_approval(approver.clone())
258            }
259        }
260    }
261
262    /// Operator-facing summary explaining the explosion. Stable across
263    /// `Block` and `RequireApproval` variants so log scrapers can rely on it.
264    pub fn message(&self) -> String {
265        let mut breakdown = self
266            .top_contributors
267            .iter()
268            .map(|item| format!("{} ({})", item.relative_dir, item.count))
269            .collect::<Vec<_>>()
270            .join(", ");
271        if breakdown.is_empty() {
272            breakdown = "(no contributing directories)".to_string();
273        }
274        let level = match self.level {
275            PredicateCeilingLevel::RequireApproval => "soft",
276            PredicateCeilingLevel::Block => "hard",
277        };
278        format!(
279            "predicate union of {count} exceeds {level} ceiling {threshold}; \
280             top contributors: {breakdown}",
281            count = self.count,
282            level = level,
283            threshold = self.threshold,
284            breakdown = breakdown,
285        )
286    }
287}
288
289/// Outcome of running [`enforce_predicate_ceiling`].
290#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(tag = "status", rename_all = "snake_case")]
292pub enum PredicateCeilingOutcome {
293    /// Slice is within budget. Evaluation may proceed.
294    Within { count: usize },
295    /// Slice exceeded the soft or hard ceiling.
296    Exceeded(PredicateCeilingViolation),
297}
298
299impl PredicateCeilingOutcome {
300    pub fn count(&self) -> usize {
301        match self {
302            Self::Within { count } => *count,
303            Self::Exceeded(violation) => violation.count,
304        }
305    }
306
307    pub fn violation(&self) -> Option<&PredicateCeilingViolation> {
308        match self {
309            Self::Within { .. } => None,
310            Self::Exceeded(violation) => Some(violation),
311        }
312    }
313}
314
315/// Apply a [`PredicateCeiling`] to the resolved predicate union for a slice.
316///
317/// The check is purely a count comparison — it never inspects predicate
318/// bodies — and runs in O(n) over `resolved`. Pair it with
319/// [`resolve_predicates_for_touched_directories`] before invoking the
320/// executor; both operations preserve union semantics so shared ancestors are
321/// counted once and sibling-specific predicates are kept distinct.
322pub fn enforce_predicate_ceiling(
323    resolved: &[ResolvedPredicate],
324    ceiling: &PredicateCeiling,
325) -> PredicateCeilingOutcome {
326    let count = resolved.len();
327    let level = if ceiling.block_threshold > 0 && count >= ceiling.block_threshold {
328        Some((PredicateCeilingLevel::Block, ceiling.block_threshold))
329    } else if ceiling.require_approval_threshold > 0 && count >= ceiling.require_approval_threshold
330    {
331        Some((
332            PredicateCeilingLevel::RequireApproval,
333            ceiling.require_approval_threshold,
334        ))
335    } else {
336        None
337    };
338
339    let Some((level, threshold)) = level else {
340        return PredicateCeilingOutcome::Within { count };
341    };
342
343    PredicateCeilingOutcome::Exceeded(PredicateCeilingViolation {
344        level,
345        count,
346        threshold,
347        top_contributors: top_contributors(
348            resolved,
349            PredicateCeilingViolation::MAX_TOP_CONTRIBUTORS,
350        ),
351    })
352}
353
354fn top_contributors(resolved: &[ResolvedPredicate], limit: usize) -> Vec<DirectoryContribution> {
355    let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
356    for predicate in resolved {
357        *counts
358            .entry(predicate.source.relative_dir.as_str())
359            .or_insert(0) += 1;
360    }
361    let mut ranked = counts
362        .into_iter()
363        .map(|(dir, count)| DirectoryContribution {
364            relative_dir: dir.to_string(),
365            count,
366        })
367        .collect::<Vec<_>>();
368    // Higher counts first; break ties by lexicographic directory order so the
369    // output is deterministic regardless of input ordering.
370    ranked.sort_by(|left, right| {
371        right
372            .count
373            .cmp(&left.count)
374            .then_with(|| left.relative_dir.cmp(&right.relative_dir))
375    });
376    ranked.truncate(limit);
377    ranked
378}
379
380/// Compose evaluated predicate results under stricter-child / shallower-tie
381/// semantics.
382///
383/// For each evaluation, only same-name predicates from ancestor directories are
384/// eligible to govern it. The strictest verdict wins; equal strictness selects
385/// the shallower source. This prevents a leaf `Allow` from shadowing a repo-wide
386/// `Block`, while still letting a leaf `Block` tighten an ancestor `Warn`.
387pub fn compose_predicate_results(
388    evaluations: &[PredicateEvaluation],
389) -> Vec<ComposedPredicateEvaluation> {
390    let mut composed = Vec::with_capacity(evaluations.len());
391
392    for evaluation in evaluations {
393        let selected = evaluations
394            .iter()
395            .filter(|candidate| {
396                candidate.logical_name == evaluation.logical_name
397                    && candidate.source.is_ancestor_of_or_same(&evaluation.source)
398            })
399            .max_by(|left, right| compare_evaluations(left, right))
400            .unwrap_or(evaluation);
401
402        composed.push(ComposedPredicateEvaluation {
403            qualified_name: evaluation.qualified_name.clone(),
404            logical_name: evaluation.logical_name.clone(),
405            source: evaluation.source.clone(),
406            selected_qualified_name: selected.qualified_name.clone(),
407            selected_source: selected.source.clone(),
408            result: selected.result.clone(),
409        });
410    }
411
412    composed
413}
414
415pub fn verdict_strictness(verdict: &Verdict) -> VerdictStrictness {
416    match verdict {
417        Verdict::Allow => VerdictStrictness::Allow,
418        Verdict::Warn { .. } => VerdictStrictness::Warn,
419        Verdict::RequireApproval { .. } => VerdictStrictness::RequireApproval,
420        Verdict::Block { .. } => VerdictStrictness::Block,
421    }
422}
423
424fn compare_evaluations(
425    left: &PredicateEvaluation,
426    right: &PredicateEvaluation,
427) -> std::cmp::Ordering {
428    let left_strictness = verdict_strictness(&left.result.verdict);
429    let right_strictness = verdict_strictness(&right.result.verdict);
430    left_strictness
431        .cmp(&right_strictness)
432        // `max_by` keeps the greater value, so reverse depth for shallower ties.
433        .then_with(|| right.source.depth.cmp(&left.source.depth))
434        .then_with(|| right.qualified_name.cmp(&left.qualified_name))
435}
436
437fn qualified_name(relative_dir: &str, name: &str) -> String {
438    let relative_dir = normalize_relative_dir(relative_dir.to_string());
439    if relative_dir == "." {
440        name.to_string()
441    } else {
442        format!("{relative_dir}::{name}")
443    }
444}
445
446fn normalize_relative_dir(value: String) -> String {
447    let parts = value
448        .split('/')
449        .filter(|part| !part.is_empty() && *part != "." && *part != "..")
450        .collect::<Vec<_>>();
451    if parts.is_empty() {
452        ".".to_string()
453    } else {
454        parts.join("/")
455    }
456}
457
458fn directory_depth(relative_dir: &str) -> usize {
459    if relative_dir == "." {
460        0
461    } else {
462        relative_dir
463            .split('/')
464            .filter(|part| !part.is_empty())
465            .count()
466    }
467}
468
469fn is_ancestor_dir(ancestor: &str, descendant: &str) -> bool {
470    let ancestor = normalize_relative_dir(ancestor.to_string());
471    let descendant = normalize_relative_dir(descendant.to_string());
472    if ancestor == "." || ancestor == descendant {
473        return true;
474    }
475    descendant
476        .strip_prefix(&ancestor)
477        .is_some_and(|remaining| remaining.starts_with('/'))
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use crate::flow::{Approver, InvariantBlockError, PredicateHash, PredicateKind};
484    use harn_lexer::Span;
485    use std::path::PathBuf;
486
487    fn predicate(name: &str) -> DiscoveredPredicate {
488        DiscoveredPredicate {
489            name: name.to_string(),
490            kind: PredicateKind::Deterministic,
491            fallback: None,
492            fallback_policy: crate::flow::SemanticFallbackPolicy::Enforce,
493            archivist: None,
494            retroactive: false,
495            source_hash: PredicateHash::new(format!("sha256:{name}")),
496            span: Span::dummy(),
497        }
498    }
499
500    fn file(relative_dir: &str, names: &[&str]) -> DiscoveredInvariantFile {
501        DiscoveredInvariantFile {
502            path: PathBuf::from(relative_dir).join("invariants.harn"),
503            relative_dir: relative_dir.to_string(),
504            source: String::new(),
505            predicates: names.iter().map(|name| predicate(name)).collect(),
506            diagnostics: Vec::new(),
507        }
508    }
509
510    fn evaluation(
511        qualified_name: &str,
512        logical_name: &str,
513        relative_dir: &str,
514        result: InvariantResult,
515    ) -> PredicateEvaluation {
516        PredicateEvaluation {
517            qualified_name: qualified_name.to_string(),
518            logical_name: logical_name.to_string(),
519            source: PredicateSource::new(relative_dir),
520            result,
521        }
522    }
523
524    #[test]
525    fn resolve_predicates_keeps_ancestor_and_child_declarations() {
526        let resolved = resolve_predicates(&[file(".", &["shared"]), file("src", &["shared"])]);
527        let qualified = resolved
528            .iter()
529            .map(|predicate| predicate.qualified_name.as_str())
530            .collect::<Vec<_>>();
531        assert_eq!(qualified, vec!["shared", "src::shared"]);
532        assert_eq!(resolved[0].source.depth, 0);
533        assert_eq!(resolved[1].source.depth, 1);
534    }
535
536    #[test]
537    fn override_narrowing_allows_deeper_stricter_verdict() {
538        let evaluations = vec![
539            evaluation(
540                "security",
541                "security",
542                ".",
543                InvariantResult::warn("repo warning"),
544            ),
545            evaluation(
546                "src::security",
547                "security",
548                "src",
549                InvariantResult::block(InvariantBlockError::new(
550                    "leaf_policy",
551                    "leaf policy blocks this slice",
552                )),
553            ),
554        ];
555
556        let composed = compose_predicate_results(&evaluations);
557        let child = composed
558            .iter()
559            .find(|item| item.qualified_name == "src::security")
560            .unwrap();
561        assert_eq!(child.selected_qualified_name, "src::security");
562        assert_eq!(
563            verdict_strictness(&child.result.verdict),
564            VerdictStrictness::Block
565        );
566    }
567
568    #[test]
569    fn override_relaxing_keeps_shallower_block() {
570        let evaluations = vec![
571            evaluation(
572                "security",
573                "security",
574                ".",
575                InvariantResult::block(InvariantBlockError::new(
576                    "repo_policy",
577                    "repo policy blocks this slice",
578                )),
579            ),
580            evaluation("src::security", "security", "src", InvariantResult::allow()),
581        ];
582
583        let composed = compose_predicate_results(&evaluations);
584        let child = composed
585            .iter()
586            .find(|item| item.qualified_name == "src::security")
587            .unwrap();
588        assert_eq!(child.selected_qualified_name, "security");
589        assert_eq!(
590            verdict_strictness(&child.result.verdict),
591            VerdictStrictness::Block
592        );
593    }
594
595    #[test]
596    fn equal_strictness_ties_go_to_shallower_predicate() {
597        let evaluations = vec![
598            evaluation(
599                "review",
600                "review",
601                ".",
602                InvariantResult::require_approval(Approver::role("platform")),
603            ),
604            evaluation(
605                "src::review",
606                "review",
607                "src",
608                InvariantResult::require_approval(Approver::role("local")),
609            ),
610        ];
611
612        let composed = compose_predicate_results(&evaluations);
613        let child = composed
614            .iter()
615            .find(|item| item.qualified_name == "src::review")
616            .unwrap();
617        assert_eq!(child.selected_qualified_name, "review");
618    }
619
620    #[test]
621    fn cross_directory_union_deduplicates_shared_ancestors_only() {
622        let api_chain = vec![
623            file(".", &["repo"]),
624            file("services/api", &["api", "shared_name"]),
625        ];
626        let web_chain = vec![
627            file(".", &["repo"]),
628            file("services/web", &["web", "shared_name"]),
629        ];
630
631        let resolved = resolve_predicates_for_touched_directories(&[api_chain, web_chain]);
632        let qualified = resolved
633            .iter()
634            .map(|predicate| predicate.qualified_name.as_str())
635            .collect::<Vec<_>>();
636
637        assert_eq!(
638            qualified,
639            vec![
640                "repo",
641                "services/api::api",
642                "services/api::shared_name",
643                "services/web::web",
644                "services/web::shared_name"
645            ]
646        );
647    }
648
649    #[test]
650    fn sibling_same_name_predicates_do_not_shadow_each_other() {
651        let evaluations = vec![
652            evaluation(
653                "services/api::guard",
654                "guard",
655                "services/api",
656                InvariantResult::block(InvariantBlockError::new("api", "api blocked")),
657            ),
658            evaluation(
659                "services/web::guard",
660                "guard",
661                "services/web",
662                InvariantResult::allow(),
663            ),
664        ];
665
666        let composed = compose_predicate_results(&evaluations);
667        let web = composed
668            .iter()
669            .find(|item| item.qualified_name == "services/web::guard")
670            .unwrap();
671        assert_eq!(web.selected_qualified_name, "services/web::guard");
672        assert_eq!(
673            verdict_strictness(&web.result.verdict),
674            VerdictStrictness::Allow
675        );
676    }
677
678    fn predicate_in(relative_dir: &str, name: &str, source_order: usize) -> ResolvedPredicate {
679        ResolvedPredicate {
680            qualified_name: qualified_name(relative_dir, name),
681            logical_name: name.to_string(),
682            source: PredicateSource::new(relative_dir),
683            source_order,
684            fallback_hash: None,
685            predicate: predicate(name),
686        }
687    }
688
689    fn synthetic_union(rules_per_dir: usize, dirs: &[&str]) -> Vec<ResolvedPredicate> {
690        let mut order = 0;
691        let mut resolved = Vec::with_capacity(dirs.len() * rules_per_dir);
692        for dir in dirs {
693            for index in 0..rules_per_dir {
694                resolved.push(predicate_in(dir, &format!("rule_{index}"), order));
695                order += 1;
696            }
697        }
698        resolved
699    }
700
701    #[test]
702    fn enforce_returns_within_when_under_thresholds() {
703        let resolved = synthetic_union(4, &["a", "b", "c"]);
704        let outcome = enforce_predicate_ceiling(&resolved, &PredicateCeiling::default());
705        assert!(matches!(outcome, PredicateCeilingOutcome::Within { count } if count == 12));
706    }
707
708    #[test]
709    fn enforce_emits_require_approval_at_soft_ceiling() {
710        let resolved = synthetic_union(8, &["a", "b", "c"]);
711        let ceiling = PredicateCeiling {
712            require_approval_threshold: 16,
713            block_threshold: 64,
714            approver: Approver::role("flow-platform"),
715        };
716        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
717        let violation = match outcome {
718            PredicateCeilingOutcome::Exceeded(violation) => violation,
719            other => panic!("expected Exceeded, got {other:?}"),
720        };
721        assert_eq!(violation.level, PredicateCeilingLevel::RequireApproval);
722        assert_eq!(violation.threshold, 16);
723        assert_eq!(violation.count, 24);
724        let result = violation.to_invariant_result(&ceiling.approver);
725        assert!(matches!(
726            result.verdict,
727            Verdict::RequireApproval {
728                approver: Approver::Role { ref name }
729            } if name == "flow-platform"
730        ));
731    }
732
733    #[test]
734    fn enforce_emits_block_at_hard_ceiling() {
735        let resolved = synthetic_union(40, &["a", "b", "c"]);
736        let ceiling = PredicateCeiling {
737            require_approval_threshold: 16,
738            block_threshold: 64,
739            approver: Approver::role("flow-platform"),
740        };
741        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
742        let violation = match outcome {
743            PredicateCeilingOutcome::Exceeded(violation) => violation,
744            other => panic!("expected Exceeded, got {other:?}"),
745        };
746        assert_eq!(violation.level, PredicateCeilingLevel::Block);
747        assert_eq!(violation.threshold, 64);
748        assert_eq!(violation.count, 120);
749        let result = violation.to_invariant_result(&ceiling.approver);
750        let error = result.block_error().expect("block carries error");
751        assert_eq!(error.code, PREDICATE_COUNT_EXPLOSION_CODE);
752        assert!(error.message.contains("hard ceiling"));
753        assert!(error.message.contains("120"));
754    }
755
756    #[test]
757    fn enforce_lists_top_contributors_in_descending_order() {
758        let mut resolved = synthetic_union(6, &["alpha", "bravo"]);
759        resolved.extend(synthetic_union(2, &["charlie", "delta"]));
760        let ceiling = PredicateCeiling {
761            require_approval_threshold: 8,
762            block_threshold: 32,
763            approver: Approver::role("flow-platform"),
764        };
765        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
766        let violation = outcome
767            .violation()
768            .cloned()
769            .expect("expected explosion outcome");
770        let dirs: Vec<_> = violation
771            .top_contributors
772            .iter()
773            .map(|item| (item.relative_dir.as_str(), item.count))
774            .collect();
775        assert_eq!(
776            dirs,
777            vec![("alpha", 6), ("bravo", 6), ("charlie", 2), ("delta", 2),]
778        );
779    }
780
781    #[test]
782    fn enforce_truncates_top_contributors_to_max() {
783        let dirs: Vec<String> = (0..PredicateCeilingViolation::MAX_TOP_CONTRIBUTORS + 3)
784            .map(|index| format!("d{index:02}"))
785            .collect();
786        let dir_refs: Vec<&str> = dirs.iter().map(String::as_str).collect();
787        let resolved = synthetic_union(4, &dir_refs);
788        let ceiling = PredicateCeiling {
789            require_approval_threshold: 4,
790            block_threshold: 9999,
791            approver: Approver::role("flow-platform"),
792        };
793        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
794        let violation = outcome
795            .violation()
796            .cloned()
797            .expect("expected explosion outcome");
798        assert_eq!(
799            violation.top_contributors.len(),
800            PredicateCeilingViolation::MAX_TOP_CONTRIBUTORS
801        );
802    }
803
804    #[test]
805    fn enforce_zero_threshold_disables_a_level() {
806        let resolved = synthetic_union(8, &["a"]);
807        let ceiling = PredicateCeiling {
808            require_approval_threshold: 0,
809            block_threshold: 4,
810            approver: Approver::role("flow-platform"),
811        };
812        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
813        let violation = outcome
814            .violation()
815            .cloned()
816            .expect("hard ceiling alone should still trigger");
817        assert_eq!(violation.level, PredicateCeilingLevel::Block);
818
819        let ceiling = PredicateCeiling {
820            require_approval_threshold: 4,
821            block_threshold: 0,
822            approver: Approver::role("flow-platform"),
823        };
824        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
825        let violation = outcome
826            .violation()
827            .cloned()
828            .expect("soft ceiling alone should still trigger");
829        assert_eq!(violation.level, PredicateCeilingLevel::RequireApproval);
830    }
831
832    #[test]
833    fn enforce_uses_hard_ceiling_when_both_thresholds_match() {
834        let resolved = synthetic_union(64, &["a"]);
835        let ceiling = PredicateCeiling {
836            require_approval_threshold: 64,
837            block_threshold: 64,
838            approver: Approver::role("flow-platform"),
839        };
840        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
841        let violation = outcome.violation().cloned().expect("expected explosion");
842        // Block must take precedence so a misconfigured equal pair still
843        // refuses the slice rather than asking for a co-sign.
844        assert_eq!(violation.level, PredicateCeilingLevel::Block);
845    }
846
847    #[test]
848    fn cross_directory_union_with_ceiling_blocks_when_pathological() {
849        let chains: Vec<Vec<DiscoveredInvariantFile>> = (0..32)
850            .map(|index| {
851                let dir = format!("services/svc_{index:02}");
852                let names: Vec<String> = (0..40).map(|rule| format!("rule_{rule:02}")).collect();
853                let name_refs: Vec<&str> = names.iter().map(String::as_str).collect();
854                vec![file(".", &["repo"]), file(&dir, &name_refs)]
855            })
856            .collect();
857        let resolved = resolve_predicates_for_touched_directories(&chains);
858        // 1 shared ancestor + 32 dirs × 40 rules each = 1281 predicates total.
859        assert_eq!(resolved.len(), 1281);
860        let outcome = enforce_predicate_ceiling(&resolved, &PredicateCeiling::default());
861        let violation = outcome.violation().cloned().expect("union should explode");
862        assert_eq!(violation.level, PredicateCeilingLevel::Block);
863        // Top contributors should all be sibling services, not the root.
864        for contribution in &violation.top_contributors {
865            assert!(contribution.relative_dir.starts_with("services/svc_"));
866            assert_eq!(contribution.count, 40);
867        }
868    }
869}