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 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 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 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 for contribution in &violation.top_contributors {
865 assert!(contribution.relative_dir.starts_with("services/svc_"));
866 assert_eq!(contribution.count, 40);
867 }
868 }
869}