Skip to main content

secunit_core/risks/
fold.rs

1//! The deterministic left-fold over a risk's events and the status machine
2//! that governs lifecycle transitions.
3//!
4//! The fold is a pure function of the event list: last-write-wins per field,
5//! status follows the latest lifecycle event, finding refs accumulate. The
6//! status machine (see `docs/risks.md`) is enforced at append time so an
7//! illegal `status-changed` never lands in the log.
8
9use chrono::Duration;
10
11use super::model::{EventData, RiskEvent, RiskState, Status};
12
13/// An attempted lifecycle transition that the status machine rejects.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct TransitionError {
16    pub from: Status,
17    pub to: Status,
18}
19
20impl std::fmt::Display for TransitionError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(
23            f,
24            "illegal status transition: {} → {}",
25            self.from.as_str(),
26            self.to.as_str()
27        )
28    }
29}
30
31impl std::error::Error for TransitionError {}
32
33/// The status machine, as drawn in `docs/risks.md`:
34///
35/// ```text
36///             ┌─────────────► accepted-exception
37///             │                       │
38/// opened ──► open ──► in-progress ──► remediated ──► reopened ──► open
39///             │            │              │
40///             └────────────┴──────────────┴──► false-positive
41/// ```
42///
43/// Returns `Ok(())` if moving `from → to` is legal, else a
44/// [`TransitionError`]. A no-op (`from == to`) is rejected — a
45/// `status-changed` event must actually change the status.
46pub fn validate_transition(from: Status, to: Status) -> Result<(), TransitionError> {
47    use Status::*;
48    let ok = match (from, to) {
49        // Any non-terminal state may be ruled a false positive.
50        (Open | InProgress | Remediated, FalsePositive) => true,
51        // Open or in-progress may be accepted as a documented exception.
52        (Open | InProgress, AcceptedException) => true,
53        // Forward progress.
54        (Open, InProgress) => true,
55        (InProgress, Remediated) => true,
56        // A remediation can be undone.
57        (Remediated, Reopened) => true,
58        // The transient Reopened marker resolves back to Open. (The fold
59        // also normalises Reopened → Open, but allow it explicitly so a
60        // direct status-changed to/from it round-trips.)
61        (Reopened, Open) => true,
62        // A reopened risk that re-enters work goes straight to in-progress,
63        // and an accepted exception that lapses can be reopened to open.
64        (Reopened, InProgress) => true,
65        (AcceptedException, Open) => true,
66        // Everything else (including no-op and all terminal-state exits) is
67        // rejected.
68        _ => false,
69    };
70    if ok {
71        Ok(())
72    } else {
73        Err(TransitionError { from, to })
74    }
75}
76
77/// Fold events in `seq` order into the current [`RiskState`].
78///
79/// Deterministic and total: the first event MUST be `opened` (callers
80/// guarantee this — [`super::store::open`] writes it), and the result is a
81/// pure function of the slice. The caller is responsible for passing events
82/// already sorted by `seq`; [`super::store::load_events`] does this.
83///
84/// # Panics
85///
86/// Panics if the first event is not `opened`, since a log that doesn't start
87/// with `opened` is structurally corrupt and cannot produce a coherent
88/// state. Loaders validate this before folding.
89pub fn fold(events: &[RiskEvent]) -> RiskState {
90    let first = events.first().expect("fold: empty event log");
91    let mut state = seed(first);
92    for ev in &events[1..] {
93        apply(&mut state, ev);
94    }
95    state
96}
97
98/// [`fold`] restricted to events dated at or before `cutoff` — the risk's
99/// state as of that day's end. Borrows the slice, never clones events.
100/// Returns `None` when no event qualifies. Same contract as [`fold`]
101/// otherwise: the first qualifying event must be `opened`.
102pub fn fold_at(events: &[RiskEvent], cutoff: chrono::NaiveDate) -> Option<RiskState> {
103    let mut iter = events.iter().filter(|e| e.ts.date_naive() <= cutoff);
104    let first = iter.next()?;
105    let mut state = seed(first);
106    for ev in iter {
107        apply(&mut state, ev);
108    }
109    Some(state)
110}
111
112/// Seed the state from the leading `opened` event.
113///
114/// # Panics
115///
116/// Panics when the event is not `opened` — see [`fold`]'s contract.
117fn seed(first: &RiskEvent) -> RiskState {
118    match &first.data {
119        EventData::Opened {
120            finding_ref,
121            title,
122            severity,
123            impact,
124            likelihood,
125            affected_systems,
126            sla_days,
127            due_at,
128        } => RiskState {
129            status: Status::Open,
130            title: title.clone(),
131            severity: *severity,
132            impact: *impact,
133            likelihood: *likelihood,
134            owner: None,
135            due_at: Some(*due_at),
136            sla_days: Some(*sla_days),
137            affected_systems: affected_systems.clone(),
138            finding_refs: vec![finding_ref.clone()],
139            external: Vec::new(),
140            external_status: Default::default(),
141            resolved_at: None,
142            exception_expires_at: None,
143        },
144        other => panic!(
145            "fold: first event must be `opened`, got `{}`",
146            other.type_str()
147        ),
148    }
149}
150
151fn apply(state: &mut RiskState, ev: &RiskEvent) {
152    match &ev.data {
153        // Already consumed as the seed; a second `opened` is ignored (the
154        // schema/append protocol prevents it landing in the first place).
155        EventData::Opened { .. } => {}
156        EventData::OwnerAssigned { owner } => {
157            state.owner = Some(owner.clone());
158        }
159        EventData::ScoreChanged {
160            impact,
161            likelihood,
162            severity,
163            ..
164        } => {
165            state.impact = *impact;
166            state.likelihood = *likelihood;
167            state.severity = *severity;
168            // Recompute due_at if the SLA derives from an sla_days window
169            // anchored on the open date. We anchor on the originating
170            // finding's risk-open day, which is the first event's date.
171            if let Some(days) = state.sla_days {
172                if let Some(first_ref_day) = open_day(state) {
173                    state.due_at = Some(first_ref_day + Duration::days(days as i64));
174                }
175            }
176        }
177        EventData::Retitled { title, .. } => {
178            state.title = title.clone();
179        }
180        EventData::SlaSet { due_at, .. } => {
181            state.due_at = Some(*due_at);
182            // An explicit override detaches due_at from the sla_days basis.
183            state.sla_days = None;
184        }
185        EventData::StatusChanged { to, .. } => {
186            set_status(state, *to, ev);
187        }
188        EventData::EvidenceLinked { finding_ref } => {
189            state.finding_refs.push(finding_ref.clone());
190        }
191        EventData::ExternalLinked {
192            system,
193            external_id,
194            url,
195        } => {
196            state.external.push(super::model::ExternalLink {
197                system: system.clone(),
198                external_id: external_id.clone(),
199                url: url.clone(),
200            });
201        }
202        EventData::ExternalStatusObserved { system, status, .. } => {
203            // Advisory only — recorded, never authoritative over `status`.
204            state.external_status.insert(system.clone(), status.clone());
205        }
206        EventData::Note { .. } => {}
207        EventData::Remediated { .. } => {
208            set_status(state, Status::Remediated, ev);
209        }
210        EventData::Reopened { .. } => {
211            // remediated → open (Reopened is the transient marker; resolve
212            // straight to Open and clear the resolution timestamp).
213            state.status = Status::Open;
214            state.resolved_at = None;
215        }
216        EventData::ExceptionDocumented { expires_at, .. } => {
217            state.status = Status::AcceptedException;
218            state.exception_expires_at = Some(*expires_at);
219        }
220    }
221}
222
223/// Apply a status change, normalising the transient `Reopened` marker to
224/// `Open` and maintaining `resolved_at`.
225fn set_status(state: &mut RiskState, to: Status, ev: &RiskEvent) {
226    match to {
227        Status::Remediated => {
228            state.status = Status::Remediated;
229            state.resolved_at = Some(ev.ts);
230        }
231        Status::Reopened | Status::Open => {
232            state.status = Status::Open;
233            state.resolved_at = None;
234        }
235        other => {
236            state.status = other;
237        }
238    }
239}
240
241/// The risk's open day, derived from the originating finding's `run_id`
242/// (YYYY-MM-DD prefix), used as the anchor for recomputing `due_at`.
243fn open_day(state: &RiskState) -> Option<chrono::NaiveDate> {
244    let run_id = &state.finding_refs.first()?.run_id;
245    run_id
246        .get(0..10)
247        .and_then(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
248}