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 = 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}