Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-build-thing/src/kernel_trim/veto.rs

//! **Row vetoes, one at a time, each with a receipt** (tunnr 1.1 decision
//! 2026-10-30 step 3, Heimdall tooling plan step 5).
//!
//! `kernel veto <row> CONFIG_X` is the only door a trim candidate takes into a
//! row, and it takes exactly ONE symbol per run:
//!
//! 1. refused by name before anything is built when the symbol is protected
//!    ([`ProtectList`]), when no coverage report was measured on the row's
//!    current fragment (or its suite was red), when the symbol was not measured
//!    never-executed, when anything that would leave with it EXECUTED, or when
//!    the static what-if is not clean;
//! 2. the vetoed kernel is composed beside the baseline and the inverse check
//!    must hold (everything listed gone, nothing else moved) — that gives the
//!    bzImage delta;
//! 3. the veto is written to the row's veto list ([`VetoStore`]: one
//!    `CONFIG_X receipt reason` line, which the recipe lands as a `=n`
//!    requirement on the row), and the row's FULL suite reruns on media built
//!    from the recipe;
//! 4. green keeps it (`applied`); red reverts the list and records a
//!    `refused` receipt. Either way the receipt
//!    (`.nornir/kernel-vetoes/<row>/receipts/<date>-<CONFIG_X>.json`) holds the
//!    fragment sha before and after, the bzImage sizes, the suite's stages, the
//!    date and the tool versions. `kernel vetoes <row>` lists them.
//!
//! The suite is behind [`SuiteRunner`] (a test hands back canned stages; the
//! live leg is [`ScriptSuite`], the row repo's own KVM script).

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow, bail, ensure};
use serde::{Deserialize, Serialize};

use super::coverage::{self, CoverageReport, ImageDump, KernelImage, KernelProbe, NotRun, ProtectList, Snapshot, StageResult, Status};
use super::{Composer, InverseCheck, RowKernel, TreeSizes, TrimModel, full, load};

/// `<repo>/.nornir/kernel-vetoes/<row>/` — the protect list and the receipts.
pub fn veto_dir(repo_root: &Path, row: &str) -> PathBuf {
    repo_root.join(".nornir").join("kernel-vetoes").join(row)
}

// ── the veto list ───────────────────────────────────────────────────────────

/// **One row veto**: `CONFIG_X <receipt> <reason…>`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VetoEntry {
    /// `CONFIG_X`.
    pub symbol: String,
    /// The receipt's file name.
    pub receipt: String,
    /// The why.
    pub reason: String,
}

/// Parse a veto list (`#` comments, blank lines ignored; one symbol once).
pub fn parse_vetoes(text: &str) -> Result<Vec<VetoEntry>> {
    let mut out: Vec<VetoEntry> = Vec::new();
    for (i, line) in text.lines().enumerate() {
        let l = line.trim();
        if l.is_empty() || l.starts_with('#') {
            continue;
        }
        let mut w = l.splitn(3, char::is_whitespace).filter(|x| !x.is_empty());
        let symbol = w.next().unwrap_or("").to_string();
        ensure!(symbol.starts_with("CONFIG_") && symbol.len() > 7, "veto list line {}: {symbol:?} is not a CONFIG_* symbol", i + 1);
        let receipt = w.next().ok_or_else(|| anyhow!("veto list line {}: {symbol} names no receipt", i + 1))?.to_string();
        let reason = w.next().unwrap_or("").trim().to_string();
        ensure!(!out.iter().any(|e| e.symbol == symbol), "veto list line {}: {symbol} is vetoed twice", i + 1);
        out.push(VetoEntry { symbol, receipt, reason });
    }
    Ok(out)
}

/// The header every veto list starts with.
pub const VETO_LIST_HEADER: &str = "\
# Row kernel vetoes — written by `kernel veto <row> CONFIG_X` (edda kernel_trim::veto), one per run,
# each kept only after the row's full suite stayed green; the receipt is in
# .nornir/kernel-vetoes/<row>/receipts/. Format: CONFIG_X <receipt> <reason>.
";

