Skip to main content

harn_vm/flow/
audit.rs

1//! Advisory replay audit for predicate hash drift.
2//!
3//! A shipped slice pins the predicate hashes that evaluated it. Current
4//! `@retroactive` predicates are advisory-only: if a historical slice does not
5//! carry the current hash, the audit reports drift but does not rewrite or
6//! block the slice.
7
8use 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/// Current predicate metadata included in replay-audit reports.
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ReplayAuditPredicate {
20    pub name: String,
21    pub hash: PredicateHash,
22}
23
24/// Per-slice replay-audit outcome.
25#[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/// Aggregate replay-audit report.
43#[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
57/// Audit one shipped slice against the current predicate set.
58pub 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
98/// Audit many shipped slices and retain only slices with reportable content.
99pub 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}