1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{
3 ApprovalDisposition, EvidenceBundle, EvidenceGap, EvidenceState, GovernedChange,
4};
5use crate::integrity::{is_approver_independent, two_party_review_severity};
6use crate::verdict::Severity;
7
8pub struct TwoPartyReviewControl;
10
11impl Control for TwoPartyReviewControl {
12 fn id(&self) -> ControlId {
13 builtin::id(builtin::TWO_PARTY_REVIEW)
14 }
15
16 fn description(&self) -> &'static str {
17 "At least two independent reviewers must approve changes"
18 }
19
20 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
21 if evidence.change_requests.is_empty() {
22 return vec![ControlFinding::not_applicable(
23 self.id(),
24 "No governed changes were supplied",
25 )];
26 }
27
28 evidence
29 .change_requests
30 .iter()
31 .map(evaluate_change)
32 .collect()
33 }
34}
35
36fn evaluate_change(change: &GovernedChange) -> ControlFinding {
37 let subject = change.id.to_string();
38
39 if change.is_bot_submitted() {
40 return ControlFinding::not_applicable(
41 builtin::id(builtin::TWO_PARTY_REVIEW),
42 format!("{subject}: bot-submitted change; review verified on constituent PRs"),
43 );
44 }
45
46 let mut gaps = collect_gaps(&change.approval_decisions);
47 gaps.extend(collect_gaps(&change.source_revisions));
48
49 let approvals = match change.approval_decisions.value() {
50 Some(approvals) => approvals,
51 None => {
52 return ControlFinding::indeterminate(
53 builtin::id(builtin::TWO_PARTY_REVIEW),
54 "Approval evidence is unavailable",
55 vec![subject],
56 gaps,
57 );
58 }
59 };
60
61 let revisions = match change.source_revisions.value() {
62 Some(revisions) => revisions,
63 None => {
64 return ControlFinding::indeterminate(
65 builtin::id(builtin::TWO_PARTY_REVIEW),
66 "Source revision evidence is unavailable",
67 vec![subject],
68 gaps,
69 );
70 }
71 };
72
73 let mut authors: Vec<&str> = revisions
74 .iter()
75 .filter_map(|revision| revision.authored_by.as_deref())
76 .collect();
77 authors.sort_unstable();
78 authors.dedup();
79
80 if change.submitted_by.is_none() {
81 gaps.push(EvidenceGap::MissingField {
82 source: "control-normalization".to_string(),
83 subject: subject.clone(),
84 field: "submitted_by".to_string(),
85 });
86 }
87
88 if authors.is_empty() {
89 gaps.push(EvidenceGap::MissingField {
90 source: "control-normalization".to_string(),
91 subject: subject.clone(),
92 field: "source_revisions.authored_by".to_string(),
93 });
94 }
95
96 if !gaps.is_empty() {
97 return ControlFinding::indeterminate(
98 builtin::id(builtin::TWO_PARTY_REVIEW),
99 "Two-party review cannot be proven from partial evidence",
100 vec![subject],
101 gaps,
102 );
103 }
104
105 let requester = change
106 .submitted_by
107 .as_deref()
108 .expect("submitted_by guaranteed Some: early return on missing field");
109
110 let independent_count = approvals
111 .iter()
112 .filter(|approval| {
113 if approval.disposition != ApprovalDisposition::Approved {
114 return false;
115 }
116 let is_commit_author = authors.contains(&approval.actor.as_str());
117 let is_pr_author = approval.actor == requester;
118 is_approver_independent(is_commit_author, is_pr_author)
119 })
120 .count();
121
122 match two_party_review_severity(independent_count) {
123 Severity::Pass => ControlFinding::satisfied(
124 builtin::id(builtin::TWO_PARTY_REVIEW),
125 format!("{independent_count} independent approver(s) found (>= 2 required)"),
126 vec![subject],
127 ),
128 _ => ControlFinding::violated(
129 builtin::id(builtin::TWO_PARTY_REVIEW),
130 format!("Only {independent_count} independent approver(s) found; at least 2 required"),
131 vec![subject],
132 ),
133 }
134}
135
136fn collect_gaps<T>(state: &EvidenceState<T>) -> Vec<EvidenceGap> {
137 state.gaps().to_vec()
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::control::ControlStatus;
144 use crate::evidence::{
145 ApprovalDecision, AuthenticityEvidence, ChangeRequestId, EvidenceBundle, SourceRevision,
146 };
147
148 fn make_change(approvers: Vec<&str>) -> GovernedChange {
149 let decisions = approvers
150 .into_iter()
151 .map(|actor| ApprovalDecision {
152 actor: actor.to_string(),
153 disposition: ApprovalDisposition::Approved,
154 submitted_at: Some("2026-03-15T00:00:00Z".to_string()),
155 })
156 .collect();
157
158 GovernedChange {
159 id: ChangeRequestId::new("test", "owner/repo#1"),
160 title: "feat: add new control".to_string(),
161 summary: None,
162 submitted_by: Some("author".to_string()),
163 changed_assets: EvidenceState::complete(vec![]),
164 approval_decisions: EvidenceState::complete(decisions),
165 source_revisions: EvidenceState::complete(vec![SourceRevision {
166 id: "abc123".to_string(),
167 authored_by: Some("author".to_string()),
168 committed_at: Some("2026-03-14T00:00:00Z".to_string()),
169 merge: false,
170 authenticity: EvidenceState::complete(AuthenticityEvidence::new(
171 true,
172 Some("gpg".to_string()),
173 )),
174 }]),
175 work_item_refs: EvidenceState::complete(vec![]),
176 }
177 }
178
179 fn bundle(change: GovernedChange) -> EvidenceBundle {
180 EvidenceBundle {
181 change_requests: vec![change],
182 ..Default::default()
183 }
184 }
185
186 #[test]
187 fn satisfied_with_two_independent_approvers() {
188 let change = make_change(vec!["reviewer-a", "reviewer-b"]);
189 let findings = TwoPartyReviewControl.evaluate(&bundle(change));
190 assert_eq!(findings.len(), 1);
191 assert_eq!(findings[0].status, ControlStatus::Satisfied);
192 assert!(findings[0].rationale.contains("2"));
193 }
194
195 #[test]
196 fn violated_with_only_one_independent_approver() {
197 let change = make_change(vec!["reviewer-a"]);
198 let findings = TwoPartyReviewControl.evaluate(&bundle(change));
199 assert_eq!(findings.len(), 1);
200 assert_eq!(findings[0].status, ControlStatus::Violated);
201 assert!(findings[0].rationale.contains("1"));
202 }
203
204 #[test]
205 fn violated_when_self_approval_reduces_count() {
206 let change = make_change(vec!["author", "reviewer-a"]);
208 let findings = TwoPartyReviewControl.evaluate(&bundle(change));
209 assert_eq!(findings.len(), 1);
210 assert_eq!(findings[0].status, ControlStatus::Violated);
211 }
212
213 #[test]
214 fn not_applicable_when_no_changes() {
215 let evidence = EvidenceBundle::default();
216 let findings = TwoPartyReviewControl.evaluate(&evidence);
217 assert_eq!(findings.len(), 1);
218 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
219 }
220
221 #[test]
222 fn indeterminate_when_approvals_missing() {
223 let mut change = make_change(vec![]);
224 change.approval_decisions = EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
225 source: "github".to_string(),
226 subject: "reviews".to_string(),
227 detail: "API error".to_string(),
228 }]);
229 let findings = TwoPartyReviewControl.evaluate(&bundle(change));
230 assert_eq!(findings.len(), 1);
231 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
232 }
233
234 #[test]
235 fn indeterminate_when_revisions_missing() {
236 let mut change = make_change(vec!["reviewer-a", "reviewer-b"]);
237 change.source_revisions = EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
238 source: "github".to_string(),
239 subject: "commits".to_string(),
240 detail: "API error".to_string(),
241 }]);
242 let findings = TwoPartyReviewControl.evaluate(&bundle(change));
243 assert_eq!(findings.len(), 1);
244 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
245 }
246
247 #[test]
248 fn satisfied_with_three_independent_approvers() {
249 let change = make_change(vec!["reviewer-a", "reviewer-b", "reviewer-c"]);
250 let findings = TwoPartyReviewControl.evaluate(&bundle(change));
251 assert_eq!(findings.len(), 1);
252 assert_eq!(findings[0].status, ControlStatus::Satisfied);
253 assert!(findings[0].rationale.contains("3"));
254 }
255
256 #[test]
257 fn violated_when_zero_approvals() {
258 let change = make_change(vec![]);
259 let findings = TwoPartyReviewControl.evaluate(&bundle(change));
260 assert_eq!(findings.len(), 1);
261 assert_eq!(findings[0].status, ControlStatus::Violated);
262 assert!(findings[0].rationale.contains("0"));
263 }
264
265 #[test]
266 fn indeterminate_when_submitted_by_missing() {
267 let mut change = make_change(vec!["reviewer-a", "reviewer-b"]);
268 change.submitted_by = None;
269 let findings = TwoPartyReviewControl.evaluate(&bundle(change));
270 assert_eq!(findings.len(), 1);
271 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
272 }
273
274 #[test]
275 fn correct_control_id() {
276 assert_eq!(
277 TwoPartyReviewControl.id(),
278 builtin::id(builtin::TWO_PARTY_REVIEW)
279 );
280 }
281}