/// Render a veto list.
pub fn render_vetoes(entries: &[VetoEntry]) -> String {
    let mut s = VETO_LIST_HEADER.to_string();
    for e in entries {
        s.push_str(&format!("{} {} {}\n", e.symbol, e.receipt, e.reason));
    }
    s
}

/// **The list beside its receipts** (KTOOL fix 9): every line of `list_text`
/// must name an `applied` receipt in `<repo_root>/.nornir/kernel-vetoes/<row>/receipts/`
/// for this row and symbol, measured on the CURRENT row: the receipt's before
/// and after fragments are the row's merged fragment without this line and the
/// lines after it, and with this line added, as `run_veto` measured them. A
/// line without one is refused by name (`[veto-no-receipt]`,
/// `[veto-not-applied]`, `[veto-receipt-mismatch]`, `[veto-stale]`).
pub fn check_list(repo_root: &Path, row: &RowKernel, list_text: &str) -> Result<()> {
    let entries = parse_vetoes(list_text)?;
    for i in 0..entries.len() {
        check_line(repo_root, row, &entries, i)?;
    }
    Ok(())
}

/// The row as it was before `entries[from..]` landed: their `=n` rows taken off
/// the kernel edge (a row whose recipe has not landed them is unchanged).
pub fn row_without(row: &RowKernel, entries: &[VetoEntry]) -> RowKernel {
    let mut r = row.clone();
    if let Some(reqs) = r.chosen.requirements.as_mut() {
        for e in entries {
            let tag = format!("row kernel veto (receipt {})", e.receipt);
            if let Some(rows) = reqs.rows.get_mut(&e.symbol) {
                rows.retain(|(v, q)| !(*v == crate::kernel_requires::KconfigValue::N && q.reason.starts_with(&tag)));
                if rows.is_empty() {
                    reqs.rows.remove(&e.symbol);
                }
            }
        }
    }
    r
}

/// One line of [`check_list`]: `entries[i]` against its receipt.
pub fn check_line(repo_root: &Path, row: &RowKernel, entries: &[VetoEntry], i: usize) -> Result<()> {
    let e = &entries[i];
    let sym = &e.symbol;
    let path = veto_dir(repo_root, &row.row).join("receipts").join(&e.receipt);
    let text = std::fs::read_to_string(&path).map_err(|err| {
        anyhow!("[veto-no-receipt] {sym} on row {}: no receipt {} ({err}) — a veto line is written only by `kernel veto {} {sym}`, with its receipt", row.row, path.display(), row.row)
    })?;
    let r: VetoReceipt = serde_json::from_str(&text).map_err(|err| anyhow!("[veto-no-receipt] {sym} on row {}: {} is not a {} receipt: {err}", row.row, path.display(), VetoReceipt::SCHEMA))?;
    if r.schema != VetoReceipt::SCHEMA || r.row != row.row || r.symbol != *sym {
        bail!("[veto-receipt-mismatch] {sym} on row {}: {} is {} for {} {}", row.row, path.display(), r.schema, r.row, r.symbol);
    }
    if r.verdict != VetoVerdict::Applied {
        bail!("[veto-not-applied] {sym} on row {}: receipt {} says {:?} ({}) — only an applied veto rides the build", row.row, e.receipt, r.verdict, r.reason);
    }
    // The row as `run_veto` saw it: without this line and the ones after it.
    let base = row_without(row, &entries[i..]);
    let extra = if r.fragment_extra.is_empty() {
        // Receipts before `fragment_extra` (2026-11-03): the symbol and what left with it.
        std::iter::once(sym).chain(&r.leaving).map(|x| format!("# {} is not set\n", full(x))).collect::<String>()
    } else {
        r.fragment_extra.clone()
    };
    let before = nornir_hash::sha256_hex(base.fragment("")?.as_bytes());
    let after = nornir_hash::sha256_hex(base.fragment(&extra)?.as_bytes());
    if before != r.fragment_sha256_before || after != r.fragment_sha256_after {
        bail!(
            "[veto-stale] {sym} on row {}: receipt {} was measured on fragment {} -> {}, the row is {} -> {} now — the row moved under the veto; rerun `kernel veto {} {sym}` on the current row, or drop the line",
            row.row,
            e.receipt,
            &r.fragment_sha256_before[..12.min(r.fragment_sha256_before.len())],
            &r.fragment_sha256_after[..12.min(r.fragment_sha256_after.len())],
            &before[..12],
            &after[..12],
            row.row
        );
    }
    Ok(())
}

