Skip to main content

libverify_core/controls/
release_traceability.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, PromotionBatch};
3
4/// Verifies that release promotion batches have linked change requests.
5///
6/// Maps to SOC2 CC7.1: change traceability through the release pipeline.
7/// Every release should trace back to governed change requests (PRs) to
8/// maintain a complete audit trail from code change to production deployment.
9pub struct ReleaseTraceabilityControl;
10
11impl Control for ReleaseTraceabilityControl {
12    fn id(&self) -> ControlId {
13        builtin::id(builtin::RELEASE_TRACEABILITY)
14    }
15
16    fn description(&self) -> &'static str {
17        "Release batches must trace to governed change requests"
18    }
19
20    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
21        if evidence.promotion_batches.is_empty() {
22            return vec![ControlFinding::not_applicable(
23                self.id(),
24                "No promotion batches found",
25            )];
26        }
27
28        evidence
29            .promotion_batches
30            .iter()
31            .map(|batch| evaluate_batch(self.id(), batch))
32            .collect()
33    }
34}
35
36fn evaluate_batch(id: ControlId, batch: &PromotionBatch) -> ControlFinding {
37    let batch_subject = batch.id.clone();
38
39    match &batch.linked_change_requests {
40        EvidenceState::NotApplicable => {
41            ControlFinding::not_applicable(id, "Linked change requests not applicable")
42        }
43        EvidenceState::Missing { gaps } => ControlFinding::indeterminate(
44            id,
45            format!("{batch_subject}: linked change request evidence could not be collected"),
46            vec![batch_subject],
47            gaps.clone(),
48        ),
49        EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
50            if value.is_empty() {
51                ControlFinding::violated(
52                    id,
53                    format!(
54                        "{batch_subject}: no linked change requests found — release has no CR traceability"
55                    ),
56                    vec![batch_subject],
57                )
58            } else {
59                let cr_ids: Vec<String> = value.iter().map(|cr| cr.to_string()).collect();
60                ControlFinding::satisfied(
61                    id,
62                    format!(
63                        "{batch_subject}: traces to {} change request(s)",
64                        value.len()
65                    ),
66                    cr_ids,
67                )
68            }
69        }
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::control::ControlStatus;
77    use crate::evidence::{ChangeRequestId, EvidenceGap, SourceRevision};
78
79    fn make_batch(linked_crs: EvidenceState<Vec<ChangeRequestId>>) -> PromotionBatch {
80        PromotionBatch {
81            id: "github_release:owner/repo:v0.1.0..v0.2.0".to_string(),
82            source_revisions: EvidenceState::complete(vec![SourceRevision {
83                id: "abc123".to_string(),
84                authored_by: Some("dev".to_string()),
85                committed_at: None,
86                merge: false,
87                authenticity: EvidenceState::not_applicable(),
88            }]),
89            linked_change_requests: linked_crs,
90        }
91    }
92
93    fn bundle(batches: Vec<PromotionBatch>) -> EvidenceBundle {
94        EvidenceBundle {
95            promotion_batches: batches,
96            ..Default::default()
97        }
98    }
99
100    #[test]
101    fn not_applicable_when_no_batches() {
102        let findings = ReleaseTraceabilityControl.evaluate(&EvidenceBundle::default());
103        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
104    }
105
106    #[test]
107    fn satisfied_when_crs_linked() {
108        let batch = make_batch(EvidenceState::complete(vec![
109            ChangeRequestId::new("test", "owner/repo#1"),
110            ChangeRequestId::new("test", "owner/repo#2"),
111        ]));
112        let findings = ReleaseTraceabilityControl.evaluate(&bundle(vec![batch]));
113        assert_eq!(findings[0].status, ControlStatus::Satisfied);
114        assert!(findings[0].rationale.contains("2 change request(s)"));
115    }
116
117    #[test]
118    fn violated_when_no_crs_linked() {
119        let batch = make_batch(EvidenceState::complete(vec![]));
120        let findings = ReleaseTraceabilityControl.evaluate(&bundle(vec![batch]));
121        assert_eq!(findings[0].status, ControlStatus::Violated);
122        assert!(findings[0].rationale.contains("no linked change requests"));
123    }
124
125    #[test]
126    fn indeterminate_when_evidence_missing() {
127        let batch = make_batch(EvidenceState::missing(vec![
128            EvidenceGap::CollectionFailed {
129                source: "github".to_string(),
130                subject: "commits".to_string(),
131                detail: "API error".to_string(),
132            },
133        ]));
134        let findings = ReleaseTraceabilityControl.evaluate(&bundle(vec![batch]));
135        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
136    }
137
138    #[test]
139    fn not_applicable_when_crs_not_applicable() {
140        let batch = make_batch(EvidenceState::not_applicable());
141        let findings = ReleaseTraceabilityControl.evaluate(&bundle(vec![batch]));
142        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
143    }
144}