Skip to main content

secunit_core/
reports.rs

1//! Report data assembly: the structured JSON behind `secunit report data`.
2//!
3//! Aggregates per-control coverage, sealed-run activity, and the risk
4//! register over one reporting window (week, month, quarter, or year) into
5//! a [`ReportData`] payload. The `report` skill renders this to prose —
6//! the binary never composes the report itself, and this module never
7//! captures or mutates anything.
8
9use std::path::Path;
10
11use chrono::{DateTime, NaiveDate, Utc};
12use serde::{Deserialize, Serialize};
13
14use crate::evidence::manifest::RunOutcome;
15use crate::model::{Cadence, LoadedRegistry, RunStatus};
16use crate::registry::coverage::{self, PeriodCoverage, PeriodStatus};
17use crate::registry::period;
18use crate::registry::resolver;
19use crate::risks::{self, EventData, RiskState, Severity, Status};
20
21// ---------- payload ---------------------------------------------------------
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ReportData {
25    pub schema_version: u32,
26    /// `org.name` from `_config.yaml`, when set.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub org: Option<String>,
29    pub period: ReportPeriod,
30    /// The pinned "today" the assembly ran under — separates `Open` from
31    /// `Gap` periods and drives `past_sla` / `overdue`.
32    pub generated_on: NaiveDate,
33    pub controls: Vec<ControlActivity>,
34    pub totals: Totals,
35    /// Sealed manifests that exist but could not be read or parsed. The
36    /// report proceeds without them, but a non-empty list means run
37    /// counts and period statuses above understate the evidence (a
38    /// period may read Gap only because its manifest is unreadable) —
39    /// the skill must surface these, and the operator must investigate:
40    /// an altered manifest is an integrity event.
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub manifest_errors: Vec<ManifestError>,
43    /// Controls past due and past their cadence grace window as of
44    /// `generated_on`, per the same resolver `secunit due` uses: a due
45    /// date that passed without a completed run holds until satisfied or
46    /// skipped, so a missed control stays here until it runs. Never-run
47    /// controls have no cached due date and appear under `upcoming`;
48    /// historical misses beyond the latest one surface as `Gap` rows in
49    /// `controls[].periods`.
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub overdue: Vec<OverdueControl>,
52    pub risks: RiskSummary,
53    /// Controls due by `period.horizon` that are not overdue, per the
54    /// same resolver `secunit due` uses. Includes a held due date still
55    /// inside its grace window — due now, not yet overdue.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub upcoming: Vec<UpcomingControl>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ReportPeriod {
62    /// The selector as given: `2026-W30`, `2026-07`, `2026-q3`, or `2026`.
63    pub label: String,
64    /// The window's own cadence — what one "period" means here.
65    pub cadence: Cadence,
66    pub start: NaiveDate,
67    pub end: NaiveDate,
68    /// The through-date `upcoming` considers: the end of the calendar
69    /// period after the window, floored at the period containing
70    /// `generated_on` so late-assembled reports still look forward from
71    /// now. Cite this when rendering an "Upcoming" section.
72    pub horizon: NaiveDate,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ControlActivity {
77    pub id: String,
78    pub title: String,
79    pub owner: String,
80    pub cadence: Cadence,
81    /// Coverage rows for every period of this control's cadence touching
82    /// the window (a weekly report window sits inside one quarterly
83    /// period — that period appears here with its current status).
84    pub periods: Vec<PeriodCoverage>,
85    pub counts: PeriodCounts,
86    /// Sealed runs claiming a period in the window or completed inside it.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub runs: Vec<RunSummary>,
89}
90
91#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
92pub struct PeriodCounts {
93    pub satisfied: usize,
94    /// Satisfied, but the run sealed after the period ended.
95    pub late: usize,
96    pub failed: usize,
97    pub gaps: usize,
98    pub open: usize,
99    pub skipped: usize,
100    pub future: usize,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct RunSummary {
105    pub run_id: String,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub period_id: Option<String>,
108    pub completed_at: DateTime<Utc>,
109    pub status: RunOutcome,
110    pub draft_risks: usize,
111    pub draft_issues: usize,
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub external_links: Vec<serde_json::Value>,
114    /// Run dir relative to the secunit root.
115    pub path: String,
116}
117
118#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
119pub struct Totals {
120    pub controls: usize,
121    /// Sum of `controls[].runs` lengths — which include runs merely
122    /// *touching* the window (a quarterly run whose period surrounds a
123    /// reported week, a catch-up run sealed inside it). Not "runs
124    /// performed this window": the same surrounding-period run appears
125    /// in every finer-cadence report it overlaps, and regenerating an
126    /// old window after later runs seal can raise this number. Render it
127    /// as "related runs", never as "runs this <period>".
128    pub runs: usize,
129    pub satisfied: usize,
130    pub late: usize,
131    pub failed: usize,
132    pub gaps: usize,
133    pub open: usize,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct OverdueControl {
138    pub id: String,
139    pub title: String,
140    pub owner: String,
141    pub next_due: NaiveDate,
142    pub days_overdue: i64,
143    pub last_status: RunStatus,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct UpcomingControl {
148    pub id: String,
149    pub title: String,
150    pub owner: String,
151    pub cadence: Cadence,
152    pub next_due: NaiveDate,
153}
154
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct RiskSummary {
157    /// Risks currently open / in-progress, most severe first.
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub open: Vec<OpenRisk>,
160    /// Risks created (an `opened` event) inside the window.
161    pub opened_in_period: usize,
162    /// Risks reopened inside the window. Counted separately from
163    /// `opened_in_period` so a reopen never reads as "no new risk".
164    pub reopened_in_period: usize,
165    /// Risks that closed inside the window and are still closed at the
166    /// window's end. Per-risk, not per-event: close→reopen→close churn
167    /// counts once, and a close undone by a reopen counts zero.
168    pub closed_in_period: usize,
169    /// Open risks whose SLA due date has passed `generated_on`.
170    pub past_sla: usize,
171    /// Accepted exceptions whose expiry (or, lacking one, original SLA
172    /// date — matching `secunit risks list --past-sla`) has passed. Not
173    /// "open", but the pass leadership granted has run out: the report
174    /// must surface them for re-attestation or remediation.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub lapsed_exceptions: Vec<LapsedException>,
177    /// Risk logs that could not be read or failed chain validation. The
178    /// report proceeds without them, but a non-empty list means every
179    /// count above understates the register — the skill must surface
180    /// these verbatim, and the operator must investigate before trusting
181    /// the risk section (a broken chain means evidence was altered).
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub register_errors: Vec<RegisterError>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct RegisterError {
188    pub risk_id: String,
189    pub error: String,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct LapsedException {
194    pub risk_id: String,
195    pub title: String,
196    pub severity: Severity,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub owner: Option<String>,
199    pub expired_on: NaiveDate,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct ManifestError {
204    pub control_id: String,
205    /// Run dir relative to the secunit root.
206    pub path: String,
207    pub error: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct OpenRisk {
212    pub risk_id: String,
213    pub title: String,
214    pub severity: Severity,
215    pub status: Status,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub owner: Option<String>,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub due_at: Option<NaiveDate>,
220    pub past_sla: bool,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub source_control: Option<String>,
223}
224
225// ---------- assembly --------------------------------------------------------
226
227/// Assemble the report payload for `[start, end]`, labelled `label`.
228///
229/// `window_cadence` is the cadence of the reporting window itself (weekly
230/// report → `Weekly`) and anchors the `upcoming` horizon to the next
231/// calendar period. `today` separates open from gap periods and anchors
232/// SLA/overdue math — pin it (`--today`) for deterministic output.
233pub fn assemble(
234    reg: &LoadedRegistry,
235    label: &str,
236    window_cadence: Cadence,
237    start: NaiveDate,
238    end: NaiveDate,
239    today: NaiveDate,
240) -> anyhow::Result<ReportData> {
241    let mut controls: Vec<ControlActivity> = Vec::new();
242    let mut totals = Totals::default();
243
244    // One walk of the whole evidence tree: coverage and the run summaries
245    // below consume the same sealed manifests, bucketed per control. The
246    // walk's skipped-manifest list goes into the payload — a corrupt
247    // manifest must not silently read as a gap.
248    let walk = coverage::sealed_runs_by_control(&reg.root)?;
249    let mut runs_by_control = walk.runs;
250    let manifest_errors: Vec<ManifestError> = walk
251        .errors
252        .into_iter()
253        .map(|e| ManifestError {
254            control_id: e.control_id,
255            path: e
256                .path
257                .strip_prefix(&reg.root)
258                .unwrap_or(&e.path)
259                .to_string_lossy()
260                .into_owned(),
261            error: e.error,
262        })
263        .collect();
264
265    for control in reg.controls.values() {
266        let sealed = runs_by_control.remove(&control.id).unwrap_or_default();
267        let cov = if matches!(control.cadence, Cadence::Continuous) {
268            None
269        } else {
270            Some(coverage::coverage_over_runs(
271                reg,
272                &control.id,
273                &sealed,
274                start,
275                end,
276                today,
277            )?)
278        };
279        let periods = cov.map(|c| c.periods).unwrap_or_default();
280        let runs = runs_in_window(&reg.root, &sealed, control.cadence, &periods, start, end);
281
282        // A control with nothing due and nothing run this window carries
283        // no signal for the period — leave it out of the payload.
284        if periods.is_empty() && runs.is_empty() {
285            continue;
286        }
287
288        let mut counts = PeriodCounts::default();
289        for p in &periods {
290            match p.status {
291                PeriodStatus::Satisfied => {
292                    counts.satisfied += 1;
293                    if p.late {
294                        counts.late += 1;
295                    }
296                }
297                PeriodStatus::Failed => counts.failed += 1,
298                PeriodStatus::Gap => counts.gaps += 1,
299                PeriodStatus::Open => counts.open += 1,
300                PeriodStatus::Skipped => counts.skipped += 1,
301                PeriodStatus::Future => counts.future += 1,
302            }
303        }
304        totals.controls += 1;
305        totals.runs += runs.len();
306        totals.satisfied += counts.satisfied;
307        totals.late += counts.late;
308        totals.failed += counts.failed;
309        totals.gaps += counts.gaps;
310        totals.open += counts.open;
311
312        controls.push(ControlActivity {
313            id: control.id.clone(),
314            title: control.title.clone(),
315            owner: control.owner.clone(),
316            cadence: control.cadence,
317            periods,
318            counts,
319            runs,
320        });
321    }
322
323    // One resolver pass; overdue and upcoming partition the same rows.
324    let due_rows = resolver::due_rows(reg, today);
325    let horizon = upcoming_horizon(window_cadence, start, end, today);
326    let overdue = overdue_controls(reg, &due_rows, today);
327    let upcoming = upcoming_controls(reg, &due_rows, horizon);
328    let risks = risk_summary(&reg.root, start, end, today)?;
329
330    Ok(ReportData {
331        schema_version: crate::SCHEMA_VERSION,
332        org: reg.config.org.as_ref().and_then(|o| o.name.clone()),
333        period: ReportPeriod {
334            label: label.to_string(),
335            cadence: window_cadence,
336            start,
337            end,
338            horizon,
339        },
340        generated_on: today,
341        controls,
342        totals,
343        manifest_errors,
344        overdue,
345        risks,
346        upcoming,
347    })
348}
349
350/// The window's slice of already-walked sealed runs: a run belongs to
351/// the window when it was sealed inside it (catch-up runs claiming a
352/// prior period, and runs whose period id predates a cadence change,
353/// must not vanish from every report) or when its claimed period touches
354/// it. Pure filter — the walk happened once in `assemble`.
355fn runs_in_window(
356    root: &Path,
357    sealed: &[coverage::SealedRun],
358    cadence: Cadence,
359    periods: &[PeriodCoverage],
360    start: NaiveDate,
361    end: NaiveDate,
362) -> Vec<RunSummary> {
363    let mut out: Vec<RunSummary> = Vec::new();
364    for run in sealed {
365        let manifest = &run.manifest;
366        let completed_in_window = {
367            let d = manifest.completed_at.date_naive();
368            d >= start && d <= end
369        };
370        let in_window = completed_in_window
371            || match &manifest.period_id {
372                Some(pid) => {
373                    periods.iter().any(|p| &p.period_id == pid)
374                        || period::bounds(cadence, pid)
375                            .is_some_and(|(ps, pe)| ps <= end && pe >= start)
376                }
377                None => false,
378            };
379        if !in_window {
380            continue;
381        }
382        let rel = run
383            .path
384            .strip_prefix(root)
385            .unwrap_or(&run.path)
386            .to_string_lossy()
387            .into_owned();
388        out.push(RunSummary {
389            run_id: manifest.run_id.clone(),
390            period_id: manifest.period_id.clone(),
391            completed_at: manifest.completed_at,
392            status: manifest.status,
393            draft_risks: manifest.draft_risks.len(),
394            draft_issues: manifest.draft_issues.len(),
395            external_links: manifest.external_links.clone(),
396            path: rel,
397        });
398    }
399    out.sort_by_key(|r| r.completed_at);
400    out
401}
402
403/// Overdue per the resolver — the same due dates, schedule overrides,
404/// never-run handling, and per-cadence grace `secunit due` applies, so the
405/// report can never contradict it.
406fn overdue_controls(
407    reg: &LoadedRegistry,
408    due_rows: &[resolver::DueRow],
409    today: NaiveDate,
410) -> Vec<OverdueControl> {
411    let mut out: Vec<OverdueControl> = Vec::new();
412    for row in due_rows {
413        if !row.overdue {
414            continue;
415        }
416        let Some(next_due) = row.next_due else {
417            continue;
418        };
419        let Some(control) = reg.controls.get(&row.control_id) else {
420            continue;
421        };
422        let last_status = reg
423            .state
424            .controls
425            .get(&row.control_id)
426            .map(|e| e.last_status)
427            .unwrap_or(RunStatus::NeverRun);
428        out.push(OverdueControl {
429            id: control.id.clone(),
430            title: control.title.clone(),
431            owner: control.owner.clone(),
432            next_due,
433            days_overdue: (today - next_due).num_days(),
434            last_status,
435        });
436    }
437    out.sort_by_key(|o| std::cmp::Reverse(o.days_overdue));
438    out
439}
440
441/// The `upcoming` through-date: the end of the calendar period after the
442/// window, floored at the period containing `today` — a report assembled
443/// long after its window still looks forward from now instead of at an
444/// interval that already ended.
445fn upcoming_horizon(
446    window_cadence: Cadence,
447    start: NaiveDate,
448    end: NaiveDate,
449    today: NaiveDate,
450) -> NaiveDate {
451    let anchor = (end + chrono::Duration::days(1)).max(today);
452    period::derive(window_cadence, anchor)
453        .and_then(|pid| period::bounds(window_cadence, &pid))
454        .map(|(_, pe)| pe)
455        // Continuous has no periods; fall back to one window-length.
456        .unwrap_or(anchor + chrono::Duration::days((end - start).num_days()))
457}
458
459/// Controls due by `horizon`, resolver-computed like `overdue_controls`.
460/// Every due row lands in exactly one list: overdue rows there, all
461/// other in-horizon rows here — including a held due date still inside
462/// its grace window (due, not yet overdue).
463fn upcoming_controls(
464    reg: &LoadedRegistry,
465    due_rows: &[resolver::DueRow],
466    horizon: NaiveDate,
467) -> Vec<UpcomingControl> {
468    let mut out: Vec<UpcomingControl> = Vec::new();
469    for row in due_rows {
470        if row.overdue {
471            continue;
472        }
473        let Some(next_due) = row.next_due else {
474            continue;
475        };
476        if next_due > horizon {
477            continue;
478        }
479        let Some(control) = reg.controls.get(&row.control_id) else {
480            continue;
481        };
482        out.push(UpcomingControl {
483            id: control.id.clone(),
484            title: control.title.clone(),
485            owner: control.owner.clone(),
486            cadence: control.cadence,
487            next_due,
488        });
489    }
490    out.sort_by(|a, b| a.next_due.cmp(&b.next_due).then(a.id.cmp(&b.id)));
491    out
492}
493
494/// Fold every risk log under `risks/` into the report's register view:
495/// currently-open risks plus opened/reopened/closed counts inside the
496/// window. A corrupt log doesn't abort the report — that would take the
497/// reporting cadence itself into gap until the append-only log is
498/// restored — but it is never silent either: the broken risk lands in
499/// `register_errors`, in-band where the skill and the reader see it.
500fn risk_summary(
501    root: &Path,
502    start: NaiveDate,
503    end: NaiveDate,
504    today: NaiveDate,
505) -> anyhow::Result<RiskSummary> {
506    let mut summary = RiskSummary::default();
507    // The store's shared lenient load — the same degradation rule
508    // `risks list` and the GUI use. Make paths store-relative: the skill
509    // headlines these strings and may publish the report as a tracker
510    // issue, so the operator's absolute filesystem layout must not
511    // travel with them.
512    let register = risks::load_register(root)?;
513    summary.register_errors = register
514        .errors
515        .into_iter()
516        .map(|(risk_id, error)| RegisterError {
517            risk_id,
518            error: error.replace(&format!("{}/", root.display()), ""),
519        })
520        .collect();
521    for (risk_id, events) in register.risks {
522        // Count per risk, not per event: a close that a later in-window
523        // reopen undoes must not read as a closure, and churn must not
524        // double-count. `closed` therefore requires both an in-window
525        // closing event and a closed status when the window ends.
526        let mut opened = false;
527        let mut reopened = false;
528        let mut closing_event = false;
529        for ev in &events {
530            let d = ev.ts.date_naive();
531            if d < start || d > end {
532                continue;
533            }
534            match &ev.data {
535                EventData::Opened { .. } => opened = true,
536                EventData::Reopened { .. } => reopened = true,
537                EventData::Remediated { .. } | EventData::ExceptionDocumented { .. } => {
538                    closing_event = true
539                }
540                EventData::StatusChanged { to, .. } => {
541                    if to.is_closed() {
542                        closing_event = true;
543                    } else if matches!(to, Status::Reopened) {
544                        reopened = true;
545                    }
546                }
547                _ => {}
548            }
549        }
550        if opened {
551            summary.opened_in_period += 1;
552        }
553        if reopened {
554            summary.reopened_in_period += 1;
555        }
556        if closing_event {
557            let closed_at_end = risks::fold_at(&events, end).is_some_and(|s| s.status.is_closed());
558            if closed_at_end {
559                summary.closed_in_period += 1;
560            }
561        }
562
563        let state: RiskState = risks::fold(&events);
564        if state.status.is_open() {
565            let past_sla = state.due_at.is_some_and(|d| d < today);
566            if past_sla {
567                summary.past_sla += 1;
568            }
569            summary.open.push(OpenRisk {
570                risk_id,
571                title: state.title.clone(),
572                severity: state.severity,
573                status: state.status,
574                owner: state.owner.clone(),
575                due_at: state.due_at,
576                past_sla,
577                source_control: state.source_control().map(str::to_string),
578            });
579        } else if matches!(state.status, Status::AcceptedException) {
580            // An exception's pass can run out; expiry first, else the
581            // original SLA date — the same trigger `risks list --past-sla`
582            // uses — so the report and the CLI agree on lapsed exceptions.
583            let expired_on = state.exception_expires_at.or(state.due_at);
584            if let Some(expired_on) = expired_on.filter(|d| *d < today) {
585                summary.lapsed_exceptions.push(LapsedException {
586                    risk_id,
587                    title: state.title.clone(),
588                    severity: state.severity,
589                    owner: state.owner.clone(),
590                    expired_on,
591                });
592            }
593        }
594    }
595    summary
596        .open
597        .sort_by(|a, b| a.severity.cmp(&b.severity).then(a.risk_id.cmp(&b.risk_id)));
598    Ok(summary)
599}