/// A veto list beside its receipts: `<repo>/.nornir/kernel-vetoes/<row>/vetoes.list`
/// (the store a `--repo` veto lands in: the other repo's own row directory).
pub struct RowDirVetoStore {
    /// The repo root.
    pub repo_root: PathBuf,
}

/// The list's file name in the row directory.
pub const ROW_VETO_LIST: &str = "vetoes.list";

impl RowDirVetoStore {
    /// The file of `row`.
    pub fn path(&self, row: &str) -> PathBuf {
        veto_dir(&self.repo_root, row).join(ROW_VETO_LIST)
    }
}

impl VetoStore for RowDirVetoStore {
    fn read(&self, row: &str) -> Result<String> {
        let p = self.path(row);
        match std::fs::read_to_string(&p) {
            Ok(t) => Ok(t),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
            Err(e) => Err(e).with_context(|| format!("read {}", p.display())),
        }
    }

    fn write(&self, row: &str, text: &str) -> Result<()> {
        let p = self.path(row);
        let dir = veto_dir(&self.repo_root, row);
        std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
        std::fs::write(&p, text).with_context(|| format!("write {}", p.display()))
    }
}

/// The `=n` requirements a recipe lands for a veto list — the veto rides the
/// row's kernel edge like any other requirement, so a feature that needs the
/// symbol refuses it by name.
pub fn veto_requirements(text: &str) -> Result<Vec<crate::kernel_requires::KconfigReq>> {
    Ok(parse_vetoes(text)?
        .into_iter()
        .map(|e| crate::kernel_requires::KconfigReq::n(&e.symbol, &format!("row kernel veto (receipt {}): {}", e.receipt, e.reason)))
        .collect())
}

/// **Where a row's veto list lives** (the recipe reads it; the verb writes it).
pub trait VetoStore {
    /// The list's text (empty when there is none).
    fn read(&self, row: &str) -> Result<String>;
    /// Replace the list.
    fn write(&self, row: &str, text: &str) -> Result<()>;
}

/// A veto list per row as `<dir>/<row>.list`.
pub struct DirVetoStore {
    /// The directory.
    pub dir: PathBuf,
}

impl DirVetoStore {
    /// The file of `row`.
    pub fn path(&self, row: &str) -> PathBuf {
        self.dir.join(format!("{row}.list"))
    }
}

impl VetoStore for DirVetoStore {
    fn read(&self, row: &str) -> Result<String> {
        let p = self.path(row);
        match std::fs::read_to_string(&p) {
            Ok(t) => Ok(t),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
            Err(e) => Err(e).with_context(|| format!("read {}", p.display())),
        }
    }

    fn write(&self, row: &str, text: &str) -> Result<()> {
        let p = self.path(row);
        std::fs::create_dir_all(&self.dir).with_context(|| format!("create {}", self.dir.display()))?;
        std::fs::write(&p, text).with_context(|| format!("write {}", p.display()))
    }
}

// ── the suite ───────────────────────────────────────────────────────────────

/// One suite run asked for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SuiteRequest {
    /// The row.
    pub row: String,
    /// The suite (`bridge`).
    pub suite: String,
    /// The probe kernel, for a coverage run.
    pub probe: Option<KernelProbe>,
    /// Where the run writes (logs, saved images).
    pub out: PathBuf,
}

/// A saved kernel image, as the suite reports it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotFile {
    /// The VM.
    pub node: String,
    /// The stage it was saved after.
    pub stage: String,
    /// The virtual address of byte 0 (`_text`).
    pub base: u64,
    /// The file.
    pub path: PathBuf,
}

