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            archivist: None,
493            retroactive: false,
494            source_hash: PredicateHash::new(format!("sha256:{name}")),
495            span: Span::dummy(),
496        }
497    }
498
499    fn file(relative_dir: &str, names: &[&str]) -> DiscoveredInvariantFile {
500        DiscoveredInvariantFile {
501            path: PathBuf::from(relative_dir).join("invariants.harn"),
502            relative_dir: relative_dir.to_string(),
503            source: String::new(),
504            predicates: names.iter().map(|name| predicate(name)).collect(),
505            diagnostics: Vec::new(),
506        }
507    }
508
509    fn evaluation(
510        qualified_name: &str,
511        logical_name: &str,
512        relative_dir: &str,
513        result: InvariantResult,
514    ) -> PredicateEvaluation {
515        PredicateEvaluation {
516            qualified_name: qualified_name.to_string(),
517            logical_name: logical_name.to_string(),
518            source: PredicateSource::new(relative_dir),
519            result,
520        }
521    }
522
523    #[test]
524    fn resolve_predicates_keeps_ancestor_and_child_declarations() {
525        let resolved = resolve_predicates(&[file(".", &["shared"]), file("src", &["shared"])]);
526        let qualified = resolved
527            .iter()
528            .map(|predicate| predicate.qualified_name.as_str())
529            .collect::<Vec<_>>();
530        assert_eq!(qualified, vec!["shared", "src::shared"]);
531        assert_eq!(resolved[0].source.depth, 0);
532        assert_eq!(resolved[1].source.depth, 1);
533    }
534
535    #[test]
536    fn override_narrowing_allows_deeper_stricter_verdict() {
537        let evaluations = vec![
538            evaluation(
539                "security",
540                "security",
541                ".",
542                InvariantResult::warn("repo warning"),
543            ),
544            evaluation(
545                "src::security",
546                "security",
547                "src",
548                InvariantResult::block(InvariantBlockError::new(
549                    "leaf_policy",
550                    "leaf policy blocks this slice",
551                )),
552            ),
553        ];
554
555        let composed = compose_predicate_results(&evaluations);
556        let child = composed
557            .iter()
558            .find(|item| item.qualified_name == "src::security")
559            .unwrap();
560        assert_eq!(child.selected_qualified_name, "src::security");
561        assert_eq!(
562            verdict_strictness(&child.result.verdict),
563            VerdictStrictness::Block
564        );
565    }
566
567    #[test]
568    fn override_relaxing_keeps_shallower_block() {
569        let evaluations = vec![
570            evaluation(
571                "security",
572                "security",
573                ".",
574                InvariantResult::block(InvariantBlockError::new(
575                    "repo_policy",
576                    "repo policy blocks this slice",
577                )),
578            ),
579            evaluation("src::security", "security", "src", InvariantResult::allow()),
580        ];
581
582        let composed = compose_predicate_results(&evaluations);
583        let child = composed
584            .iter()
585            .find(|item| item.qualified_name == "src::security")
586            .unwrap();
587        assert_eq!(child.selected_qualified_name, "security");
588        assert_eq!(
589            verdict_strictness(&child.result.verdict),
590            VerdictStrictness::Block
591        );
592    }
593
594    #[test]
595    fn equal_strictness_ties_go_to_shallower_predicate() {
596        let evaluations = vec![
597            evaluation(
598                "review",
599                "review",
600                ".",
601                InvariantResult::require_approval(Approver::role("platform")),
602            ),
603            evaluation(
604                "src::review",
605                "review",
606                "src",
607                InvariantResult::require_approval(Approver::role("local")),
608            ),
609        ];
610
611        let composed = compose_predicate_results(&evaluations);
612        let child = composed
613            .iter()
614            .find(|item| item.qualified_name == "src::review")
615            .unwrap();
616        assert_eq!(child.selected_qualified_name, "review");
617    }
618
619    #[test]
620    fn cross_directory_union_deduplicates_shared_ancestors_only() {
621        let api_chain = vec![
622            file(".", &["repo"]),
623            file("services/api", &["api", "shared_name"]),
624        ];
625        let web_chain = vec![
626            file(".", &["repo"]),
627            file("services/web", &["web", "shared_name"]),
628        ];
629
630        let resolved = resolve_predicates_for_touched_directories(&[api_chain, web_chain]);
631        let qualified = resolved
632            .iter()
633            .map(|predicate| predicate.qualified_name.as_str())
634            .collect::<Vec<_>>();
635
636        assert_eq!(
637            qualified,
638            vec![
639                "repo",
640                "services/api::api",
641                "services/api::shared_name",
642                "services/web::web",
643                "services/web::shared_name"
644            ]
645        );
646    }
647
648    #[test]
649    fn sibling_same_name_predicates_do_not_shadow_each_other() {
650        let evaluations = vec![
651            evaluation(
652                "services/api::guard",
653                "guard",
654                "services/api",
655                InvariantResult::block(InvariantBlockError::new("api", "api blocked")),
656            ),
657            evaluation(
658                "services/web::guard",
659                "guard",
660                "services/web",
661                InvariantResult::allow(),
662            ),
663        ];
664
665        let composed = compose_predicate_results(&evaluations);
666        let web = composed
667            .iter()
668            .find(|item| item.qualified_name == "services/web::guard")
669            .unwrap();
670        assert_eq!(web.selected_qualified_name, "services/web::guard");
671        assert_eq!(
672            verdict_strictness(&web.result.verdict),
673            VerdictStrictness::Allow
674        );
675    }
676
677    fn predicate_in(relative_dir: &str, name: &str, source_order: usize) -> ResolvedPredicate {
678        ResolvedPredicate {
679            qualified_name: qualified_name(relative_dir, name),
680            logical_name: name.to_string(),
681            source: PredicateSource::new(relative_dir),
682            source_order,
683            fallback_hash: None,
684            predicate: predicate(name),
685        }
686    }
687
688    fn synthetic_union(rules_per_dir: usize, dirs: &[&str]) -> Vec<ResolvedPredicate> {
689        let mut order = 0;
690        let mut resolved = Vec::with_capacity(dirs.len() * rules_per_dir);
691        for dir in dirs {
692            for index in 0..rules_per_dir {
693                resolved.push(predicate_in(dir, &format!("rule_{index}"), order));
694                order += 1;
695            }
696        }
697        resolved
698    }
699
700    #[test]
701    fn enforce_returns_within_when_under_thresholds() {
702        let resolved = synthetic_union(4, &["a", "b", "c"]);
703        let outcome = enforce_predicate_ceiling(&resolved, &PredicateCeiling::default());
704        assert!(matches!(outcome, PredicateCeilingOutcome::Within { count } if count == 12));
705    }
706
707    #[test]
708    fn enforce_emits_require_approval_at_soft_ceiling() {
709        let resolved = synthetic_union(8, &["a", "b", "c"]);
710        let ceiling = PredicateCeiling {
711            require_approval_threshold: 16,
712            block_threshold: 64,
713            approver: Approver::role("flow-platform"),
714        };
715        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
716        let violation = match outcome {
717            PredicateCeilingOutcome::Exceeded(violation) => violation,
718            other => panic!("expected Exceeded, got {other:?}"),
719        };
720        assert_eq!(violation.level, PredicateCeilingLevel::RequireApproval);
721        assert_eq!(violation.threshold, 16);
722        assert_eq!(violation.count, 24);
723        let result = violation.to_invariant_result(&ceiling.approver);
724        assert!(matches!(
725            result.verdict,
726            Verdict::RequireApproval {
727                approver: Approver::Role { ref name }
728            } if name == "flow-platform"
729        ));
730    }
731
732    #[test]
733    fn enforce_emits_block_at_hard_ceiling() {
734        let resolved = synthetic_union(40, &["a", "b", "c"]);
735        let ceiling = PredicateCeiling {
736            require_approval_threshold: 16,
737            block_threshold: 64,
738            approver: Approver::role("flow-platform"),
739        };
740        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
741        let violation = match outcome {
742            PredicateCeilingOutcome::Exceeded(violation) => violation,
743            other => panic!("expected Exceeded, got {other:?}"),
744        };
745        assert_eq!(violation.level, PredicateCeilingLevel::Block);
746        assert_eq!(violation.threshold, 64);
747        assert_eq!(violation.count, 120);
748        let result = violation.to_invariant_result(&ceiling.approver);
749        let error = result.block_error().expect("block carries error");
750        assert_eq!(error.code, PREDICATE_COUNT_EXPLOSION_CODE);
751        assert!(error.message.contains("hard ceiling"));
752        assert!(error.message.contains("120"));
753    }
754
755    #[test]
756    fn enforce_lists_top_contributors_in_descending_order() {
757        let mut resolved = synthetic_union(6, &["alpha", "bravo"]);
758        resolved.extend(synthetic_union(2, &["charlie", "delta"]));
759        let ceiling = PredicateCeiling {
760            require_approval_threshold: 8,
761            block_threshold: 32,
762            approver: Approver::role("flow-platform"),
763        };
764        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
765        let violation = outcome
766            .violation()
767            .cloned()
768            .expect("expected explosion outcome");
769        let dirs: Vec<_> = violation
770            .top_contributors
771            .iter()
772            .map(|item| (item.relative_dir.as_str(), item.count))
773            .collect();
774        assert_eq!(
775            dirs,
776            vec![("alpha", 6), ("bravo", 6), ("charlie", 2), ("delta", 2),]
777        );
778    }
779
780    #[test]
781    fn enforce_truncates_top_contributors_to_max() {
782        let dirs: Vec<String> = (0..PredicateCeilingViolation::MAX_TOP_CONTRIBUTORS + 3)
783            .map(|index| format!("d{index:02}"))
784            .collect();
785        let dir_refs: Vec<&str> = dirs.iter().map(String::as_str).collect();
786        let resolved = synthetic_union(4, &dir_refs);
787        let ceiling = PredicateCeiling {
788            require_approval_threshold: 4,
789            block_threshold: 9999,
790            approver: Approver::role("flow-platform"),
791        };
792        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
793        let violation = outcome
794            .violation()
795            .cloned()
796            .expect("expected explosion outcome");
797        assert_eq!(
798            violation.top_contributors.len(),
799            PredicateCeilingViolation::MAX_TOP_CONTRIBUTORS
800        );
801    }
802
803    #[test]
804    fn enforce_zero_threshold_disables_a_level() {
805        let resolved = synthetic_union(8, &["a"]);
806        let ceiling = PredicateCeiling {
807            require_approval_threshold: 0,
808            block_threshold: 4,
809            approver: Approver::role("flow-platform"),
810        };
811        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
812        let violation = outcome
813            .violation()
814            .cloned()
815            .expect("hard ceiling alone should still trigger");
816        assert_eq!(violation.level, PredicateCeilingLevel::Block);
817
818        let ceiling = PredicateCeiling {
819            require_approval_threshold: 4,
820            block_threshold: 0,
821            approver: Approver::role("flow-platform"),
822        };
823        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
824        let violation = outcome
825            .violation()
826            .cloned()
827            .expect("soft ceiling alone should still trigger");
828        assert_eq!(violation.level, PredicateCeilingLevel::RequireApproval);
829    }
830
831    #[test]
832    fn enforce_uses_hard_ceiling_when_both_thresholds_match() {
833        let resolved = synthetic_union(64, &["a"]);
834        let ceiling = PredicateCeiling {
835            require_approval_threshold: 64,
836            block_threshold: 64,
837            approver: Approver::role("flow-platform"),
838        };
839        let outcome = enforce_predicate_ceiling(&resolved, &ceiling);
840        let violation = outcome.violation().cloned().expect("expected explosion");
841        // Block must take precedence so a misconfigured equal pair still
842        // refuses the slice rather than asking for a co-sign.
843        assert_eq!(violation.level, PredicateCeilingLevel::Block);
844    }
845
846    #[test]
847    fn cross_directory_union_with_ceiling_blocks_when_pathological() {
848        let chains: Vec<Vec<DiscoveredInvariantFile>> = (0..32)
849            .map(|index| {
850                let dir = format!("services/svc_{index:02}");
851                let names: Vec<String> = (0..40).map(|rule| format!("rule_{rule:02}")).collect();
852                let name_refs: Vec<&str> = names.iter().map(String::as_str).collect();
853                vec![file(".", &["repo"]), file(&dir, &name_refs)]
854            })
855            .collect();
856        let resolved = resolve_predicates_for_touched_directories(&chains);
857        // 1 shared ancestor + 32 dirs × 40 rules each = 1281 predicates total.
858        assert_eq!(resolved.len(), 1281);
859        let outcome = enforce_predicate_ceiling(&resolved, &PredicateCeiling::default());
860        let violation = outcome.violation().cloned().expect("union should explode");
861        assert_eq!(violation.level, PredicateCeilingLevel::Block);
862        // Top contributors should all be sibling services, not the root.
863        for contribution in &violation.top_contributors {
864            assert!(contribution.relative_dir.starts_with("services/svc_"));
865            assert_eq!(contribution.count, 40);
866        }
867    }
868}