Skip to main content

secunit_core/evidence/
runner.rs

1//! prepare → (skill executes) → finalize, plus abort and resume.
2//!
3//! All filesystem state changes happen here; the agent only writes data
4//! files into the slots `prepare` carved out. Hash chaining and atomic
5//! writes are handled in `hasher`; concurrency in `lock`.
6
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{anyhow, bail, Context, Result};
12use chrono::{DateTime, Datelike, NaiveDate, Utc};
13
14use super::hasher::{self, atomic_write, sha256_bytes, sha256_file};
15use super::lock::RootLock;
16use super::manifest::{
17    AgentInfo, Artifact, BySystemBlock, Manifest, PrepareContext, PriorRun, RunOutcome, RunResult,
18    ScopeLayout, SkillRef, SystemOutcome,
19};
20use crate::model::{Cadence, LoadedRegistry, RunStatus, StateEntry};
21use crate::registry::{period, resolver};
22use crate::SCHEMA_VERSION;
23
24const PENDING_SENTINEL: &str = ".run-pending";
25const PREPARE_FILE: &str = "prepare.json";
26const RESULT_FILE: &str = "result.json";
27const MANIFEST_FILE: &str = "manifest.json";
28const STATE_FILE: &str = "state.json";
29
30/// Options for `prepare`. Only `today` is required; the rest are
31/// agent-supplied metadata.
32#[derive(Debug, Clone, Default)]
33pub struct PrepareOpts {
34    pub today: Option<NaiveDate>,
35    pub operator: Option<String>,
36    pub note: Option<String>,
37    pub now: Option<DateTime<Utc>>,
38    /// Operator-supplied period claim. `None` means derive from `today` —
39    /// `period_id` records the calendar period the work was performed in.
40    pub period_id: Option<String>,
41}
42
43/// Allocate a run directory, snapshot scope, write `prepare.json`, drop
44/// the `.run-pending` sentinel, and return the prepare context. Holds
45/// the root lock for the duration so concurrent prepares serialise.
46pub fn prepare(
47    reg: &LoadedRegistry,
48    control_id: &str,
49    opts: &PrepareOpts,
50) -> Result<PrepareContext> {
51    let _lock = RootLock::acquire(&reg.root).context("acquire root lock")?;
52    let ctrl = reg
53        .controls
54        .get(control_id)
55        .ok_or_else(|| anyhow!("control `{control_id}` not found"))?;
56
57    let now = opts.now.unwrap_or_else(Utc::now);
58    let today = opts.today.unwrap_or_else(|| now.date_naive());
59
60    // Refuse to allocate a second pending run for the same control.
61    let existing = list_pending(&reg.root)?;
62    if let Some(p) = existing.iter().find(|r| r.control_id == control_id) {
63        bail!(
64            "pending run already exists for `{}` at {}",
65            control_id,
66            p.run_dir.display()
67        );
68    }
69
70    let resolved = resolver::resolve_scope(ctrl, &reg.inventory, today);
71
72    // Empty scope against a non-org-wide control is the silent-failure
73    // case: allocating a run dir would seal a "successful" manifest with
74    // zero artifacts, advancing the chain as if work happened. Fail
75    // early instead — operator either updates inventory.yaml or retires
76    // the control.
77    if ctrl.scope.is_some() && resolved.is_empty() {
78        let scope_desc = match &ctrl.scope {
79            Some(crate::model::Scope::Inventory(s)) => {
80                format!("kind: {}, has_tags: {:?}", s.kind, s.has_tags)
81            }
82            Some(crate::model::Scope::Inline(_)) => "inline".to_string(),
83            None => unreachable!(),
84        };
85        bail!(
86            "control `{control_id}` has scope ({scope_desc}) but no inventory entries match on {today}. Either update inventory.yaml or retire the control."
87        );
88    }
89
90    // Per storage.md: flat is legal when scope is empty (org-wide) or
91    // resolves to exactly one entry. Default to flat in both cases —
92    // by-system would just nest a lone system under an extra dir.
93    let scope_layout = if ctrl.scope.is_none() || resolved.len() == 1 {
94        ScopeLayout::Flat
95    } else {
96        ScopeLayout::BySystem
97    };
98
99    // Resolve period_id before allocating any disk state so an invalid
100    // --period rejects without leaving a half-formed run dir behind.
101    let period_id = match &opts.period_id {
102        Some(supplied) => {
103            if matches!(ctrl.cadence, Cadence::Continuous) {
104                bail!(
105                    "control `{control_id}` has continuous cadence and does not have schedule periods; --period is not allowed"
106                );
107            }
108            // Canonicalize before validating and storing: coverage matches
109            // period ids by exact string against derive-minted spellings,
110            // so a verbatim `2026-Q3`/`2026-q04` would seal a run that can
111            // never satisfy its period.
112            let canonical = period::canonicalize(ctrl.cadence, supplied);
113            if period::bounds(ctrl.cadence, &canonical).is_none() {
114                bail!(
115                    "`{supplied}` is not a valid period id for cadence {:?}",
116                    ctrl.cadence
117                );
118            }
119            Some(canonical)
120        }
121        None => {
122            if matches!(ctrl.cadence, Cadence::Continuous) {
123                None
124            } else {
125                // period_id records the calendar period the work was
126                // done in, so anchor on `today`. Using `next_due` here
127                // would attribute a run on Sat 2026-05-02 (W18) to the
128                // upcoming Mon 2026-05-04 (W19), leaving the current
129                // week stuck Open in coverage.
130                period::derive(ctrl.cadence, today)
131            }
132        }
133    };
134
135    let run_dir = allocate_run_dir(&reg.root, control_id, today)?;
136    if matches!(scope_layout, ScopeLayout::BySystem) {
137        for sys in &resolved {
138            fs::create_dir_all(run_dir.join("by-system").join(&sys.name).join("raw"))?;
139        }
140    } else {
141        fs::create_dir_all(run_dir.join("raw"))?;
142    }
143
144    let registry_git_sha = git_head(&reg.root).with_context(|| {
145        format!(
146            "{} is not a git repository — `cd` into a checked-out registry, or `git init && git commit` if starting fresh",
147            reg.root.display()
148        )
149    })?;
150
151    // Resolve the runbook now (local-first, then bundled) so the prepare
152    // context can point the agent straight at it and pin its sha. Fail
153    // here rather than at finalize if the skill resolves to nothing.
154    let skill = {
155        let r = crate::skills::resolve(&reg.root, &ctrl.skill).ok_or_else(|| {
156            anyhow!(
157                "skill `{}` not found (no local skills/{}.md and not bundled)",
158                ctrl.skill,
159                ctrl.skill
160            )
161        })?;
162        SkillRef {
163            name: r.name,
164            source: r.source.as_str().to_string(),
165            sha256: r.sha256,
166        }
167    };
168
169    let ctx = PrepareContext {
170        schema_version: SCHEMA_VERSION,
171        control_id: control_id.to_string(),
172        run_id: run_id_from_dir(&run_dir)?,
173        run_dir: run_dir.clone(),
174        started_at: now,
175        skill,
176        operator: opts.operator.clone(),
177        note: opts.note.clone(),
178        scope_layout,
179        resolved_scope: resolved,
180        registry_git_sha,
181        period_id,
182    };
183
184    let json = serde_json::to_vec_pretty(&ctx)?;
185    atomic_write(&run_dir.join(PREPARE_FILE), &json)?;
186    // Sentinel last — once it exists, the run is officially pending.
187    atomic_write(&run_dir.join(PENDING_SENTINEL), b"")?;
188    Ok(ctx)
189}
190
191/// Re-emit the prepare context for a pending run. No-op idempotent
192/// helper for resuming after an interrupted agent session.
193pub fn resume(run_dir: &Path) -> Result<PrepareContext> {
194    let path = run_dir.join(PREPARE_FILE);
195    let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
196    let ctx: PrepareContext = serde_json::from_slice(&bytes)?;
197    Ok(ctx)
198}
199
200/// Tear down a pending run by sealing a failed manifest. Records the
201/// reason in `manifest.failure_reason` so the audit trail says why.
202/// Leaves any partial evidence on disk under the run dir.
203pub fn abort(reg: &LoadedRegistry, run_dir: &Path, reason: &str) -> Result<Manifest> {
204    let _lock = RootLock::acquire(&reg.root).context("acquire root lock")?;
205
206    let prepare: PrepareContext = read_json(&run_dir.join(PREPARE_FILE))?;
207    let ctrl = reg
208        .controls
209        .get(&prepare.control_id)
210        .ok_or_else(|| anyhow!("control `{}` not found", prepare.control_id))?;
211
212    let prior_run = prior_run_link(&reg.root, &prepare.control_id, &prepare.run_id)?;
213    let control_sha256 = sha256_for_control(&reg.root, &prepare.control_id)?;
214    let skill_sha256 = sha256_for_skill(&reg.root, &ctrl.skill)?;
215
216    let manifest = Manifest {
217        schema_version: SCHEMA_VERSION,
218        control_id: prepare.control_id.clone(),
219        run_id: prepare.run_id.clone(),
220        started_at: prepare.started_at,
221        completed_at: Utc::now(),
222        operator: prepare.operator.clone(),
223        agent: AgentInfo {
224            model: std::env::var("SECUNIT_AGENT_MODEL").unwrap_or_else(|_| "unknown".into()),
225            skill: ctrl.skill.clone(),
226            skill_sha256,
227            control_sha256,
228        },
229        registry_git_sha: prepare.registry_git_sha.clone(),
230        scope_layout: prepare.scope_layout,
231        resolved_scope: prepare.resolved_scope.clone(),
232        prior_run,
233        artifacts: Vec::new(),
234        by_system: Vec::new(),
235        status: RunOutcome::Failed,
236        failure_reason: Some(reason.to_string()),
237        draft_risks: Vec::new(),
238        draft_issues: Vec::new(),
239        external_links: Vec::new(),
240        period_id: prepare.period_id.clone(),
241    };
242
243    let bytes = serde_json::to_vec(&manifest)?;
244    atomic_write(&run_dir.join(MANIFEST_FILE), &bytes)?;
245    update_state(reg, &manifest)?;
246
247    let pending = run_dir.join(PENDING_SENTINEL);
248    if pending.exists() {
249        fs::remove_file(&pending)?;
250    }
251    Ok(manifest)
252}
253
254/// Hash every artifact, link the manifest to the prior run, atomically
255/// write `manifest.json`, update `state.json`, and remove the pending
256/// sentinel. Returns the sealed manifest.
257pub fn finalize(reg: &LoadedRegistry, run_dir: &Path) -> Result<Manifest> {
258    finalize_at(reg, run_dir, Utc::now())
259}
260
261/// Like [`finalize`], but with an injectable completion clock. The CLI
262/// path uses [`finalize`] (wall-clock); tests pin `completed_at` so the
263/// coverage math (on-time vs late) is deterministic instead of drifting
264/// with the calendar.
265pub fn finalize_at(
266    reg: &LoadedRegistry,
267    run_dir: &Path,
268    completed_at: DateTime<Utc>,
269) -> Result<Manifest> {
270    let _lock = RootLock::acquire(&reg.root).context("acquire root lock")?;
271
272    let prepare: PrepareContext = read_json(&run_dir.join(PREPARE_FILE))?;
273    let result: RunResult = read_json(&run_dir.join(RESULT_FILE))?;
274    if prepare.control_id != result.control_id || prepare.run_id != result.run_id {
275        bail!(
276            "result.json mismatches prepare.json (prepare={}/{}, result={}/{})",
277            prepare.control_id,
278            prepare.run_id,
279            result.control_id,
280            result.run_id,
281        );
282    }
283
284    // Skip the per-run metadata files when hashing.
285    let exclude = [PREPARE_FILE, RESULT_FILE, MANIFEST_FILE, PENDING_SENTINEL];
286    let hashed = hasher::hash_tree(run_dir, &exclude)?;
287
288    let mut artifacts: Vec<Artifact> = Vec::new();
289    let mut by_system_artifacts: BTreeMap<String, Vec<Artifact>> = BTreeMap::new();
290
291    for h in &hashed {
292        let art = Artifact {
293            path: h.path.clone(),
294            sha256: h.sha256.clone(),
295            bytes: h.bytes,
296        };
297        if let Some(sys) = h.path.strip_prefix("by-system/") {
298            // by-system/<name>/raw/<file>...
299            let name = sys.split('/').next().unwrap_or("").to_string();
300            by_system_artifacts.entry(name).or_default().push(art);
301        } else {
302            artifacts.push(art);
303        }
304    }
305
306    let by_system_blocks: Vec<BySystemBlock> = result
307        .by_system
308        .iter()
309        .map(|sr| BySystemBlock {
310            name: sr.name.clone(),
311            status: sr.status,
312            summary: None,
313            artifacts: by_system_artifacts.remove(&sr.name).unwrap_or_default(),
314        })
315        .collect();
316
317    // Anything captured under by-system that wasn't reflected in result.json
318    // becomes its own block too — we never silently drop hashed artifacts.
319    let mut extra_blocks: Vec<BySystemBlock> = by_system_artifacts
320        .into_iter()
321        .map(|(name, arts)| BySystemBlock {
322            name,
323            status: SystemOutcome::Complete,
324            summary: None,
325            artifacts: arts,
326        })
327        .collect();
328    let mut by_system_blocks = by_system_blocks;
329    by_system_blocks.append(&mut extra_blocks);
330    by_system_blocks.sort_by(|a, b| a.name.cmp(&b.name));
331
332    // Find the prior run for this control and link via its manifest sha.
333    let prior_run = prior_run_link(&reg.root, &prepare.control_id, &prepare.run_id)?;
334
335    let control_sha256 = sha256_for_control(&reg.root, &prepare.control_id)?;
336    let skill_sha256 = sha256_for_skill(&reg.root, &reg.controls[&prepare.control_id].skill)?;
337
338    let manifest = Manifest {
339        schema_version: SCHEMA_VERSION,
340        control_id: prepare.control_id.clone(),
341        run_id: prepare.run_id.clone(),
342        started_at: prepare.started_at,
343        completed_at,
344        operator: prepare.operator.clone(),
345        agent: AgentInfo {
346            model: std::env::var("SECUNIT_AGENT_MODEL").unwrap_or_else(|_| "unknown".into()),
347            skill: reg.controls[&prepare.control_id].skill.clone(),
348            skill_sha256,
349            control_sha256,
350        },
351        registry_git_sha: prepare.registry_git_sha.clone(),
352        scope_layout: prepare.scope_layout,
353        resolved_scope: prepare.resolved_scope.clone(),
354        prior_run,
355        artifacts,
356        by_system: by_system_blocks,
357        status: result.status,
358        failure_reason: None,
359        draft_risks: result.draft_risks.clone(),
360        draft_issues: result.draft_issues.clone(),
361        external_links: result.external_links.clone(),
362        period_id: prepare.period_id.clone(),
363    };
364
365    // Compact canonical JSON (no pretty-printing) so `jq`-style
366    // reformatting cannot silently break the chain hash. Operators who
367    // want to read a manifest pipe it through `jq .` on demand.
368    let bytes = serde_json::to_vec(&manifest)?;
369    atomic_write(&run_dir.join(MANIFEST_FILE), &bytes)?;
370
371    update_state(reg, &manifest)?;
372
373    let pending = run_dir.join(PENDING_SENTINEL);
374    if pending.exists() {
375        fs::remove_file(&pending)?;
376    }
377    Ok(manifest)
378}
379
380/// Pending run pointer: control id, run id, and the run dir.
381#[derive(Debug, Clone)]
382pub struct PendingRun {
383    pub control_id: String,
384    pub run_id: String,
385    pub run_dir: PathBuf,
386}
387
388/// Walk `<root>/evidence/` looking for `.run-pending` sentinels. A
389/// sentinel sitting next to a sealed `manifest.json` is treated as
390/// crash-recovery debris from a finalize that died after the manifest
391/// landed but before sentinel removal: it's silently swept rather than
392/// surfaced as a pending run (which would block fresh prepares for that
393/// control).
394pub fn list_pending(root: &Path) -> Result<Vec<PendingRun>> {
395    let mut out = Vec::new();
396    let evidence = root.join("evidence");
397    if !evidence.exists() {
398        return Ok(out);
399    }
400    // Layout depth: evidence/<y>/<q>/<cid>/<rid>/.run-pending = 5 below evidence.
401    for entry in walkdir::WalkDir::new(&evidence).max_depth(5) {
402        let entry = entry?;
403        if entry.file_name() == PENDING_SENTINEL {
404            let run_dir = entry.path().parent().unwrap().to_path_buf();
405            // Crash recovery: sealed manifest + stale sentinel → sweep.
406            if run_dir.join(MANIFEST_FILE).exists() {
407                let _ = fs::remove_file(entry.path());
408                continue;
409            }
410            let prepare_path = run_dir.join(PREPARE_FILE);
411            if prepare_path.exists() {
412                let prepare: PrepareContext = read_json(&prepare_path)?;
413                out.push(PendingRun {
414                    control_id: prepare.control_id,
415                    run_id: prepare.run_id,
416                    run_dir,
417                });
418            }
419        }
420    }
421    Ok(out)
422}
423
424// ---------- helpers --------------------------------------------------------
425
426fn allocate_run_dir(root: &Path, control_id: &str, today: NaiveDate) -> Result<PathBuf> {
427    let q = quarter_label(today);
428    let base = root
429        .join("evidence")
430        .join(today.year().to_string())
431        .join(&q)
432        .join(control_id);
433    fs::create_dir_all(&base)?;
434    let mut n = 1u32;
435    loop {
436        let id = format!("{}-run-{:03}", today, n);
437        let candidate = base.join(&id);
438        if !candidate.exists() {
439            fs::create_dir_all(&candidate)?;
440            return Ok(candidate);
441        }
442        n += 1;
443        if n > 999 {
444            bail!("run-id counter overflowed for {control_id} on {today}");
445        }
446    }
447}
448
449fn quarter_label(d: NaiveDate) -> String {
450    let q = (d.month() - 1) / 3 + 1;
451    format!("q{q}")
452}
453
454fn run_id_from_dir(dir: &Path) -> Result<String> {
455    Ok(dir
456        .file_name()
457        .ok_or_else(|| anyhow!("run dir has no basename"))?
458        .to_string_lossy()
459        .into_owned())
460}
461
462fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
463    let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
464    Ok(serde_json::from_slice(&bytes)?)
465}
466
467fn prior_run_link(root: &Path, control_id: &str, current_run_id: &str) -> Result<Option<PriorRun>> {
468    let evidence = root.join("evidence");
469    if !evidence.exists() {
470        return Ok(None);
471    }
472    let mut all_manifests: Vec<(String, PathBuf)> = Vec::new();
473    for entry in walkdir::WalkDir::new(&evidence) {
474        let entry = entry?;
475        if entry.file_name() != MANIFEST_FILE {
476            continue;
477        }
478        let dir = entry.path().parent().unwrap();
479        let id = dir
480            .file_name()
481            .and_then(|s| s.to_str())
482            .unwrap_or("")
483            .to_string();
484        // Restrict to manifests for the same control id.
485        let parent_control = dir
486            .parent()
487            .and_then(|p| p.file_name())
488            .and_then(|s| s.to_str())
489            .unwrap_or("");
490        if parent_control != control_id {
491            continue;
492        }
493        all_manifests.push((id, entry.path().to_path_buf()));
494    }
495    all_manifests.sort_by(|a, b| a.0.cmp(&b.0));
496    let prior = all_manifests
497        .into_iter()
498        .rfind(|(id, _)| id.as_str() < current_run_id);
499    match prior {
500        None => Ok(None),
501        Some((id, path)) => {
502            let sha = sha256_file(&path)?;
503            Ok(Some(PriorRun {
504                run_id: id,
505                manifest_sha256: sha,
506            }))
507        }
508    }
509}
510
511fn sha256_for_control(root: &Path, control_id: &str) -> Result<String> {
512    let path = root.join("controls").join(format!("{control_id}.yaml"));
513    sha256_file(&path).with_context(|| format!("hash control {}", path.display()))
514}
515
516fn sha256_for_skill(root: &Path, skill: &str) -> Result<String> {
517    crate::skills::resolve(root, skill)
518        .map(|r| r.sha256)
519        .ok_or_else(|| {
520            anyhow!("skill `{skill}` not found (no local skills/{skill}.md and not bundled)")
521        })
522}
523
524/// Resolve the registry repo's HEAD commit hex via gix. Errors when
525/// `root` isn't a git repo or HEAD can't be peeled (no commits yet,
526/// detached weirdness, etc) — `prepare` requires a real git sha to
527/// pin what the registry said at run time.
528///
529/// Public so `doctor` can use the *same* check `prepare` does: a repo that
530/// fails here is a repo `run prepare` will reject, so doctor's verdict is
531/// aligned with reality rather than just testing for a `.git` directory.
532pub fn git_head(root: &Path) -> Result<String> {
533    let repo = gix::open(root).context("open repo")?;
534    let head = repo.head().context("read HEAD")?;
535    let id = head.into_peeled_id().context("peel HEAD to a commit")?;
536    Ok(id.to_hex().to_string())
537}
538
539/// True if the working tree at `root` has uncommitted changes to *tracked*
540/// files (index vs. worktree vs. HEAD). Untracked files are not considered.
541/// Errors only when `root` is not a usable git repo.
542pub fn git_is_dirty(root: &Path) -> Result<bool> {
543    let repo = gix::open(root).context("open repo")?;
544    repo.is_dirty().context("compute worktree status")
545}
546
547fn update_state(reg: &LoadedRegistry, manifest: &Manifest) -> Result<()> {
548    let path = reg.root.join(STATE_FILE);
549    // Bail loudly on a corrupt state file rather than silently dropping
550    // every prior control's entry by replacing with default. Operators
551    // who genuinely want to reset state.json can remove it; finalize
552    // will then start fresh.
553    let mut state: crate::model::State = if path.exists() {
554        let bytes = fs::read(&path)?;
555        serde_json::from_slice(&bytes).with_context(|| {
556            format!(
557                "{} is corrupt; refusing to overwrite — remove it manually to reset state",
558                path.display()
559            )
560        })?
561    } else {
562        crate::model::State::default()
563    };
564
565    // Compute the next firing date as part of finalize so state.json is
566    // a useful cache for `secunit due` and downstream report skills,
567    // rather than a placeholder readers have to re-derive on every load.
568    // A complete run (dated by the run-id's YYYY-MM-DD prefix, not
569    // wall-clock completion time) satisfies the obligation the resolver
570    // reports for that day, so the cache advances to the first nominal
571    // firing after whichever is later: that obligation or the run date. Re-resolving
572    // at "run date + 1" instead hands the day after the run back for
573    // month-anchored cadences — the resolver's monthly/quarterly/
574    // semi-annual arms answer "when is the current period due", never
575    // "when is the next firing" — so a control run yesterday would read
576    // past-due today (issue #65). Taking the later of the two also keeps
577    // an early run from re-arming its own obligation: an annual control
578    // run on 07-20 against a 07-31 due date must advance to next year,
579    // not get 07-31 handed back.
580    let run_date = manifest
581        .run_id
582        .get(0..10)
583        .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
584    let next_due = run_date.and_then(|d| {
585        reg.controls.get(&manifest.control_id).and_then(|ctrl| {
586            let obligation = crate::registry::resolver::next_due(
587                ctrl,
588                &reg.schedule,
589                state.controls.get(&manifest.control_id),
590                d,
591                reg.config.weekly_default_weekday,
592            );
593            // Only a complete run satisfies the obligation. A failed or
594            // partial run leaves the due date standing, so the control
595            // stays in `secunit due` and escalates to overdue past its
596            // grace window instead of falling silent until next period.
597            // (Coverage agrees: a failed-only period is `failed`, not
598            // `satisfied`.)
599            if manifest.status != RunOutcome::Complete {
600                return obligation;
601            }
602            crate::registry::resolver::next_firing_after(
603                ctrl,
604                &reg.schedule,
605                obligation.map_or(d, |s| s.max(d)),
606                reg.config.weekly_default_weekday,
607            )
608        })
609    });
610
611    state.controls.insert(
612        manifest.control_id.clone(),
613        StateEntry {
614            last_run_id: Some(manifest.run_id.clone()),
615            last_run_path: Some(manifest_relative_path(&reg.root, manifest)),
616            last_run_at: Some(manifest.completed_at),
617            last_status: match manifest.status {
618                RunOutcome::Complete => RunStatus::Complete,
619                RunOutcome::Partial => RunStatus::InProgress,
620                RunOutcome::Failed => RunStatus::Failed,
621            },
622            next_due,
623        },
624    );
625    state.updated_at = Some(Utc::now());
626    let bytes = serde_json::to_vec_pretty(&state)?;
627    atomic_write(&path, &bytes)?;
628    Ok(())
629}
630
631fn manifest_relative_path(_root: &Path, manifest: &Manifest) -> String {
632    // Keep portability cheap: compute from known shape.
633    let q = {
634        // Re-derive from the run id (YYYY-MM-DD-run-NNN).
635        let date = manifest
636            .run_id
637            .get(0..10)
638            .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
639        date.map(quarter_label).unwrap_or_else(|| "q0".into())
640    };
641    let year = manifest.run_id.get(0..4).unwrap_or("0000");
642    format!(
643        "evidence/{year}/{q}/{cid}/{rid}/",
644        cid = manifest.control_id,
645        rid = manifest.run_id,
646    )
647}
648
649// quiet unused-import lint when sha256_bytes only appears in cfg(test).
650#[allow(dead_code)]
651fn _silence_unused() -> String {
652    sha256_bytes(b"")
653}