/// **What one suite run reports.**
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SuiteRun {
    /// Every stage, in order.
    pub stages: Vec<StageResult>,
    /// The checks of the full suite it did not run.
    pub not_run: Vec<NotRun>,
    /// Saved kernel images (a coverage run).
    pub snapshots: Vec<SnapshotFile>,
    /// The medium's `vmlinux`.
    pub vmlinux: Option<PathBuf>,
    /// The medium's kernel `.config`.
    pub kernel_config: Option<PathBuf>,
    /// The medium's `bzImage`.
    pub bz_image: Option<PathBuf>,
}

impl SuiteRun {
    /// Every stage green (and at least one ran)?
    pub fn green(&self) -> bool {
        !self.stages.is_empty() && self.stages.iter().all(|s| s.passed)
    }
}

/// **Run a row's check suite** (the live leg is [`ScriptSuite`]).
pub trait SuiteRunner {
    /// Build the row's media (with the probe, when asked), boot them, run the
    /// checks, report.
    fn run(&self, req: &SuiteRequest) -> Result<SuiteRun>;
}

/// Parse a suite's report lines (everything else is the script's own log):
///
/// ```text
/// STAGE <name> PASS|FAIL [detail…]
/// NOTRUN <name> <why…>
/// SNAPSHOT <node> <stage> <base-hex> <path>
/// VMLINUX <path> | KCONFIG <path> | BZIMAGE <path>
/// ```
pub fn parse_suite_output(text: &str) -> Result<SuiteRun> {
    let mut r = SuiteRun::default();
    for line in text.lines() {
        let mut w = line.split_whitespace();
        match w.next() {
            Some("STAGE") => {
                let name = w.next().ok_or_else(|| anyhow!("STAGE line without a name: {line:?}"))?.to_string();
                let passed = match w.next() {
                    Some("PASS") => true,
                    Some("FAIL") => false,
                    other => bail!("STAGE {name}: {other:?} is not PASS|FAIL"),
                };
                r.stages.push(StageResult { name, passed, detail: w.collect::<Vec<_>>().join(" ") });
            }
            Some("NOTRUN") => {
                let name = w.next().ok_or_else(|| anyhow!("NOTRUN line without a name: {line:?}"))?.to_string();
                r.not_run.push(NotRun { name, why: w.collect::<Vec<_>>().join(" ") });
            }
            Some("SNAPSHOT") => {
                let (Some(node), Some(stage), Some(base), Some(path)) = (w.next(), w.next(), w.next(), w.next()) else {
                    bail!("SNAPSHOT wants <node> <stage> <base-hex> <path>: {line:?}");
                };
                let base = u64::from_str_radix(base.trim_start_matches("0x"), 16).with_context(|| format!("SNAPSHOT base {base:?}"))?;
                r.snapshots.push(SnapshotFile { node: node.into(), stage: stage.into(), base, path: path.into() });
            }
            Some("VMLINUX") => r.vmlinux = w.next().map(PathBuf::from),
            Some("KCONFIG") => r.kernel_config = w.next().map(PathBuf::from),
            Some("BZIMAGE") => r.bz_image = w.next().map(PathBuf::from),
            _ => {}
        }
    }
    Ok(r)
}

/// **The live suite**: the row repo's own script, run as
/// `bash <script> <row> <suite> <probe|off> <out>`; its stdout is kept in
/// `<out>/suite.log` and parsed by [`parse_suite_output`]. The script owns the
/// KVM (and the host's KVM gate); this side only reads what it reports.
pub struct ScriptSuite {
    /// The script.
    pub script: PathBuf,
    /// Its working directory (the repo root).
    pub cwd: PathBuf,
}

impl SuiteRunner for ScriptSuite {
    fn run(&self, req: &SuiteRequest) -> Result<SuiteRun> {
        std::fs::create_dir_all(&req.out).with_context(|| format!("create {}", req.out.display()))?;
        let probe = req.probe.map(|p| p.word()).unwrap_or("off");
        let out = std::process::Command::new("bash")
            .arg(&self.script)
            .args([req.row.as_str(), req.suite.as_str(), probe])
            .arg(&req.out)
            .current_dir(&self.cwd)
            .stderr(std::process::Stdio::inherit())
            .output()
            .with_context(|| format!("run {}", self.script.display()))?;
        let text = String::from_utf8_lossy(&out.stdout).to_string();
        std::fs::write(req.out.join("suite.log"), &text).ok();
        let r = parse_suite_output(&text)?;
        if r.stages.is_empty() {
            bail!(
                "{} exited {} and reported no STAGE — the suite did not run (log: {})",
                self.script.display(),
                out.status,
                req.out.join("suite.log").display()
            );
        }
        Ok(r)
    }
}

