libverify_core/controls/
review_independence.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{
3 ApprovalDisposition, EvidenceBundle, EvidenceGap, EvidenceState, GovernedChange,
4};
5use crate::integrity::is_approver_independent;
6
7pub 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 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}