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}