// ── the coverage run ────────────────────────────────────────────────────────

/// **`kernel coverage <row> --run <suite>`** — boot the probe kernel through
/// the suite, read every saved image, map to `CONFIG_*`, store the report.
pub fn run_coverage(row: &RowKernel, suite_name: &str, suite: &dyn SuiteRunner, repo_root: &Path, out: &Path, tool: &str) -> Result<(CoverageReport, PathBuf)> {
    let req = SuiteRequest { row: row.row.clone(), suite: suite_name.to_string(), probe: Some(KernelProbe::Gcov), out: out.to_path_buf() };
    let run = suite.run(&req)?;
    let vmlinux = run.vmlinux.clone().ok_or_else(|| anyhow!("the suite reported no VMLINUX — the probe kernel's vmlinux is needed to read its counters"))?;
    let kconfig = run.kernel_config.clone().ok_or_else(|| anyhow!("the suite reported no KCONFIG"))?;
    ensure!(!run.snapshots.is_empty(), "the suite saved no kernel image (no SNAPSHOT line)");
    let img = KernelImage::read(&vmlinux)?;
    let src = vmlinux.parent().ok_or_else(|| anyhow!("{} has no parent", vmlinux.display()))?;
    let sizes = TreeSizes::read(src).with_context(|| format!("read the built tree {}", src.display()))?;
    let mut snaps = Vec::new();
    for s in &run.snapshots {
        let dump = ImageDump::read(&s.path, s.base)?;
        let counts = coverage::walk_gcov(&img, &dump).with_context(|| format!("{}:{} ({})", s.node, s.stage, s.path.display()))?;
        snaps.push(Snapshot { node: s.node.clone(), stage: s.stage.clone(), counts });
    }
    let mut kernel = row.chosen.clone();
    kernel.probe = Some(KernelProbe::Gcov);
    let fragment = row.fragment("")?;
    let probe_config = std::fs::read_to_string(&kconfig).with_context(|| format!("read {}", kconfig.display()))?;
    let r = coverage::report(coverage::Measured {
        row: &row.row,
        kernel: &kernel,
        fragment: &fragment,
        probe_config: &probe_config,
        sizes: &sizes,
        snapshots: &snaps,
        suite: suite_name,
        stages: run.stages.clone(),
        not_run: run.not_run.clone(),
        tool: tool.to_string(),
    })?;
    let p = coverage::store(repo_root, &r)?;
    // The images are big and derived; the report is the record.
    for s in &run.snapshots {
        std::fs::remove_file(&s.path).ok();
    }
    Ok((r, p))
}

// ── candidates with coverage ────────────────────────────────────────────────

/// A trim candidate with its coverage.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoverageCandidate {
    /// The static candidate.
    pub candidate: super::Candidate,
    /// The symbol's own coverage verdict.
    pub status: Status,
    /// Why it (or something leaving with it) is protected.
    pub protected: Option<String>,
    /// Symbols leaving with it that EXECUTED under the suite.
    pub executed_along: Vec<String>,
}

impl CoverageCandidate {
    /// Never executed, unprotected, nothing executed leaves with it.
    pub fn veto_ready(&self) -> bool {
        self.status == Status::NeverExecuted && self.protected.is_none() && self.executed_along.is_empty()
    }
}

