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 = match &first.data {
92 EventData::Opened {
93 finding_ref,
94 title,
95 severity,
96 impact,
97 likelihood,
98 affected_systems,
99 sla_days,
100 due_at,
101 } => RiskState {
102 status: Status::Open,
103 title: title.clone(),
104 severity: *severity,
105 impact: *impact,
106 likelihood: *likelihood,
107 owner: None,
108 due_at: Some(*due_at),
109 sla_days: Some(*sla_days),
110 affected_systems: affected_systems.clone(),
111 finding_refs: vec![finding_ref.clone()],
112 external: Vec::new(),
113 external_status: Default::default(),
114 resolved_at: None,
115 exception_expires_at: None,
116 },
117 other => panic!(
118 "fold: first event must be `opened`, got `{}`",
119 other.type_str()
120 ),
121 };
122
123 for ev in &events[1..] {
124 apply(&mut state, ev);
125 }
126 state
127}
128
129fn apply(state: &mut RiskState, ev: &RiskEvent) {
130 match &ev.data {
131 // Already consumed as the seed; a second `opened` is ignored (the
132 // schema/append protocol prevents it landing in the first place).
133 EventData::Opened { .. } => {}
134 EventData::OwnerAssigned { owner } => {
135 state.owner = Some(owner.clone());
136 }
137 EventData::ScoreChanged {
138 impact,
139 likelihood,
140 severity,
141 ..
142 } => {
143 state.impact = *impact;
144 state.likelihood = *likelihood;
145 state.severity = *severity;
146 // Recompute due_at if the SLA derives from an sla_days window
147 // anchored on the open date. We anchor on the originating
148 // finding's risk-open day, which is the first event's date.
149 if let Some(days) = state.sla_days {
150 if let Some(first_ref_day) = open_day(state) {
151 state.due_at = Some(first_ref_day + Duration::days(days as i64));
152 }
153 }
154 }
155 EventData::SlaSet { due_at, .. } => {
156 state.due_at = Some(*due_at);
157 // An explicit override detaches due_at from the sla_days basis.
158 state.sla_days = None;
159 }
160 EventData::StatusChanged { to, .. } => {
161 set_status(state, *to, ev);
162 }
163 EventData::EvidenceLinked { finding_ref } => {
164 state.finding_refs.push(finding_ref.clone());
165 }
166 EventData::ExternalLinked {
167 system,
168 external_id,
169 url,
170 } => {
171 state.external.push(super::model::ExternalLink {
172 system: system.clone(),
173 external_id: external_id.clone(),
174 url: url.clone(),
175 });
176 }
177 EventData::ExternalStatusObserved { system, status, .. } => {
178 // Advisory only — recorded, never authoritative over `status`.
179 state.external_status.insert(system.clone(), status.clone());
180 }
181 EventData::Note { .. } => {}
182 EventData::Remediated { .. } => {
183 set_status(state, Status::Remediated, ev);
184 }
185 EventData::Reopened { .. } => {
186 // remediated → open (Reopened is the transient marker; resolve
187 // straight to Open and clear the resolution timestamp).
188 state.status = Status::Open;
189 state.resolved_at = None;
190 }
191 EventData::ExceptionDocumented { expires_at, .. } => {
192 state.status = Status::AcceptedException;
193 state.exception_expires_at = Some(*expires_at);
194 }
195 }
196}
197
198/// Apply a status change, normalising the transient `Reopened` marker to
199/// `Open` and maintaining `resolved_at`.
200fn set_status(state: &mut RiskState, to: Status, ev: &RiskEvent) {
201 match to {
202 Status::Remediated => {
203 state.status = Status::Remediated;
204 state.resolved_at = Some(ev.ts);
205 }
206 Status::Reopened | Status::Open => {
207 state.status = Status::Open;
208 state.resolved_at = None;
209 }
210 other => {
211 state.status = other;
212 }
213 }
214}
215
216/// The risk's open day, derived from the originating finding's `run_id`
217/// (YYYY-MM-DD prefix), used as the anchor for recomputing `due_at`.
218fn open_day(state: &RiskState) -> Option<chrono::NaiveDate> {
219 let run_id = &state.finding_refs.first()?.run_id;
220 run_id
221 .get(0..10)
222 .and_then(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
223}