1use 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
15pub const PREDICATE_COUNT_EXPLOSION_CODE: &str = "predicate_count_explosion";
17
18#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
20pub struct PredicateSource {
21 pub relative_dir: String,
24 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#[derive(Clone, Debug)]
45pub struct ResolvedPredicate {
46 pub qualified_name: String,
48 pub logical_name: String,
50 pub source: PredicateSource,
51 pub source_order: usize,
53 pub fallback_hash: Option<PredicateHash>,
55 pub predicate: DiscoveredPredicate,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
60pub enum VerdictStrictness {
61 Allow,
62 Warn,
63 RequireApproval,
64 Block,
65}
66
67#[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#[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 pub selected_qualified_name: String,
96 pub selected_source: PredicateSource,
97 pub result: InvariantResult,
98}
99
100pub 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
134pub 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#[derive(Clone, Debug)]
186pub struct PredicateCeiling {
187 pub require_approval_threshold: usize,
188 pub block_threshold: usize,
189 pub approver: Approver,
191}
192
193impl PredicateCeiling {
194 pub const DEFAULT_REQUIRE_APPROVAL_THRESHOLD: usize = 256;
197 pub const DEFAULT_BLOCK_THRESHOLD: usize = 1024;
200 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
216pub struct DirectoryContribution {
217 pub relative_dir: String,
218 pub count: usize,
219}
220
221#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum PredicateCeilingLevel {
225 RequireApproval,
226 Block,
227}
228
229#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
233pub struct PredicateCeilingViolation {
234 pub level: PredicateCeilingLevel,
235 pub count: usize,
236 pub threshold: usize,
237 pub top_contributors: Vec<DirectoryContribution>,
240}
241
242impl PredicateCeilingViolation {
243 pub const MAX_TOP_CONTRIBUTORS: usize = 5;
246
247 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 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(tag = "status", rename_all = "snake_case")]
292pub enum PredicateCeilingOutcome {
293 Within { count: usize },
295 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
315pub 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 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
380pub 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 .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 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 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 for contribution in &violation.top_contributors {
864 assert!(contribution.relative_dir.starts_with("services/svc_"));
865 assert_eq!(contribution.count, 40);
866 }
867 }
868}