/// **The candidates, with coverage** — each candidate's what-if read against
/// the report and the protect list.
pub fn annotate(model: &TrimModel, c: &super::Candidates, cov: &CoverageReport, protect: &ProtectList) -> Vec<CoverageCandidate> {
    c.rows
        .iter()
        .map(|cand| {
            let w = model.what_if(std::slice::from_ref(&cand.symbol), false);
            let status = cov.symbol(&cand.symbol).map(|s| s.status).unwrap_or(Status::Unmeasured);
            let mut protected = protect.why(&cand.symbol);
            let mut executed_along = Vec::new();
            for g in w.leaving.iter().chain(&w.declared_leaving) {
                if protected.is_none() {
                    protected = protect.why(g).map(|why| format!("{g} leaves with it ({why})"));
                }
                if cov.symbol(g).map(|s| s.status) == Some(Status::Executed) {
                    executed_along.push(g.clone());
                }
            }
            CoverageCandidate { candidate: cand.clone(), status, protected, executed_along }
        })
        .collect()
}

/// The human table of annotated candidates, top `n`.
pub fn render_annotated(rows: &[CoverageCandidate], n: usize) -> String {
    let ready = rows.iter().filter(|r| r.veto_ready()).count();
    let mut s = format!("# with coverage: {ready} of {} candidates are veto-ready (never executed, unprotected, nothing executed leaves with them)\n", rows.len());
    s.push_str(&format!("{:<4} {:<40} {:>12} {:<15} {}\n", "#", "symbol", "obj bytes", "coverage", "note"));
    for (i, r) in rows.iter().take(n).enumerate() {
        let b = r.candidate.estimate.map(|e| e.object_bytes.to_string()).unwrap_or_else(|| "-".into());
        let note = if let Some(p) = &r.protected {
            format!("protected: {p}")
        } else if !r.executed_along.is_empty() {
            format!("executed along: {}", r.executed_along.join(" "))
        } else if r.veto_ready() {
            "veto-ready".to_string()
        } else {
            String::new()
        };
        s.push_str(&format!("{:<4} {:<40} {:>12} {:<15} {}\n", i + 1, r.candidate.symbol, b, r.status.word(), note));
    }
    s
}

// ── the veto ────────────────────────────────────────────────────────────────

pub use nordisk_kernel_evidence::veto::{VetoReceipt, VetoVerdict};

/// What the veto run needs besides the row.
pub struct VetoContext<'a> {
    /// The row repo's root (`.nornir/` lives there).
    pub repo_root: &'a Path,
    /// The row's veto list.
    pub store: &'a dyn VetoStore,
    /// The static composer (bzImage before/after, inverse check).
    pub composer: &'a dyn Composer,
    /// The suite.
    pub suite: &'a dyn SuiteRunner,
    /// The compose work dir.
    pub work: &'a Path,
    /// The suite's output dir.
    pub out: &'a Path,
    /// Tool lines for the receipt.
    pub tools: Vec<String>,
}

