Skip to main content

libverify_core/controls/
review_independence.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{
3    ApprovalDisposition, EvidenceBundle, EvidenceGap, EvidenceState, GovernedChange,
4};
5use crate::integrity::is_approver_independent;
6
7/// Verifies that at least one approver is independent from the change author and requester.
8pub struct ReviewIndependenceControl;
9
10impl Control for ReviewIndependenceControl {
11    fn id(&self) -> ControlId {
12        builtin::id(builtin::REVIEW_INDEPENDENCE)
13    }
14
15    fn description(&self) -> &'static str {
16        "Four-eyes: approver must differ from author"
17    }
18
19    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
20        if evidence.change_requests.is_empty() {
21            return vec![ControlFinding::not_applicable(
22                self.id(),
23                "No governed changes were supplied",
24            )];
25        }
26
27        evidence
28            .change_requests
29            .iter()
30            .map(evaluate_change)
31            .collect()
32    }
33}
34
35fn evaluate_change(change: &GovernedChange) -> ControlFinding {
36    let id = builtin::id(builtin::REVIEW_INDEPENDENCE);
37    let subject = change.id.to_string();
38
39    // Bot-submitted PRs (bors rollups, mergify merges) aggregate
40    // already-reviewed changes. Review independence was verified
41    // on the constituent PRs, not on the merge PR itself.
42    if change.is_bot_submitted() {
43        return ControlFinding::not_applicable(
44            id,
45            format!(
46                "{subject}: bot-submitted change ({}); review verified on constituent PRs",
47                change.submitted_by.as_deref().unwrap_or("unknown")
48            ),
49        );
50    }
51
52    let mut gaps = collect_gaps(&change.approval_decisions);
53    gaps.extend(collect_gaps(&change.source_revisions));
54
55    let approvals = match change.approval_decisions.value() {
56        Some(approvals) => approvals,
57        None => {
58            return ControlFinding::indeterminate(
59                id,
60                "Approval evidence is unavailable",
61                vec![subject],
62                gaps,
63            );
64        }
65    };
66
67    let revisions = match change.source_revisions.value() {
68        Some(revisions) => revisions,
69        None => {
70            return ControlFinding::indeterminate(
71                id,
72                "Source revision evidence is unavailable",
73                vec![subject],
74                gaps,
75            );
76        }
77    };
78
79    let mut authors: Vec<&str> = revisions
80        .iter()
81        .filter_map(|revision| revision.authored_by.as_deref())
82        .collect();
83    authors.sort_unstable();
84    authors.dedup();
85
86    if change.submitted_by.is_none() {
87        gaps.push(EvidenceGap::MissingField {
88            source: "control-normalization".to_string(),
89            subject: subject.clone(),
90            field: "submitted_by".to_string(),
91        });
92    }
93
94    if authors.is_empty() {
95        gaps.push(EvidenceGap::MissingField {
96            source: "control-normalization".to_string(),
97            subject: subject.clone(),
98            field: "source_revisions.authored_by".to_string(),
99        });
100    }
101
102    if !gaps.is_empty() {
103        return ControlFinding::indeterminate(
104            id,
105            "Independent review cannot be proven from partial evidence",
106            vec![subject],
107            gaps,
108        );
109    }
110
111    let requester = change
112        .submitted_by
113        .as_deref()
114        .expect("submitted_by guaranteed Some: early return on missing field");
115    let has_independent_approval = approvals.iter().any(|approval| {
116        if approval.disposition != ApprovalDisposition::Approved {
117            return false;
118        }
119        let is_commit_author = authors.contains(&approval.actor.as_str());
120        let is_pr_author = approval.actor == requester;
121        is_approver_independent(is_commit_author, is_pr_author)
122    });
123
124    if has_independent_approval {
125        ControlFinding::satisfied(
126            id,
127            "At least one approver is independent from both author and requester",
128            vec![subject],
129        )
130    } else {
131        ControlFinding::violated(
132            id,
133            "No independent approver was found for the change request",
134            vec![subject],
135        )
136    }
137}
138
139fn collect_gaps<T>(state: &EvidenceState<T>) -> Vec<EvidenceGap> {
140    state.gaps().to_vec()
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::evidence::{
147        ApprovalDecision, AuthenticityEvidence, ChangeRequestId, EvidenceBundle, SourceRevision,
148    };
149
150    fn make_change() -> GovernedChange {
151        GovernedChange {
152            id: ChangeRequestId::new("test", "owner/repo#1"),
153            title: "feat: add evidence layer".to_string(),
154            summary: None,
155            submitted_by: Some("author".to_string()),
156            changed_assets: EvidenceState::complete(vec![]),
157            approval_decisions: EvidenceState::complete(vec![ApprovalDecision {
158                actor: "reviewer".to_string(),
159                disposition: ApprovalDisposition::Approved,
160                submitted_at: Some("2026-03-15T00:00:00Z".to_string()),
161            }]),
162            source_revisions: EvidenceState::complete(vec![SourceRevision {
163                id: "abc123".to_string(),
164                authored_by: Some("author".to_string()),
165                committed_at: Some("2026-03-14T00:00:00Z".to_string()),
166                merge: false,
167                authenticity: EvidenceState::complete(AuthenticityEvidence::new(
168                    true,
169                    Some("gpg".to_string()),
170                )),
171            }]),
172            work_item_refs: EvidenceState::complete(vec![]),
173        }
174    }
175
176    #[test]
177    fn independent_approval_is_satisfied() {
178        let finding = evaluate_change(&make_change());
179        assert_eq!(finding.status, crate::control::ControlStatus::Satisfied);
180    }
181
182    #[test]
183    fn self_approval_is_violated() {
184        let mut change = make_change();
185        change.approval_decisions = EvidenceState::complete(vec![ApprovalDecision {
186            actor: "author".to_string(),
187            disposition: ApprovalDisposition::Approved,
188            submitted_at: None,
189        }]);
190
191        let finding = evaluate_change(&change);
192        assert_eq!(finding.status, crate::control::ControlStatus::Violated);
193    }
194
195    #[test]
196    fn missing_authorship_is_indeterminate() {
197        let mut change = make_change();
198        change.source_revisions = EvidenceState::partial(
199            vec![SourceRevision {
200                id: "abc123".to_string(),
201                authored_by: None,
202                committed_at: Some("2026-03-14T00:00:00Z".to_string()),
203                merge: false,
204                authenticity: EvidenceState::not_applicable(),
205            }],
206            vec![EvidenceGap::Unsupported {
207                source: "github".to_string(),
208                capability: "author login unavailable for change request commit evidence"
209                    .to_string(),
210            }],
211        );
212
213        let findings = ReviewIndependenceControl.evaluate(&EvidenceBundle {
214            change_requests: vec![change],
215            promotion_batches: vec![],
216            ..Default::default()
217        });
218
219        assert_eq!(
220            findings[0].status,
221            crate::control::ControlStatus::Indeterminate
222        );
223    }
224}