1use std::collections::BTreeSet;
9
10use serde::{Deserialize, Serialize};
11
12use super::predicates::ResolvedPredicate;
13#[cfg(test)]
14use super::predicates::{DiscoveredPredicate, PredicateSource};
15use super::slice::{PredicateHash, Slice, SliceId};
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ReplayAuditPredicate {
20 pub name: String,
21 pub hash: PredicateHash,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub struct SliceReplayAudit {
27 pub slice_id: SliceId,
28 pub recorded_predicates: usize,
29 pub current_retroactive_predicates: usize,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub advisory_drift: Vec<ReplayAuditPredicate>,
32 #[serde(default, skip_serializing_if = "Vec::is_empty")]
33 pub historical_only_predicates: Vec<PredicateHash>,
34}
35
36impl SliceReplayAudit {
37 pub fn has_drift(&self) -> bool {
38 !self.advisory_drift.is_empty()
39 }
40}
41
42#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
44pub struct ReplayAuditReport {
45 pub audited_slices: usize,
46 pub drifted_slices: usize,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub slices: Vec<SliceReplayAudit>,
49}
50
51impl ReplayAuditReport {
52 pub fn has_drift(&self) -> bool {
53 self.drifted_slices > 0
54 }
55}
56
57pub fn audit_slice_against_current_predicates(
59 slice: &Slice,
60 current_predicates: &[ResolvedPredicate],
61) -> SliceReplayAudit {
62 let recorded = slice
63 .invariants_applied
64 .iter()
65 .map(|(hash, _)| hash.clone())
66 .collect::<BTreeSet<_>>();
67 let current = current_predicates
68 .iter()
69 .map(|resolved| resolved.predicate.source_hash.clone())
70 .collect::<BTreeSet<_>>();
71 let advisory_drift = current_predicates
72 .iter()
73 .filter(|resolved| resolved.predicate.retroactive)
74 .filter(|resolved| !recorded.contains(&resolved.predicate.source_hash))
75 .map(|resolved| ReplayAuditPredicate {
76 name: resolved.qualified_name.clone(),
77 hash: resolved.predicate.source_hash.clone(),
78 })
79 .collect::<Vec<_>>();
80 let historical_only_predicates = recorded
81 .iter()
82 .filter(|hash| !current.contains(*hash))
83 .cloned()
84 .collect::<Vec<_>>();
85
86 SliceReplayAudit {
87 slice_id: slice.id,
88 recorded_predicates: recorded.len(),
89 current_retroactive_predicates: current_predicates
90 .iter()
91 .filter(|resolved| resolved.predicate.retroactive)
92 .count(),
93 advisory_drift,
94 historical_only_predicates,
95 }
96}
97
98pub fn replay_audit_report(
100 slices: impl IntoIterator<Item = Slice>,
101 current_predicates: &[ResolvedPredicate],
102) -> ReplayAuditReport {
103 let mut report = ReplayAuditReport::default();
104 for slice in slices {
105 report.audited_slices += 1;
106 let audit = audit_slice_against_current_predicates(&slice, current_predicates);
107 if audit.has_drift() {
108 report.drifted_slices += 1;
109 }
110 if audit.has_drift() || !audit.historical_only_predicates.is_empty() {
111 report.slices.push(audit);
112 }
113 }
114 report
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::flow::{Approval, AtomId, InvariantResult, PredicateKind, SliceStatus, TestId};
121 use harn_lexer::Span;
122
123 fn slice(applied: Vec<PredicateHash>) -> Slice {
124 Slice {
125 id: SliceId([1; 32]),
126 atoms: vec![AtomId([2; 32])],
127 intents: Vec::new(),
128 invariants_applied: applied
129 .into_iter()
130 .map(|hash| (hash, InvariantResult::allow()))
131 .collect(),
132 required_tests: vec![TestId::new("unit")],
133 approval_chain: Vec::<Approval>::new(),
134 base_ref: AtomId([0; 32]),
135 status: SliceStatus::Ready,
136 }
137 }
138
139 fn predicate(name: &str, hash: &str, retroactive: bool) -> ResolvedPredicate {
140 ResolvedPredicate {
141 qualified_name: name.to_string(),
142 logical_name: name.to_string(),
143 source: PredicateSource::new("."),
144 source_order: 0,
145 fallback_hash: None,
146 predicate: DiscoveredPredicate {
147 name: name.to_string(),
148 kind: PredicateKind::Deterministic,
149 fallback: None,
150 archivist: None,
151 retroactive,
152 source_hash: PredicateHash::new(hash),
153 span: Span::dummy(),
154 },
155 }
156 }
157
158 #[test]
159 fn current_retroactive_predicate_missing_from_slice_reports_advisory_drift() {
160 let report = replay_audit_report(
161 vec![slice(vec![PredicateHash::new("sha256:old")])],
162 &[predicate("no_secrets", "sha256:new", true)],
163 );
164
165 assert!(report.has_drift());
166 assert_eq!(report.audited_slices, 1);
167 assert_eq!(report.drifted_slices, 1);
168 assert_eq!(report.slices[0].advisory_drift[0].name, "no_secrets");
169 assert_eq!(
170 report.slices[0].historical_only_predicates,
171 vec![PredicateHash::new("sha256:old")]
172 );
173 }
174
175 #[test]
176 fn non_retroactive_predicate_changes_do_not_surface_advisory_drift() {
177 let report = replay_audit_report(
178 vec![slice(vec![PredicateHash::new("sha256:old")])],
179 &[predicate("style", "sha256:new", false)],
180 );
181
182 assert!(!report.has_drift());
183 assert_eq!(report.drifted_slices, 0);
184 assert!(report.slices[0].advisory_drift.is_empty());
185 }
186}