/// **Apply ONE veto, rerun the suite, write the receipt.**
pub fn run_veto(row: &RowKernel, symbol: &str, suite_name: &str, cx: &VetoContext<'_>) -> Result<(VetoReceipt, PathBuf)> {
    let sym = full(symbol);
    let old = cx.store.read(&row.row)?;
    let mut entries = parse_vetoes(&old)?;
    ensure!(!entries.iter().any(|e| e.symbol == sym), "{sym} is already vetoed on row {}", row.row);
    let protect = ProtectList::load(cx.repo_root, &row.row)?;
    if let Some(why) = protect.why(&sym) {
        bail!("[veto-protected] {sym} is on row {}'s protect list — {why}", row.row);
    }
    let fragment = row.fragment("")?;
    let cov = coverage::load_for(cx.repo_root, &row.row, &fragment)?.ok_or_else(|| {
        anyhow!(
            "[veto-no-coverage] no coverage report measured on row {}'s current fragment ({}) — run `kernel coverage {} --run <suite>` first",
            row.row,
            &nornir_hash::sha256_hex(fragment.as_bytes())[..12],
            row.row
        )
    })?;
    ensure!(cov.green(), "[veto-red-coverage] the coverage run of row {} was RED — a veto is never chosen on a red suite", row.row);
    match cov.symbol(&sym).map(|s| s.status) {
        Some(Status::NeverExecuted) => {}
        Some(Status::Executed) => {
            let by = cov.symbol(&sym).map(|s| s.covered_by.join(" ")).unwrap_or_default();
            bail!("[veto-executed] {sym} EXECUTED under the suite ({by}) — not a veto candidate");
        }
        Some(Status::Unmeasured) => bail!("[veto-unmeasured] {sym} has no object of its own with counters — no evidence, no veto"),
        None => bail!("[veto-not-live] {sym} is not on in row {}'s probe kernel", row.row),
    }

    // Static: the what-if must be clean, nothing executed or protected may leave.
    let base = row.compose(cx.composer, cx.work, "")?;
    let l = load(row, base)?;
    let reqs = row.chosen.requirements.clone().unwrap_or_default();
    let mut model = TrimModel::new(&l.graph, &l.fragment, &l.config, &l.base, &reqs);
    if let Some(s) = &l.sizes {
        model = model.with_sizes(s, Some(l.bz_bytes));
    }
    let mut w = model.what_if(std::slice::from_ref(&sym), true);
    // The join (crate::syscall_join): a veto that takes a MEASURED call's
    // kernel code is refused by that call's name, like any other drop, and
    // so is one that takes a POSSIBLE call's (a program's graph.json: the
    // code can reach it), with the call's path as the protect reason.
    if let Some(m) = crate::syscall_join::MeasuredRow::load(cx.repo_root, &row.row)? {
        let map = crate::syscall_join::SyscallMap::cached(&l.composed.src, &cx.work.join("syscall-map"), &row.chosen.arch, &row.chosen.version)?;
        w.join_syscalls(&map, &m);
    }
    if !w.is_clean() {
        bail!("[veto-what-if] the what-if is not clean:\n{}", w.render());
    }
    let mut unmeasured_along = Vec::new();
    for g in w.leaving.iter().chain(&w.declared_leaving) {
        if let Some(why) = protect.why(g) {
            bail!("[veto-protected] {g} would leave with {sym} and is protected — {why}");
        }
        match cov.symbol(g).map(|s| s.status) {
            Some(Status::Executed) => bail!("[veto-executed] {g} would leave with {sym} and EXECUTED under the suite"),
            Some(Status::NeverExecuted) => {}
            _ => unmeasured_along.push(g.clone()),
        }
    }
    let extra = w.fragment_lines();
    let after = row.compose(cx.composer, cx.work, &extra)?;
    let read = |p: &Path| std::fs::read_to_string(p).with_context(|| format!("read {}", p.display()));
    let after_bz = std::fs::metadata(&after.bz_image).map(|m| m.len()).unwrap_or(0);
    let ic = InverseCheck::of(&w, &l.config, &read(&after.config)?, l.bz_bytes, after_bz);
    ensure!(ic.holds(), "[veto-inverse] the recomposed kernel does not match the what-if:\n{}", ic.render());

    // Apply ONE veto, rerun the full suite.
    let date = chrono::Utc::now();
    let receipt_name = format!("{}-{}.json", date.format("%Y-%m-%dT%H%M%SZ"), sym);
    entries.push(VetoEntry {
        symbol: sym.clone(),
        receipt: receipt_name.clone(),
        reason: format!("never executed under suite {} ({} B bzImage)", suite_name, -ic.delta()),
    });
    cx.store.write(&row.row, &render_vetoes(&entries))?;
    let req = SuiteRequest { row: row.row.clone(), suite: suite_name.to_string(), probe: None, out: cx.out.to_path_buf() };
    let run = match cx.suite.run(&req) {
        Ok(r) => r,
        Err(e) => {
            cx.store.write(&row.row, &old)?;
            return Err(e.context(format!("the suite did not run — the veto of {sym} was reverted")));
        }
    };
    let mut reason = String::new();
    let mut green = run.green();
    if !green {
        let red: Vec<&str> = run.stages.iter().filter(|s| !s.passed).map(|s| s.name.as_str()).collect();
        reason = format!("suite {suite_name} RED with the veto: {}", red.join(" "));
    }
    if green {
        if let Some(k) = &run.kernel_config {
            let c = crate::kconfig_graph::ConfigView::parse(&read(k)?);
            if c.is_set(super::bare(&sym)) {
                green = false;
                reason = format!("the rerun's medium still has {sym}=y — the veto did not reach the medium");
            }
        }
    }
    let verdict = if green {
        reason = format!("never executed under suite {suite_name}; suite green with the veto");
        VetoVerdict::Applied
    } else {
        cx.store.write(&row.row, &old)?;
        VetoVerdict::Refused
    };
    let receipt = VetoReceipt {
        schema: VetoReceipt::SCHEMA.to_string(),
        row: row.row.clone(),
        symbol: sym.clone(),
        verdict,
        reason,
        date: date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
        kernel: row.chosen.name.clone(),
        version: row.chosen.version.clone(),
        fragment_sha256_before: nornir_hash::sha256_hex(fragment.as_bytes()),
        fragment_sha256_after: nornir_hash::sha256_hex(row.fragment(&extra)?.as_bytes()),
        bz_image_before: l.bz_bytes,
        bz_image_after: after_bz,
        bz_image_delta: ic.delta(),
        medium_bz_image: run.bz_image.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len()),
        leaving: w.leaving.iter().chain(&w.declared_leaving).cloned().collect(),
        inverse_holds: ic.holds(),
        coverage_report: coverage::report_path(cx.repo_root, &row.row, &cov.fragment_sha256).strip_prefix(cx.repo_root).map(|p| p.display().to_string()).unwrap_or_default(),
        coverage_runs: cov.snapshots.clone(),
        unmeasured_along,
        suite: suite_name.to_string(),
        stages: run.stages.clone(),
        not_run: run.not_run.clone(),
        tools: cx.tools.clone(),
        fragment_extra: extra.clone(),
    };
    let dir = veto_dir(cx.repo_root, &row.row).join("receipts");
    std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
    let p = dir.join(&receipt_name);
    let mut text = serde_json::to_string_pretty(&receipt)?;
    text.push('\n');
    std::fs::write(&p, text).with_context(|| format!("write {}", p.display()))?;
    Ok((receipt, p))
}

/// **`kernel vetoes <row>`** — every receipt, oldest first.
pub fn list_receipts(repo_root: &Path, row: &str) -> Result<Vec<(PathBuf, VetoReceipt)>> {
    let dir = veto_dir(repo_root, row).join("receipts");
    let mut out = Vec::new();
    let rd = match std::fs::read_dir(&dir) {
        Ok(r) => r,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
        Err(e) => return Err(e).with_context(|| format!("read {}", dir.display())),
    };
    for e in rd.flatten() {
        let p = e.path();
        if p.extension().and_then(|x| x.to_str()) != Some("json") {
            continue;
        }
        let r: VetoReceipt = serde_json::from_str(&std::fs::read_to_string(&p)?).with_context(|| format!("parse {}", p.display()))?;
        out.push((p, r));
    }
    out.sort_by(|a, b| a.0.cmp(&b.0));
    Ok(out)
}

/// The listing, with each line of the row's list BACKED or UNBACKED
/// ([`check_line`]).
pub fn render_receipts_checked(repo_root: &Path, row: &RowKernel, rows: &[(PathBuf, VetoReceipt)], list: &[VetoEntry]) -> String {
    let mut s = render_receipts(&row.row, rows, list);
    for i in 0..list.len() {
        match check_line(repo_root, row, list, i) {
            Ok(()) => s.push_str(&format!("BACKED {}: applied receipt on the current row\n", list[i].symbol)),
            Err(e) => s.push_str(&format!("UNBACKED {}: {e}\n", list[i].symbol)),
        }
    }
    s
}

/// The listing, with the row's current veto list.
pub fn render_receipts(row: &str, rows: &[(PathBuf, VetoReceipt)], list: &[VetoEntry]) -> String {
    let mut s = format!("# kernel vetoes of row {row}: {} in the list, {} receipt(s)\n", list.len(), rows.len());
    for e in list {
        s.push_str(&format!("vetoed {} ({})\n", e.symbol, e.receipt));
    }
    for (p, r) in rows {
        s.push_str(&format!("{}  [{}]\n", r.line(), p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default()));
    }
    s
}

#[cfg(test)]
mod tests;