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.rs

//! **Built-Linux-kernel build target** — the typed descriptor a ⚒ Build Thing
//! variant renders to compile a REAL vanilla Linux kernel inside a toolchain
//! container, producing a `bzImage` + its `.config` + a pin file of sha256s.
//!
//! Where an [`IsoBuild`](crate::iso::IsoBuild) masters a medium around a kernel
//! the constellation already has, and a [`VmBuild`](crate::vm::VmBuild) boots
//! one, a [`KernelBuild`] is where that kernel COMES FROM: fetch the pinned
//! kernel.org tarball (host-side, pure Rust), merge a **preset** (what is IN the
//! kernel) plus an optional **hardening profile** over a declared **base**
//! (`defconfig` or `tinyconfig`), optionally run a **SAT minify pass** over the
//! Kconfig DAG, and drive `make bzImage` in a container — the container may
//! shell and may run python (that is its purpose); the host stays pure-Rust
//! orchestration (the zero-shell law holds where it applies).
//!
//! This sentence said "merge a named hardening profile over `defconfig`" for a
//! day after the preset axis landed, which is the same defect
//! [`KernelBuild::command_line`] carried: a second description of what gets
//! built, naming a base the declaration may never use.
//!
//! This descriptor is *data* (pure serde, dependency-light). It materializes:
//! * the merged kconfig fragment ([`merged_fragment`] over [`merge_kconfig`],
//!   a pure function over kconfig text);
//! * the in-container build script ([`build_script`] — `merge_config.sh` +
//!   `olddefconfig` + a canary check that every requested option SURVIVED, then
//!   `make -j bzImage`);
//! * the container run request ([`KernelRunRequest`]: pinned image ref, one
//!   workdir bind mount, a bounded timeout);
//! * the artifact set + pin record ([`KernelPin`]) provenance seals over.
//!
//! > **Honest scope.** The descriptor, validation, merge, script, orchestration
//! > ([`build_kernel_with`] over the [`TarballFetcher`] / [`KernelContainerRunner`]
//! > seams) and the pin/receipt shape are REAL and fully tested with fakes — no
//! > network, no daemon. The LIVE legs ([`UreqFetcher`], [`DraupnirKernelRunner`]
//! > over draupnir's `backend-oci` bollard engine) compile under the
//! > `kernel-build` feature and are exercised only by the `#[ignore]`d heavy
//! > integration test (`tests/kernel_build_live.rs`) — a real kernel compile is
//! > never smuggled into a default `cargo test`. That test's `#[ignore]` string
//! > still says "30+ min"; MEASURED on this constellation it is **235 s** for
//! > `normal` and **78–103 s** for the small presets. The estimate was written
//! > from fear, before anything had been built.
//!
//! # The hardening profiles
//!
//! First-class, committed IN THIS CRATE (one authority, [`HardeningProfile`]):
//!
//! | profile | fragment(s) | what it is |
//! |---|---|---|
//! | `vanilla` | `src/kconfig/base.config` | defconfig + the constellation's MEASURED boot contract: virtio blk/pci/net builtin, TUN builtin, vfat + cp437 NLS builtin (the install record's `IO charset iso8859-1 not found` lesson), EFI stub + 8250 serial, ISO-9660 builtin |
//! | `hardened` | base + `src/kconfig/hardened.config` | a KSPP-style overlay ON TOP of vanilla: HARDENED_USERCOPY, INIT_ON_ALLOC, KASLR, lockdown LSM, freelist hardening, `MODULES=n` (feasible here — the appliances are measured to load zero modules; the fragment documents each choice's cost and the MODULE_SIG_FORCE fallback) |
//! | `k8s-hardened` | base + `hardened.config` + `src/kconfig/k8s-hardened.config` | `hardened` with ONE decision reversed: `BPF_SYSCALL=y` + `BPF_JIT`, `BPF_JIT_ALWAYS_ON`, `BPF_UNPRIV_DEFAULT_OFF` — a Kubernetes node's cgroup v2 device controller is a BPF program, so a node cannot have the veto (the fragment prices it) |
//!
//! # Declaration
//!
//! ```toml
//! [[build.kernel]]
//! name = "korp-kernel-hardened"
//! version = "6.12.4"
//! # REQUIRED — a kernel fetched unpinned is refused by name:
//! tarball_sha256 = "8b8bb37e4ddbe4bc9b62931d29e0a58fda706a58bbebeba549e2274db34bb122"
//! profile = "hardened"                    # or "vanilla" (default)
//! # source_url defaults to the kernel.org CDN for `version`:
//! #   https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.4.tar.xz
//! # extra repo-relative kconfig fragment, merged LAST (optional):
//! kconfig_fragment = "kernel/extra.config"
//! # toolchain container (debian:13/trixie family per the glibc-law notes);
//! # pin it by digest so the build environment is content-addressed too:
//! toolchain_image = "docker.io/library/debian:trixie"
//! toolchain_image_sha256 = "0d8caf1a2b2312b1b76a7c26527b545ea9b5479bb95e2e97a6b3b0dcc4bd2f4c"
//! timeout_secs = 7200                     # the bound; overrun is a loud refusal
//! # the kconfig STARTING POINT the fragments merge over (default "defconfig"):
//! base = "tinyconfig"                     # start at ~nothing; the fragment is
//!                                         # then the WHOLE positive set and every
//!                                         # missing dependency refuses BY NAME
//! # the bounds — a build container is a GUEST, never a host process:
//! mem_limit_mb = 8192                     # podman --memory; 0 is REFUSED
//! cpus = 8                                # podman --cpus, AND the `make -j` width
//! # MINIFY — the SAT pass over the Kconfig DAG (off by default):
//! solver = "klocalizer"                   # needs an image carrying kmax
//! solver_timeout_secs = 1800              # its OWN bound; overrun is a refusal
//! ```
//!
//! # MINIFY — the SAT pass over the Kconfig DAG
//!
//! `solver = "klocalizer"` runs kmax's solver in the SAME bounded container,
//! between the fragment merge and `olddefconfig`, and hands `olddefconfig` a
//! `.config` whose `depends on` / `select` chains are already closed. The
//! expensive half — the `kextract`+`kclause` formula, ~20 MB, ~46 s — is
//! content-addressed in the same shared cache the tarball uses
//! ([`kclause_cache_entry`]), and the solved dependency closure is written back
//! as `deps-<version>.config`, a fragment an operator commits. See
//! [`solver`](KernelBuild::solver) for what it measured, and for the one thing
//! it CANNOT do.
//!
//! # The bounded-guest law
//!
//! 2026-08-20, measured and expensive: an unbounded build on this constellation
//! reached 33 GB, the OOM killer took the service, and the box went down. So a
//! kernel build declares [`mem_limit_mb`](KernelBuild::mem_limit_mb) and
//! [`cpus`](KernelBuild::cpus), `0` is a NAMED refusal rather than "unlimited",
//! `-j` is derived from `cpus` and never from the host's `nproc`, and the two
//! ride draupnir's ordinary `--cpus`/`--memory` builders — no second spelling.
//!
//! `kind = "kernel"` on a `[[build.variant]]` row is the equally-legal field
//! spelling (the [vocabulary](crate#the-vocabulary)); `[[build.kernel]]` is
//! permanent sugar for it, exactly like `iso` / `installer`.

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;

use crate::provenance::Artifact;

/// The committed `vanilla` base fragment — the constellation's measured boot
/// contract, merged over `defconfig` for EVERY profile.
pub const BASE_FRAGMENT: &str = include_str!("kconfig/base.config");
/// The committed `hardened` (KSPP-style) overlay, merged ON TOP of
/// [`BASE_FRAGMENT`] for the `hardened` profile.
pub const HARDENED_FRAGMENT: &str = include_str!("kconfig/hardened.config");
/// The committed `k8s-hardened` overlay, merged ON TOP of [`HARDENED_FRAGMENT`]
/// for the `k8s-hardened` profile: the KSPP-style overlay with exactly one
/// decision reversed — `bpf(2)` is compiled in (JIT-only, unprivileged loads
/// off by default), because cgroup v2's device controller is a BPF program.
pub const K8S_HARDENED_FRAGMENT: &str = include_str!("kconfig/k8s-hardened.config");
/// The committed `profile` overlay — ftrace's function profiler, merged ONLY
/// when `profile_run = true`. A kernel carrying this is a MEASURING kernel: it
/// instruments every function entry, which costs text and time, and nothing in
/// the tree ships it.
pub const PROFILE_FRAGMENT: &str = include_str!("kconfig/profile.config");

/// The committed `minimal` overlay — what is LEFT of the linker's knife after
/// the arch was measured. Dead-code elimination is gated on
/// `HAVE_LD_DEAD_CODE_DATA_ELIMINATION`, which only arm, mips, riscv and powerpc
/// select, so on the one arch this kind wires (`x86_64`) `minimal = true` is a
/// NAMED REFUSAL rather than a no-op. The fragment carries only `EXPERT`, the
/// dependency the real option needs wherever it exists, and is kept for the day
/// this kind wires an arch that has it.
pub const MINIMAL_FRAGMENT: &str = include_str!("kconfig/minimal.config");
/// The kconfig symbol full clang LTO rides on. NOT in [`MINIMAL_FRAGMENT`]: it
/// exists only under `LLVM=1`, so it is set by the toolchain axis, never by a
/// fragment that a gcc build would then silently drop.
pub const LTO_SYMBOL: &str = "CONFIG_LTO_CLANG_FULL=y";
/// The wired kernel toolchains. `gcc` is the kernel's default; `clang` renders
/// `LLVM=1` on every make line AND is the only one that can carry LTO.
pub const WIRED_TOOLCHAINS: &[&str] = &["gcc", "clang"];

/// The committed dependency closure for the `tinyconfig` starting point —
/// merged FIRST (before the boot contract) by every preset that starts there.
/// Every line was produced by a canary refusal, not by reading Kconfig.
pub const TINYCONFIG_DEPS_FRAGMENT: &str = include_str!("kconfig/deps-tinyconfig.config");
/// The committed `nuked` overlay — what a tunnr-class appliance is, stated as
/// absence (no gfx, no sound, no input, no USB, no wireless, no legacy storage).
/// Merged LAST over the `slim` set by [`Preset::Nuked`].
pub const NUKED_FRAGMENT: &str = include_str!("kconfig/nuked.config");

/// **The nordisk kernel toolchain image name** — the ONE image every step of the
/// kernel kind runs in (compile, minify, LTO, probe-boot). Built through the
/// image kind from [`KERNEL_TOOLCHAIN_CONTAINERFILE`], then PINNED BY DIGEST in
/// the declaration; a build on this image declares no packages and therefore
/// runs `--network none`.
pub const KERNEL_TOOLCHAIN_IMAGE: &str = "nordisk-kernel-toolchain";

/// The committed Containerfile for [`KERNEL_TOOLCHAIN_IMAGE`]. ONE authority,
/// beside the kconfig fragments, for the same reason: a build environment that
/// is `apt-get`ed at build time is not pinned, it is hoped for.
pub const KERNEL_TOOLCHAIN_CONTAINERFILE: &str =
    include_str!("containerfiles/kernel-toolchain.Containerfile");

/// **The instant [`KERNEL_TOOLCHAIN_CONTAINERFILE`] is minted at**, read out of
/// its own `ENV SOURCE_DATE_EPOCH=` line.
///
/// # Why this is a parse and not a second constant
///
/// The number has to be in TWO places to do its job: inside the image (so
/// CPython emits hash-based `.pyc` files instead of mtime-stamped ones, and so
/// anything else that honours `SOURCE_DATE_EPOCH` stops reading the wall clock)
/// and on the BUILDER (`podman build --timestamp`, or podman's `timestamp`
/// build parameter — the image config's `created` field and the layer file
/// timestamps are the builder's to write, and no Dockerfile instruction can
/// reach them). Two places, one fact: so exactly one of them may be the writer,
/// and it is the Containerfile, because that is the file a reader of the recipe
/// is looking at. LAW 5 — a parse, not a twin.
///
/// MEASURED, oden 2026-08-30 (lane AV): mint this Containerfile twice into two
/// fresh empty stores WITHOUT handing the builder this number and the two mints
/// disagree —
/// `359d4c48b6f8…` vs `8086d7ee318d…` — while WITH it, four mints spread over
/// fifteen minutes all produced `3f632569f198…`. It is load-bearing, and that
/// is why it is refused rather than defaulted when the line is absent.
pub fn kernel_toolchain_source_date_epoch() -> Result<i64> {
    source_date_epoch_of(KERNEL_TOOLCHAIN_CONTAINERFILE)
}

/// The `ENV SOURCE_DATE_EPOCH=<seconds>` line of a Containerfile, as a number.
/// Split out from [`kernel_toolchain_source_date_epoch`] so the parse — the
/// absent line, the non-numeric value — is provable without a Containerfile of
/// its own. Since 2026-09-05 it serves BOTH toolchain Containerfiles (the cargo
/// one reads it through
/// [`crate::cargo_container::cargo_toolchain_source_date_epoch`]), which is why
/// the messages no longer say "kernel".
pub(crate) fn source_date_epoch_of(containerfile: &str) -> Result<i64> {
    let raw = containerfile
        .lines()
        .map(str::trim)
        .find_map(|l| l.strip_prefix("ENV SOURCE_DATE_EPOCH="))
        .ok_or_else(|| {
            anyhow!(
                "this toolchain Containerfile has no `ENV SOURCE_DATE_EPOCH=<seconds>` \
                 line. That line is the ONE writer of the instant the image is minted at: the \
                 builder is handed it as `podman build --timestamp`, and without it two mints \
                 of the same recipe get two different image ids (measured 2026-08-30) — so a \
                 default here would silently turn a reproducible pin back into a minting event."
            )
        })?;
    let v = raw.split_whitespace().next().unwrap_or("").trim_matches('"');
    v.parse::<i64>().map_err(|e| {
        anyhow!(
            "this toolchain Containerfile's `ENV SOURCE_DATE_EPOCH={v}` is not a whole \
             number of seconds since the epoch: {e}"
        )
    })
}

/// **Every tool the toolchain image must carry for a kernel row that runs EVERY
/// step**, and the step that needs it — `(tool, feature)`.
///
/// DERIVED, since lane AF (2026-08-30), from
/// [`crate::toolchain_requires::kernel_tool_requirements`], which is the
/// closure a kernel row actually places on the toolchain image behind its
/// `toolchain` edge. It was a flat `const` here, and the const was the whole
/// argument for making the toolchain a real SURFACE: every line of it is a
/// requirement with a reason, and being a const it was UNCONDITIONAL (a
/// `toolchain = "gcc"` row with no solver still demanded clang, lld, kmax and
/// qemu) and owned by NOBODY (no `toolchain.why.toml` could say which step
/// wanted a tool). One writer now; this is the maximal row's answer, which is
/// what an image built once for every kernel row must satisfy.
pub fn kernel_toolchain_tools() -> Vec<(String, String)> {
    let every_step = KernelBuild {
        name: "every-step".into(),
        toolchain: "clang".into(),
        lto: true,
        solver: "kmax".into(),
        probe_timeout_secs: DEFAULT_PROBE_TIMEOUT_SECS,
        ..Default::default()
    };
    crate::toolchain_requires::kernel_tool_requirements(&every_step)
        .into_iter()
        .map(|(feature, req)| (req.tool, feature))
        .collect()
}

/// The default toolchain container: the debian:13 (trixie) family the glibc-law
/// notes settled on for build containers. Pin it per-declaration with
/// `toolchain_image_sha256` — a named default is a convenience, not a pin.
pub const DEFAULT_TOOLCHAIN_IMAGE: &str = "docker.io/library/debian:trixie";
/// The packages the default (bare-distro) toolchain image needs before it can
/// compile a kernel. A self-contained toolchain image declares
/// `toolchain_packages = []` and the build then runs airgapped (`--network none`).
pub const DEFAULT_TOOLCHAIN_PACKAGES: &[&str] =
    &["gcc", "make", "flex", "bison", "bc", "libelf-dev", "libssl-dev", "xz-utils"];
/// The default build bound: 2 hours. A kernel compile that has not produced a
/// `bzImage` in two hours on this constellation's hardware is a hang, and a hang
/// is a loud refusal, never a wait.
pub const DEFAULT_TIMEOUT_SECS: u64 = 2 * 60 * 60;
/// The one make target wired end-to-end (x86 boot image).
pub const DEFAULT_MAKE_TARGET: &str = "bzImage";
/// The one arch wired end-to-end.
pub const DEFAULT_ARCH: &str = "x86_64";

/// The named hardening profile a [`KernelBuild`] picks. The profiles are
/// first-class committed kconfig fragments in THIS crate (one authority) —
/// see the module docs table.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HardeningProfile {
    /// defconfig + the measured constellation boot contract ([`BASE_FRAGMENT`]).
    Vanilla,
    /// [`Vanilla`](Self::Vanilla) + the KSPP-style overlay ([`HARDENED_FRAGMENT`]).
    Hardened,
    /// [`Hardened`](Self::Hardened) + the one reversal a Kubernetes node needs
    /// ([`K8S_HARDENED_FRAGMENT`]: `bpf(2)`, JIT-only, unprivileged off).
    #[serde(rename = "k8s-hardened")]
    K8sHardened,
}

impl HardeningProfile {
    /// Parse the declared profile name. NOT total on purpose: a typo'd
    /// `"hardned"` silently degrading to vanilla would be a hardening claim the
    /// artifact does not honour — unknown names are refused (by the caller,
    /// naming the variant).
    pub fn parse(s: &str) -> Option<Self> {
        match s.trim().to_ascii_lowercase().as_str() {
            "" | "vanilla" => Some(HardeningProfile::Vanilla),
            "hardened" | "kspp" => Some(HardeningProfile::Hardened),
            "k8s-hardened" => Some(HardeningProfile::K8sHardened),
            _ => None,
        }
    }
    /// Stable label.
    pub fn as_str(&self) -> &'static str {
        match self {
            HardeningProfile::Vanilla => "vanilla",
            HardeningProfile::Hardened => "hardened",
            HardeningProfile::K8sHardened => "k8s-hardened",
        }
    }
    /// **Every profile** — what the settings tree offers and what a refusal of
    /// an unknown name lists, so the list and [`parse`](Self::parse) cannot
    /// drift apart (the [`Preset::ALL`] discipline).
    pub const ALL: [HardeningProfile; 3] =
        [HardeningProfile::Vanilla, HardeningProfile::Hardened, HardeningProfile::K8sHardened];
    /// The declared names, `vanilla · hardened · k8s-hardened` — the one
    /// spelling every "unknown profile" refusal prints.
    pub fn names() -> String {
        Self::ALL.map(|p| p.as_str()).join(" · ")
    }
    /// The committed fragments this profile contributes, merged AFTER the
    /// preset's (later wins), so hardening LAYERS on whatever contents were
    /// chosen and a hardened kernel never loses the preset's boot contract.
    ///
    /// The boot contract itself moved to [`Preset`] when the preset axis landed:
    /// this enum is now the HARDENING axis only, and `vanilla` honestly means
    /// "no hardening overlay" rather than secretly also meaning "the measured
    /// contract". The default declaration (`preset = "normal"`, `profile =
    /// "vanilla"`) still merges exactly `[BASE_FRAGMENT]`, and `hardened` still
    /// merges exactly `[BASE_FRAGMENT, HARDENED_FRAGMENT]` — asserted as a
    /// regression guard, because that byte-parity is what makes this a
    /// refactor rather than a behaviour change.
    pub fn fragments(&self) -> Vec<&'static str> {
        match self {
            HardeningProfile::Vanilla => vec![],
            HardeningProfile::Hardened => vec![HARDENED_FRAGMENT],
            // LAYERED, not copied: hardened's every line, then the reversal,
            // which replaces hardened's `BPF_SYSCALL` veto in place (later wins).
            HardeningProfile::K8sHardened => vec![HARDENED_FRAGMENT, K8S_HARDENED_FRAGMENT],
        }
    }
}

/// **What is IN the kernel** — the axis a person actually picks, orthogonal to
/// [`HardeningProfile`] (every preset × profile pair composes).
///
/// | preset | base | contents |
/// |---|---|---|
/// | [`Full`](Self::Full) | `defconfig` | the generic-hardware kernel, untouched — no constellation contract at all |
/// | [`Normal`](Self::Normal) | `defconfig` | + the MEASURED boot contract ([`BASE_FRAGMENT`]). The default, and what the appliances ship today |
/// | [`Slim`](Self::Slim) | `tinyconfig` | the contract and nothing else: everything measured, nothing generic |
/// | [`Nuked`](Self::Nuked) | `tinyconfig` | slim − gfx/sound/input/USB/wireless/legacy storage ([`NUKED_FRAGMENT`]): serial console, virtio-net, TUN, virtio-blk |
///
/// **Honest status, kept current — and this paragraph has now been wrong
/// TWICE, in opposite directions.** First it called `slim` and `nuked` unbuilt
/// experiments while implying `full` was measured (the reverse of the truth);
/// then, hours after that was corrected to "`full`: never built", `full` WAS
/// built and booted. A table of receipts inside a doc comment decays every time
/// somebody runs a build, and it is read as a receipt, which is worse than
/// nothing. It stays because the numbers are load-bearing — but the authority
/// is `workspace_nordisk/.nornir/kernel-trim-design.md` §12/§15/§17/§19, and if
/// the two disagree, the design doc is right and this is stale again.
///
/// | preset | built | probe-booted |
/// |---|---|---|
/// | `full` | 13 693 952 B / 246 s | PID 1 alive, console + vda — **`tun` ABSENT** |
/// | `normal` | 13 714 432 B / 235 s | PID 1 alive, console + tun + vda |
/// | `slim` | 1 811 456 B / 80 s | PID 1 alive, console + tun + vda |
/// | `slim` + `solver` | 1 389 568 B / 103 s warm | PID 1 alive, console + tun + vda |
/// | `nuked` | 1 725 440 B / 78 s | PID 1 alive, console + tun + vda |
///
/// All on linux 6.12.104, airgapped, in the nordisk toolchain container.
///
/// **`full` boots and is not an appliance kernel.** On plain `defconfig`
/// `CONFIG_TUN` is not builtin (it is a module, and `MODULES=y` there), so an
/// appliance initramfs — which has no modules — gets no TUN device and
/// therefore no userspace-WireGuard data plane. The probe reports that absence
/// by OBSERVATION (PID 1 calling `stat()` in the booted guest), not by reading
/// a `.config`. The gap from `full` to `normal` is 20 480 bytes, and those
/// bytes are the entire boot contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Preset {
    /// The generic-hardware kernel, no constellation contract.
    Full,
    /// `defconfig` + the measured boot contract. The default.
    Normal,
    /// `tinyconfig` + the measured boot contract.
    Slim,
    /// [`Slim`](Self::Slim) + the absence overlay ([`NUKED_FRAGMENT`]).
    Nuked,
}

impl Preset {
    /// Parse the declared preset name. NOT total, for the same reason
    /// [`HardeningProfile::parse`] is not: a typo'd `"slin"` silently building
    /// a generic kernel is the opposite of what was asked for.
    pub fn parse(s: &str) -> Option<Self> {
        match s.trim().to_ascii_lowercase().as_str() {
            "" | "normal" => Some(Preset::Normal),
            "full" => Some(Preset::Full),
            "slim" => Some(Preset::Slim),
            "nuked" => Some(Preset::Nuked),
            _ => None,
        }
    }
    /// Stable label.
    pub fn as_str(&self) -> &'static str {
        match self {
            Preset::Full => "full",
            Preset::Normal => "normal",
            Preset::Slim => "slim",
            Preset::Nuked => "nuked",
        }
    }
    /// **Every preset, in size order** — the named DEFAULT-BUNDLES the option
    /// axis offers (`--opt kernel.preset=<name>`). A preset is not a separate
    /// mechanism from the per-symbol rows: it is the bundle of defaults the
    /// rows start from, and this list is what a refusal of an unknown name
    /// prints, so the list and the parser cannot drift apart
    /// ([`parse`](Self::parse) is the one reader of the names).
    pub const ALL: [Preset; 4] = [Preset::Full, Preset::Normal, Preset::Slim, Preset::Nuked];
    /// The kconfig starting point this preset implies. A declaration may still
    /// name [`base`](KernelBuild::base) explicitly and override it — the
    /// combination is legal and sometimes exactly what an experiment wants
    /// (`slim` contents on a generic base), so it is resolved in ONE place
    /// ([`KernelBuild::base`]) rather than refused.
    pub fn base(&self) -> &'static str {
        match self {
            Preset::Full | Preset::Normal => "defconfig",
            Preset::Slim | Preset::Nuked => "tinyconfig",
        }
    }
    /// The committed fragments this preset contributes, in merge order.
    ///
    /// **`Full` contributes NOTHING, and that has a consequence worth stating
    /// where it is decided**: an empty fragment means `merge_config.sh -m` runs
    /// against an empty file and the canary loop reads ZERO lines. A `full`
    /// build is therefore structurally uncheckable by the canary — there is no
    /// declared option for an option to fail to be. That is correct (a preset
    /// with no contract has nothing to assert) and it is also why `full` is the
    /// preset a probe-boot has the most to say about: measured, it boots and has
    /// no `tun`, which no canary could ever have told us.
    pub fn fragments(&self) -> Vec<&'static str> {
        match self {
            Preset::Full => vec![],
            Preset::Normal => vec![BASE_FRAGMENT],
            // The closure comes FIRST: it only enables parents, so the measured
            // contract still has the last word over anything it also names.
            Preset::Slim => vec![TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT],
            Preset::Nuked => vec![TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT, NUKED_FRAGMENT],
        }
    }
}

fn default_toolchain_image() -> String {
    DEFAULT_TOOLCHAIN_IMAGE.to_string()
}
fn default_toolchain_packages() -> Vec<String> {
    DEFAULT_TOOLCHAIN_PACKAGES.iter().map(|s| s.to_string()).collect()
}
/// The default kconfig **starting point**: `defconfig` — the generic-hardware
/// kernel every distro starts from, which the profile fragments then add to.
pub const DEFAULT_BASE: &str = "defconfig";
/// The wired starting points. `tinyconfig` is the OTHER direction: it starts at
/// (almost) nothing, so the merged fragment becomes the WHOLE positive set and
/// the canary loop turns every missing dependency into a NAMED refusal instead
/// of a silent degrade — that refusal list IS how a minimal config is closed.
/// An unwired name is refused by name (never a silent `defconfig` fallback).
pub const WIRED_BASES: &[&str] = &["defconfig", "tinyconfig"];

/// The wired **minifiers** — the SAT pass over the Kconfig DAG (design §3's
/// second knife). `klocalizer` is kmax's solver: it reads the kernel's own
/// Kconfig through `kextract`/`kclause`, turns it into a z3 formula, and answers
/// "what else must be on for these symbols to be on". An unwired name is
/// refused BY NAME, exactly like every other axis here.
pub const WIRED_SOLVERS: &[&str] = &["klocalizer"];

/// The kmax release the toolchain image carries, and therefore HALF the formula
/// cache key: a kclause formula is a function of the tree AND of the extractor
/// that produced it, so two kmax versions must never share a cache entry.
pub const DEFAULT_SOLVER_VERSION: &str = "4.10";

/// The solver's own bound, seconds.
///
/// MEASURED 2026-08-23 on linux 6.12.104 in the nordisk toolchain image, stated
/// as END-TO-END WALLS because those are the only numbers that were actually
/// timed: a slim build with the solver takes **148 s cold** and **103 s warm**,
/// and the difference — ~45 s — is the kclause formula (kextract + kclause over
/// 17 704 symbols), which the cache then serves for free. What is left after
/// subtracting it is roughly constant either way, so the SOLVE itself is tens of
/// seconds, not the ~50 s this comment previously claimed for the warm case.
/// That number was a subtraction dressed up as a measurement, and it did not
/// reconcile with the arms beside it.
///
/// 1800 s is the end-to-end wall with an order of magnitude of room. An overrun
/// is a NAMED refusal (`KERNEL-SOLVER-REFUSED`, exit 91) — proven live: a 5 s
/// budget came back in 19 s naming the variant, the step, the knob and the
/// budget — never a container that sits there until the build timeout kills it
/// with no idea which step hung.
pub const DEFAULT_SOLVER_TIMEOUT_SECS: u64 = 1800;

/// The exit code the in-container script uses for a solver refusal — distinct
/// from the canary's 90, so the orchestrator can say WHICH step refused instead
/// of handing back an anonymous non-zero.
pub const SOLVER_REFUSED_EXIT: i64 = 91;

/// The marker the solver step prints before it exits [`SOLVER_REFUSED_EXIT`].
pub const SOLVER_REFUSED_MARKER: &str = "KERNEL-SOLVER-REFUSED";

/// The exit code the in-container CANARY uses when a line of the merged
/// fragment did not survive `olddefconfig` — the solver's twin, spelled once so
/// the orchestrator's named arm and the shell that raises it cannot drift.
pub const KERNEL_CONFIG_REFUSED_EXIT: i64 = 90;

/// The marker the canary prints per refused line before it exits
/// [`KERNEL_CONFIG_REFUSED_EXIT`] (the `-TOTAL` line carries it as a prefix too).
pub const KERNEL_CONFIG_REFUSED: &str = "KERNEL-CONFIG-REFUSED";

/// The default floor of FREE DISK a kernel build demands before it starts, MiB.
///
/// MEASURED 2026-08-22 on linux 6.12.104: a full `normal` build's workdir peaks
/// at **2.5 GB** (150 MB tarball + unpacked tree + objects + `bzImage`), a
/// `slim` one at **1.9 GB**. 6 GiB is that with room for a second variant and
/// the cache copy — a build that dies at 95% because the disk filled has spent
/// the whole compile to tell you something a `statvfs` knew before it started.
pub const DEFAULT_MIN_FREE_DISK_MB: u32 = 6144;

/// The default memory bound for a build container: 8 GiB.
///
/// **Why there is a bound at all** (2026-08-20, measured, expensive): an
/// unbounded build process on this constellation reached 33 GB, the OOM killer
/// took the service, and the box went down with it. A kernel compile is a
/// BOUNDED GUEST, never a host process that may grow until something dies.
/// `0` is not "unlimited" here — it is a refusal ([`KernelBuild::validate`]).
pub const DEFAULT_MEM_LIMIT_MB: u32 = 8192;
/// The default cpu bound for a build container: 8 cores. `make -j` is derived
/// from THIS, never from the host's `nproc` — a build must not size itself to
/// whichever machine happens to run it (that is how a laptop build swaps and a
/// 64-core box starves everything else).
pub const DEFAULT_BUILD_CPUS: u32 = 8;

fn default_preset() -> String {
    "normal".to_string()
}

fn is_default_preset(s: &str) -> bool {
    s == "normal"
}

fn default_mem_limit_mb() -> u32 {
    DEFAULT_MEM_LIMIT_MB
}

fn is_default_mem_limit_mb(m: &u32) -> bool {
    *m == DEFAULT_MEM_LIMIT_MB
}

fn default_build_cpus() -> u32 {
    DEFAULT_BUILD_CPUS
}

/// The probe's boot bound: 120 s. A kernel that has not reached PID 1 in two
/// minutes under TCG is not slow, it is stuck.
pub const DEFAULT_PROBE_TIMEOUT_SECS: u64 = 120;

fn default_toolchain() -> String {
    "gcc".to_string()
}

fn is_default_toolchain(s: &str) -> bool {
    s == "gcc"
}

fn default_probe_timeout_secs() -> u64 {
    DEFAULT_PROBE_TIMEOUT_SECS
}

fn default_solver_version() -> String {
    DEFAULT_SOLVER_VERSION.to_string()
}

fn is_default_solver_version(s: &str) -> bool {
    s == DEFAULT_SOLVER_VERSION
}

fn default_solver_timeout_secs() -> u64 {
    DEFAULT_SOLVER_TIMEOUT_SECS
}

fn is_default_solver_timeout(t: &u64) -> bool {
    *t == DEFAULT_SOLVER_TIMEOUT_SECS
}

fn is_default_probe_timeout(t: &u64) -> bool {
    *t == DEFAULT_PROBE_TIMEOUT_SECS
}

fn default_min_free_disk_mb() -> u32 {
    DEFAULT_MIN_FREE_DISK_MB
}

fn is_default_min_free_disk_mb(m: &u32) -> bool {
    *m == DEFAULT_MIN_FREE_DISK_MB
}

fn is_default_build_cpus(c: &u32) -> bool {
    *c == DEFAULT_BUILD_CPUS
}

fn default_timeout_secs() -> u64 {
    DEFAULT_TIMEOUT_SECS
}
fn default_make_target() -> String {
    DEFAULT_MAKE_TARGET.to_string()
}
fn default_arch() -> String {
    DEFAULT_ARCH.to_string()
}
fn default_profile() -> String {
    HardeningProfile::Vanilla.as_str().to_string()
}
fn is_default_toolchain_image(s: &str) -> bool {
    s == DEFAULT_TOOLCHAIN_IMAGE
}
fn is_default_toolchain_packages(v: &[String]) -> bool {
    v.iter().map(String::as_str).eq(DEFAULT_TOOLCHAIN_PACKAGES.iter().copied())
}
fn is_default_timeout(t: &u64) -> bool {
    *t == DEFAULT_TIMEOUT_SECS
}
fn is_default_make_target(s: &str) -> bool {
    s == DEFAULT_MAKE_TARGET
}
fn is_default_arch(s: &str) -> bool {
    s == DEFAULT_ARCH
}
fn is_default_profile(s: &str) -> bool {
    s == HardeningProfile::Vanilla.as_str()
}

/// **A declared kernel build** — one `[[build.kernel]]` row: a variant whose
/// artifact is a built Linux kernel (`bzImage` + `.config` + a [`KernelPin`]
/// of sha256s). See the module docs for the TOML shape.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KernelBuild {
    /// The declared handle (the variant name a human types).
    pub name: String,
    /// The upstream kernel version, e.g. `"6.12.4"`.
    pub version: String,
    /// Source tarball URL. Empty ⇒ derived from `version` on the kernel.org CDN
    /// ([`source_url`](Self::source_url)).
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub source_url: String,
    /// **REQUIRED**: the lowercase-hex sha256 of the source tarball. A kernel
    /// fetched unpinned is refused by name ([`validate`](Self::validate)).
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub tarball_sha256: String,
    /// The hardening profile name (`"vanilla"` default, `"hardened"` or
    /// `"k8s-hardened"`).
    /// Held as the declared string and resolved via [`HardeningProfile::parse`]
    /// so an unknown name is a NAMED refusal, never a silent vanilla downgrade.
    #[serde(default = "default_profile", skip_serializing_if = "is_default_profile")]
    pub profile: String,
    /// Extra repo-relative kconfig fragment, merged LAST (over the profile).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub kconfig_fragment: Option<String>,
    /// **The operator's kernel DECISION, in memory** — the veto fragment a
    /// resolved [`crate::kernel_features::KernelDecision`] rendered
    /// (`--opt kernel.<symbol>=off` through the pane's own `try_toggle` door),
    /// merged after even the repo's own `kconfig_fragment` so the person at the
    /// terminal has the last word. NOT part of the TOML shape (`#[serde(skip)]`):
    /// a declaration file states the repo's defaults, and this is precisely what
    /// was decided ON TOP of them for one build. Set via
    /// [`apply_decision`](Self::apply_decision), read via
    /// [`user_fragment_in`](Self::user_fragment_in), and hashed into
    /// [`canonical_bytes`](Self::canonical_bytes) — two different decisions must
    /// never share a content address.
    #[serde(skip)]
    pub decision_fragment: Option<String>,
    /// **What this kernel's CONSUMERS require of it, in memory** — the union the
    /// planner collected over every enabled feature of every consumer in the
    /// chain ([`crate::kernel_requires::KernelRequirements`], the forward
    /// index). Merged AFTER the preset/profile fragments and BEFORE the repo's
    /// veto file and the operator's decision; a contradiction at either side
    /// is a named refusal ([`apply_requirements`](Self::apply_requirements),
    /// [`merged_fragment`]). NOT part of the TOML shape, for the same reason
    /// `decision_fragment` is not: a declaration states the donor's defaults,
    /// this is what its consumers asked of it for one build. A requirement that
    /// repeats a fragment line merges to identical bytes, so a kernel whose
    /// consumers ask only what the preset already grants keeps its pin.
    #[serde(skip)]
    pub requirements: Option<crate::kernel_requires::KernelRequirements>,
    /// **A DEV-ONLY probe kernel** (`--opt kernel.coverage=gcov`, the measured
    /// kernel trim's coverage half, [`crate::kernel_trim::coverage`]): its
    /// fragment ([`crate::kernel_trim::coverage::KernelProbe::fragment`]) is
    /// merged after every other layer, it is hashed into
    /// [`canonical_bytes`](Self::canonical_bytes), and a build of it is refused
    /// BY NAME for a release or signed medium
    /// ([`crate::kernel_trim::coverage::refuse_probe_from_env`]). Not part of
    /// the TOML shape: no declaration ships a probe kernel.
    #[serde(skip)]
    pub probe: Option<crate::kernel_trim::coverage::KernelProbe>,
    /// The make target (only `bzImage` is wired).
    #[serde(default = "default_make_target", skip_serializing_if = "is_default_make_target")]
    pub make_target: String,
    /// The target arch (only `x86_64` is wired).
    #[serde(default = "default_arch", skip_serializing_if = "is_default_arch")]
    pub arch: String,
    /// The toolchain container image ([`DEFAULT_TOOLCHAIN_IMAGE`] default).
    #[serde(default = "default_toolchain_image", skip_serializing_if = "is_default_toolchain_image")]
    pub toolchain_image: String,
    /// Optional sha256 digest pin for the toolchain image; when set the
    /// effective reference is `<toolchain_image>@sha256:<digest>`.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub toolchain_image_sha256: String,
    /// Packages the container installs before compiling
    /// ([`DEFAULT_TOOLCHAIN_PACKAGES`] default). Empty ⇒ the image is
    /// self-contained and the build runs airgapped.
    #[serde(default = "default_toolchain_packages", skip_serializing_if = "is_default_toolchain_packages")]
    pub toolchain_packages: Vec<String>,
    /// The build bound in seconds ([`DEFAULT_TIMEOUT_SECS`] default). Overrun
    /// stops the container and refuses loudly.
    #[serde(default = "default_timeout_secs", skip_serializing_if = "is_default_timeout")]
    pub timeout_secs: u64,
    /// **What is in the kernel** ([`Preset`]): `full` / `normal` (default) /
    /// `slim` / `nuked`. Held as the declared string and resolved via
    /// [`Preset::parse`], so an unknown name is a NAMED refusal.
    #[serde(default = "default_preset", skip_serializing_if = "is_default_preset")]
    pub preset: String,
    /// The kconfig **starting point** override: `defconfig` or `tinyconfig`
    /// ([`WIRED_BASES`]). Unset (the normal case) means "whatever the preset
    /// implies" ([`Preset::base`]) — resolved in ONE place, [`base()`](Self::base).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base: Option<String>,
    /// The container **memory bound** in MiB ([`DEFAULT_MEM_LIMIT_MB`]).
    /// `0` is refused: unbounded is not a bound (see the const's measured why).
    #[serde(default = "default_mem_limit_mb", skip_serializing_if = "is_default_mem_limit_mb")]
    pub mem_limit_mb: u32,
    /// The container **cpu bound** in whole cores ([`DEFAULT_BUILD_CPUS`]), and
    /// therefore the `make -j` width. `0` is refused.
    #[serde(default = "default_build_cpus", skip_serializing_if = "is_default_build_cpus")]
    pub cpus: u32,
    /// **The shared source cache** — where fetched kernel tarballs live so the
    /// next build of ANY variant on the same pin does not re-download 150 MB.
    /// Unset ⇒ resolved from the environment ([`kernel_cache_dir`]). Absolute
    /// paths are fine here: this is a machine-local cache, not a repo input, and
    /// putting it on the big disk is exactly the point.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cache_dir: Option<String>,
    /// **Build a MEASURING kernel** ([`PROFILE_FRAGMENT`]): ftrace's function
    /// profiler, so a RUN can say which functions a workload actually executed.
    /// Never ship one — the instrumentation costs text and time. This is the
    /// only instrument that sees a RUN; LTO and dead-code elimination are static
    /// and know nothing about what was called.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub profile_run: bool,
    /// **The linker's knife**: layer [`MINIMAL_FRAGMENT`] over the preset.
    /// Orthogonal to preset and profile — it removes no FEATURE, only
    /// unreachable code inside the features already chosen, so it cannot cost a
    /// boot-contract line.
    ///
    /// REFUSED on `x86_64`, which is the only arch this kind wires: the option
    /// it exists to set does not exist there (see [`MINIMAL_FRAGMENT`]). On this
    /// arch the linker's knife is `toolchain = "clang"` with `lto = true` — and
    /// that one was measured to make a slim kernel ELEVEN PERCENT BIGGER, so it
    /// is a speed knife, not a size knife.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub minimal: bool,
    /// The compiler the kernel is built with ([`WIRED_TOOLCHAINS`]): `gcc`
    /// (default) or `clang`, which renders `LLVM=1`.
    #[serde(default = "default_toolchain", skip_serializing_if = "is_default_toolchain")]
    pub toolchain: String,
    /// Full clang LTO. REQUIRES `toolchain = "clang"` — refused otherwise BY
    /// NAME, because the symbol would not survive `olddefconfig` under gcc and
    /// the build would then fail the canary for a reason that has nothing to do
    /// with what was declared.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub lto: bool,
    /// **MINIFY — the SAT pass over the Kconfig DAG** ([`WIRED_SOLVERS`]).
    /// Empty (the default) means OFF: the build is exactly what it was, and the
    /// canary is the only oracle. `solver = "klocalizer"` runs kmax's solver in
    /// the same bounded container, BETWEEN the fragment merge and
    /// `olddefconfig`, and hands `olddefconfig` a `.config` that already closes
    /// every `depends on` / `select` chain the fragment's `=y` symbols need.
    ///
    /// An unwired name is a NAMED refusal — a typo'd solver silently building
    /// the un-minified kernel is the same silent degrade every other axis here
    /// refuses.
    ///
    /// **MEASURED 2026-08-23**, linux 6.12.104, airgapped, nordisk toolchain
    /// image, `-j8` / 8192 MiB, on dwarf:
    ///
    /// | arm | bzImage | wall | note |
    /// |---|---|---|---|
    /// | `slim`, no solver | 1 811 456 B | 80 s | the hand-closed baseline |
    /// | `slim` + `solver` | **1 389 568 B** | 148 s (formula COLD) | **23.3 % smaller**, and it BOOTS: PID 1 alive, console + tun + virtio-blk |
    /// | `tinyconfig`+`normal` + `solver`, hand closure REMOVED | 1 369 088 B | 106 s (formula warm) | canary-clean and a **BRICK** — see below |
    ///
    /// The formula is 19 949 986 bytes and costs ~46 s to build; every later
    /// build on the same pin gets it from the cache for nothing.
    ///
    /// **The limit, measured rather than assumed.** The solver closes only what
    /// the FRAGMENT NAMES. With the hand-closed fragment removed it found 13 of
    /// its 16 lines by itself and missed `BINFMT_ELF`, `MULTIUSER`, `UNIX` —
    /// exactly the three the canary cannot catch either, for the identical
    /// reason. That kernel passed every canary line and could not execute PID 1.
    /// So MINIFY is a knife, not an oracle: it makes a declared contract
    /// minimal, it does not discover an undeclared one. Probe-boot ([§15],
    /// [`probe_kernel_with`]) is still what says a kernel runs.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub solver: String,
    /// The kmax release the toolchain image carries ([`DEFAULT_SOLVER_VERSION`]).
    /// It is HALF the formula cache key, and it is CHECKED against what the
    /// container reports: a formula attributed to the wrong extractor is a
    /// cache entry that lies, which is worse than no cache at all.
    #[serde(default = "default_solver_version", skip_serializing_if = "is_default_solver_version")]
    pub solver_version: String,
    /// The solver's own bound in seconds ([`DEFAULT_SOLVER_TIMEOUT_SECS`]).
    /// Overrun prints [`SOLVER_REFUSED_MARKER`] and exits
    /// [`SOLVER_REFUSED_EXIT`], so a hung solve names ITSELF instead of riding
    /// the build's timeout and arriving as "the container died".
    #[serde(default = "default_solver_timeout_secs", skip_serializing_if = "is_default_solver_timeout")]
    pub solver_timeout_secs: u64,
    /// How long the probe boot may take, seconds. A boot that has not printed
    /// its marker in this long is a HANG, and a hang is a refusal.
    #[serde(default = "default_probe_timeout_secs", skip_serializing_if = "is_default_probe_timeout")]
    pub probe_timeout_secs: u64,
    /// The **free-disk floor** in MiB ([`DEFAULT_MIN_FREE_DISK_MB`]) checked
    /// BEFORE the fetch. `0` disables the check — and, stated plainly because
    /// this doc used to claim otherwise, NOTHING ON THE RECEIPT SAYS SO: the pin
    /// has no disk field, so a build that skipped the check and one that passed
    /// it are indistinguishable afterwards.
    ///
    /// **This check is necessary and NOT sufficient, measured the hard way.**
    /// It asks `statvfs`, which answers for the FILESYSTEM. On 2026-08-23 it saw
    /// 23 GiB free, passed, and the build died ninety seconds later on
    /// `Disk quota exceeded` — `EDQUOT`, a quota against the writing user, which
    /// free filesystem space says nothing about. Same blind spot for a storage
    /// driver's own limit and for a backing store that fills after the check.
    /// [`out_of_room`] catches what gets past this, at the only other place it
    /// can be caught: the container's own output.
    #[serde(default = "default_min_free_disk_mb", skip_serializing_if = "is_default_min_free_disk_mb")]
    pub min_free_disk_mb: u32,
    /// **Consumption edges** (`[build.kernel.edges.<feature>]`) — a root
    /// feature bound to another build thing's artifact, `repo/variant : typ`,
    /// type-checked at load against the donor's declaration (design f3, the
    /// same edge every kind carries). Empty for every existing declaration;
    /// the kernel's OWN axis (`preset` / `profile` / symbols) is not an edge.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub edges: std::collections::BTreeMap<String, crate::tree::EdgeDecl>,
}

impl Default for KernelBuild {
    fn default() -> Self {
        KernelBuild {
            name: String::new(),
            version: String::new(),
            source_url: String::new(),
            tarball_sha256: String::new(),
            profile: default_profile(),
            kconfig_fragment: None,
            decision_fragment: None,
            requirements: None,
            probe: None,
            make_target: default_make_target(),
            arch: default_arch(),
            toolchain_image: default_toolchain_image(),
            toolchain_image_sha256: String::new(),
            toolchain_packages: default_toolchain_packages(),
            timeout_secs: default_timeout_secs(),
            preset: default_preset(),
            base: None,
            mem_limit_mb: default_mem_limit_mb(),
            cpus: default_build_cpus(),
            cache_dir: None,
            min_free_disk_mb: default_min_free_disk_mb(),
            probe_timeout_secs: default_probe_timeout_secs(),
            solver: String::new(),
            solver_version: default_solver_version(),
            solver_timeout_secs: default_solver_timeout_secs(),
            profile_run: false,
            minimal: false,
            toolchain: default_toolchain(),
            lto: false,
            edges: std::collections::BTreeMap::new(),
        }
    }
}

fn is_hex64(s: &str) -> bool {
    s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}

impl KernelBuild {
    /// The declared [`Preset`], or `None` if the name is unknown (refused by
    /// [`validate`](Self::validate), naming the variant).
    pub fn preset(&self) -> Option<Preset> {
        Preset::parse(&self.preset)
    }

    /// **THE resolved kconfig starting point**: the explicit
    /// [`base`](Self::base) override when declared, else the preset's own
    /// ([`Preset::base`]). One resolution, used by the script, the receipt and
    /// the tests alike — two spellings of "what did we start from" is how a
    /// picture and a build come to disagree.
    pub fn base(&self) -> &str {
        if let Some(b) = self.base.as_deref() {
            return b;
        }
        self.preset().map(|p| p.base()).unwrap_or(DEFAULT_BASE)
    }

    /// Whether the MINIFY pass rides this build ([`solver`](Self::solver) names
    /// a wired solver). One resolution, so the script, the picture, the content
    /// id and the receipt cannot disagree about whether a kernel was minified.
    pub fn solves(&self) -> bool {
        !self.solver.is_empty()
    }

    /// `"LLVM=1 "` when the declaration asks for clang, empty for gcc — the ONE
    /// place the toolchain reaches a make line, so a clang build cannot end up
    /// half-clang (a `defconfig` generated by one compiler and a `bzImage` built
    /// by the other is not a kernel anybody measured).
    pub fn llvm_flag(&self) -> &'static str {
        if self.toolchain == "clang" { "LLVM=1 " } else { "" }
    }

    /// The resolved shared cache directory: the declared one, else
    /// [`kernel_cache_dir`]'s environment resolution.
    pub fn cache_dir(&self, repo_root: &Path) -> std::path::PathBuf {
        match self.cache_dir.as_deref() {
            Some(d) => std::path::PathBuf::from(d),
            None => kernel_cache_dir(repo_root),
        }
    }

    /// The effective source tarball URL: the declared one, or the kernel.org CDN
    /// derived from `version` (`v<major>.x/linux-<version>.tar.xz`).
    pub fn source_url(&self) -> String {
        if !self.source_url.is_empty() {
            return self.source_url.clone();
        }
        let major = self.version.split('.').next().unwrap_or("");
        format!(
            "https://cdn.kernel.org/pub/linux/kernel/v{major}.x/linux-{}.tar.xz",
            self.version
        )
    }

    /// The tarball's file name inside the workdir (the URL's last segment).
    pub fn tarball_file_name(&self) -> String {
        let url = self.source_url();
        url.rsplit('/')
            .next()
            .filter(|s| !s.is_empty())
            .map(str::to_string)
            .unwrap_or_else(|| format!("linux-{}.tar.xz", self.version))
    }

    /// The effective toolchain image reference — digest-pinned when a
    /// `toolchain_image_sha256` is declared.
    pub fn toolchain_image_ref(&self) -> String {
        if self.toolchain_image_sha256.is_empty() {
            return self.toolchain_image.clone();
        }
        // ★ CORRECTED 2026-08-23, and the first diagnosis was WRONG in a way
        // worth keeping. On 08-22 `localhost/nordisk-kernel-toolchain@sha256:…`
        // died on "pinging container registry localhost: connection refused",
        // and I concluded that digest pinning is registry-only. It is not: I had
        // pasted the IMAGE ID where a MANIFEST DIGEST belongs. They are both
        // 64 hex characters and they are different things.
        //
        //   image id       388a7816cde2…  `podman run <id>`            works
        //   manifest digest 85df1a16e5fc…  `podman run name@sha256:<d>` works
        //   image id AS a digest           name@sha256:<image id>       REGISTRY PULL, fails
        //
        // So `toolchain_image_sha256` should carry the MANIFEST digest
        // (`podman image inspect --format '{{.Digest}}'`), which is addressable
        // locally AND in a registry — one spelling, no special case. The empty
        // name path stays, because an image id is still a legal way to run a
        // local image, but it is the fallback and not the answer.
        if self.toolchain_image.is_empty() {
            return self.toolchain_image_sha256.clone();
        }
        format!("{}@sha256:{}", self.toolchain_image, self.toolchain_image_sha256)
    }

    /// **Why a digest-pinned toolchain image can be absent on a machine that
    /// has everything, and the exact command that fixes it.**
    ///
    /// ## What this used to say, and what changed on 2026-08-30
    ///
    /// `toolchain_image_sha256` names an image **we build ourselves**, from this
    /// crate's own embedded `kernel-toolchain.Containerfile`. Until lane AV that
    /// made it a per-BOX fact — two machines running the same `podman build` on
    /// the same Containerfile got two different digests, because the digest
    /// covers the built layers and those carried the build's own timestamps and
    /// whatever the package index said that day. **That is no longer true of
    /// this recipe.** MEASURED, oden 2026-08-30: with the base pinned by digest,
    /// apt reading the Debian snapshot the base image itself records, exact pip
    /// versions, `SOURCE_DATE_EPOCH`, no logs or caches left in the layer that
    /// makes them, and the builder handed the Containerfile's own instant, four
    /// mints into four fresh empty stores over fifteen minutes produced ONE
    /// image id (`3f632569f198…`) and ONE manifest digest (`78eaa55a651d…`).
    /// The gate is `tests/toolchain_images_live.rs`,
    /// `two_mints_of_the_kernel_toolchain_are_measured_against_each_other`, and
    /// it fails if that stops being so.
    ///
    /// So a pin that does not resolve now means one of three things, and they
    /// want different actions: the box has never minted this image (mint it, or
    /// stage the archive a courier brought — the offline path); the RECIPE moved
    /// since the pin was taken (re-pin, and it is a mechanical edit now, not a
    /// decision about which box is authoritative); or an input moved out from
    /// under a recipe that claims to pin it, which is what the gate above
    /// catches. On a machine that has simply never built it the run still dies
    /// as
    ///
    /// ```text
    /// nordisk-kernel-toolchain@sha256:f573ca0a…: image not known
    /// ```
    ///
    /// which says nothing about pins, nothing about which command mints one,
    /// and reads like a broken installation. A build cannot start until
    /// somebody re-pins by hand, and this string is how they find out how.
    ///
    /// `None` when there is no pin to be wrong about.
    ///
    /// # Is a `.gitignore`d per-box override file the honest fix?
    ///
    /// **No — it is an honest STOPGAP, and only if it is loud.** The pin exists
    /// so that two builds reporting the same provenance really did use the same
    /// toolchain. A local file the build silently prefers keeps every build
    /// green and makes exactly that guarantee unverifiable: the receipts still
    /// say `f573ca0a…` while the compiler is whatever this box happened to
    /// bake. That is the failure the pin was created to refuse, converted from
    /// a refusal into a silence.
    ///
    /// The real fix is that the digest must stop being per-box — **publish the
    /// toolchain image** to a registry the constellation pulls from, and the
    /// committed digest becomes true on every machine, which is also the only
    /// way an airgapped or a CI box can get it at all (see `#14`, the offline
    /// kernel path).
    ///
    /// ⚠ This paragraph once ended "failing that, pin the image's **inputs** …
    /// so the pin is reproducible by construction". Lane AT measured that and it
    /// was not enough: two mints 90 s apart on one box — same base layer, same
    /// mirror, same package versions, 0 files added or removed and 0 sizes
    /// changed — still produced two different image ids, because pinning the
    /// inputs removes the drift that comes from the WORLD and leaves the drift
    /// that comes from the CLOCK, and an image id is a digest over both. Lane AV
    /// then closed the second axis as well, and the four normalisations are each
    /// load-bearing: remove any ONE of `podman build --timestamp`,
    /// `ENV SOURCE_DATE_EPOCH`, the log/aux-cache scrub, or kmax's fixed build
    /// directory, and two mints disagree again (the four measured pairs are
    /// tabulated in the gate's doc comment). Both halves are needed; neither
    /// alone is worth anything.
    ///
    /// If an override lands before either of those, it must be (a) explicit —
    /// named on the command line or in a file this refusal names, never
    /// discovered; (b) **loud** — one row per build saying the declared pin was
    /// NOT the one used, and which was; and (c) carried into the build record,
    /// so the provenance says "overridden here" instead of quietly lying. Drift
    /// you can see is a different thing from drift you cannot.
    pub fn toolchain_repin_hint(&self) -> Option<String> {
        if self.toolchain_image_sha256.is_empty() || self.toolchain_image.is_empty() {
            return None;
        }
        Some(format!(
            "the toolchain image is digest-pinned to `{img}@sha256:{pin}`, and this box cannot \
             resolve it — so the run fails with `image not known` and NOT with anything about \
             pinning.\n\
             \n\
             Since 2026-08-30 that is NOT the old 'the digest is a per-box fact' problem: the \
             embedded kernel-toolchain.Containerfile is reproducible (measured — four mints \
             into four fresh empty stores, one image id and one manifest digest), so two \
             machines building it agree. Which leaves three causes, and they want different \
             actions:\n\
             \n\
             \x20 * this box has never MINTED the image — mint it (below), or stage the \
             archive a courier brought (`toolchain_archive::stage_toolchain_archive`);\n\
             \x20 * the RECIPE moved since the pin was taken — re-pin (below); it is a \
             mechanical edit now, not a decision about which box is authoritative;\n\
             \x20 * an input moved out from under a recipe that claims to pin it — run \
             `two_mints_of_the_kernel_toolchain_are_measured_against_each_other`, which \
             names the files.\n\
             \n\
             To mint and re-pin:\n\
             \n\
             \x20 1. build the image (once):  the `[[build.image]]` kind, or\n\
             \x20    `DraupnirKernelRunner::build_toolchain_image(<ctx>, \"{img}\")`\n\
             \x20 2. read its MANIFEST DIGEST (not its image id — both are 64 hex characters\n\
             \x20    and only the manifest digest works as `name@sha256:…`):\n\
             \x20      podman image inspect --format '{{{{.Digest}}}}' {img}\n\
             \x20 3. put those 64 hex characters (without the `sha256:` prefix) in\n\
             \x20    `toolchain_image_sha256` of the `[[build.kernel]]` row named '{name}' —\n\
             \x20    in the repository's `*-build-thing` CODE, which is the one writer.\n\
             \x20    Do NOT edit `.nornir/build-variants.toml`: since the emitter moved into\n\
             \x20    the engine the toml is EMITTED from that code and a parity test holds\n\
             \x20    the two byte-identical, so a hand edit is reverted by the next emit and\n\
             \x20    fails the gate meanwhile. Re-emit instead:\n\
             \x20      cargo run -p <repo>-build-thing -- emit\n\
             \n\
             A `.gitignore`d per-box override is a stopgap, not the fix: it keeps builds green \
             by making \"the same pin means the same toolchain\" unverifiable.\n\
             \n\
             ⚠ A manifest digest still does not survive a COURIER: `podman save`+`load` keeps \
             the image id and re-synthesises the manifest (measured 2026-08-30, lane AO). So \
             if this box has no route to a registry, carry the image as a file and pin its \
             ID — see `toolchain_archive::stage_toolchain_archive` and \
             .nornir/kernel-guide.md, \"An OFFLINE kernel build\".",
            img = self.toolchain_image,
            pin = self.toolchain_image_sha256,
            name = self.name,
        ))
    }

    /// The resolved [`HardeningProfile`], when the declared name is a known one.
    pub fn hardening_profile(&self) -> Option<HardeningProfile> {
        HardeningProfile::parse(&self.profile)
    }

    /// The build bound as a [`Duration`].
    pub fn timeout(&self) -> Duration {
        Duration::from_secs(self.timeout_secs)
    }

    /// Whether the container needs a network: only to `apt-get` the toolchain
    /// packages. A self-contained image (empty `toolchain_packages`) builds
    /// airgapped.
    pub fn needs_network(&self) -> bool {
        !self.toolchain_packages.is_empty()
    }

    /// Cargo variants this build chains — none: a kernel build compiles no
    /// sibling crate (the analogue of [`IsoBuild`](crate::iso::IsoBuild)'s
    /// `variant_deps`, empty here by construction).
    pub fn variant_deps(&self) -> Vec<&str> {
        Vec::new()
    }

    /// The one-line human rendering a picker shows (the analogue of
    /// [`IsoBuild::command_line`](crate::iso::IsoBuild::command_line)); `engine`
    /// names the container engine label.
    pub fn command_line(&self, engine: &str) -> String {
        format!(
            // ★ This line used to say `defconfig` and name only the HARDENING
            // profile, whatever the declaration actually asked for — so a `slim`
            // build was rendered to a human as starting from the generic kernel
            // it explicitly does not start from. That is the `picture-follows`
            // defect (dwarves 01b9012) reproduced inside this crate: a second
            // spelling of "what are we building" that can disagree with the
            // build. It now goes through the SAME resolution the script does.
            "{engine} run --rm -v <workdir>:/work {} sh -ec '{}{} + merge({}/{}){} + make {}-j {}'",
            self.toolchain_image_ref(),
            if self.lto { "LTO " } else { "" },
            self.base(),
            self.preset,
            self.profile,
            // The picture names the MINIFY pass when it rides: a solved kernel
            // and an unsolved one are different artifacts, and a rendering that
            // hid that is the `picture-follows` defect again.
            if self.solves() { format!(" + minify({})", self.solver) } else { String::new() },
            self.llvm_flag(),
            self.make_target,
        )
    }

    /// Validate the descriptor. Every refusal NAMES the kernel variant.
    pub fn validate(&self) -> Result<()> {
        if self.name.trim().is_empty() {
            return Err(anyhow!("kernel build: empty name"));
        }
        crate::tree::validate_edge_rows(&format!("kernel '{}'", self.name), None, &self.edges, &[])?;
        if self.version.trim().is_empty()
            || !self.version.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
        {
            return Err(anyhow!(
                "kernel '{}': `version` must be a dotted release like \"6.12.4\", got {:?}",
                self.name,
                self.version
            ));
        }
        // THE pin. A kernel fetched unpinned is refused BY NAME — the network's
        // answer to a URL is not an input a build may trust unverified.
        if self.tarball_sha256.is_empty() {
            return Err(anyhow!(
                "kernel '{}': `tarball_sha256` is REQUIRED — a kernel fetched unpinned is \
                 refused. Pin the source tarball's sha256 (kernel.org publishes signed sums: \
                 https://cdn.kernel.org/pub/linux/kernel/v{}.x/sha256sums.asc)",
                self.name,
                self.version.split('.').next().unwrap_or("6"),
            ));
        }
        if !is_hex64(&self.tarball_sha256) {
            return Err(anyhow!(
                "kernel '{}': `tarball_sha256` must be 64 lowercase hex chars, got {:?} — \
                 a malformed pin is not a pin",
                self.name,
                self.tarball_sha256
            ));
        }
        if self.hardening_profile().is_none() {
            return Err(anyhow!(
                "kernel '{}': unknown hardening profile {:?} — declare one of {} \
                 (an unknown name must not silently build vanilla)",
                self.name,
                self.profile,
                HardeningProfile::names()
            ));
        }
        if self.arch != DEFAULT_ARCH {
            return Err(anyhow!(
                "kernel '{}': arch {:?} is not wired — only \"x86_64\" is \
                 (the bzImage collect path is arch-specific)",
                self.name,
                self.arch
            ));
        }
        if self.make_target != DEFAULT_MAKE_TARGET {
            return Err(anyhow!(
                "kernel '{}': make target {:?} is not wired — only \"bzImage\" is",
                self.name,
                self.make_target
            ));
        }
        if let Some(frag) = &self.kconfig_fragment {
            let p = Path::new(frag);
            if p.is_absolute() || frag.split('/').any(|c| c == "..") {
                return Err(anyhow!(
                    "kernel '{}': `kconfig_fragment` must be repo-relative \
                     (no absolute path, no `..`), got {:?}",
                    self.name,
                    frag
                ));
            }
        }
        if self.toolchain_image_ref().is_empty() {
            return Err(anyhow!(
                "kernel '{}': no toolchain image — `toolchain_image` is empty and so is \
                 `toolchain_image_sha256`, which leaves nothing for a container engine to \
                 run. Name an image, or put a local image id in `toolchain_image_sha256`",
                self.name
            ));
        }
        if !self.toolchain_image_sha256.is_empty() && !is_hex64(&self.toolchain_image_sha256) {
            return Err(anyhow!(
                "kernel '{}': `toolchain_image_sha256` must be 64 lowercase hex chars, got {:?}",
                self.name,
                self.toolchain_image_sha256
            ));
        }
        if self.timeout_secs == 0 {
            return Err(anyhow!(
                "kernel '{}': `timeout_secs = 0` is not a bound — a kernel build \
                 without a budget is a hang waiting to be discovered",
                self.name
            ));
        }
        if self.preset().is_none() {
            return Err(anyhow!(
                "kernel '{}': unknown preset {:?} — declare \"full\", \"normal\", \"slim\" \
                 or \"nuked\" (a typo must not silently build the generic kernel that is \
                 the opposite of what was asked for)",
                self.name,
                self.preset
            ));
        }
        if !WIRED_BASES.contains(&self.base()) {
            return Err(anyhow!(
                "kernel '{}': base {:?} is not wired — declare one of {:?} \
                 (an unknown starting point must not silently build `defconfig`, \
                 which is the OPPOSITE of what someone asking for `tinyconfig` wants)",
                self.name,
                self.base(),
                WIRED_BASES
            ));
        }
        if !WIRED_TOOLCHAINS.contains(&self.toolchain.as_str()) {
            return Err(anyhow!(
                "kernel '{}': toolchain {:?} is not wired — declare one of {:?}",
                self.name,
                self.toolchain,
                WIRED_TOOLCHAINS
            ));
        }
        // MEASURED 2026-08-23 by two refused builds, then read out of the tree:
        // only arm/mips/riscv/powerpc select HAVE_LD_DEAD_CODE_DATA_ELIMINATION,
        // so on x86_64 the `minimal` overlay has nothing to turn on. A flag that
        // silently does nothing is worse than one that refuses: it reads on the
        // receipt as a knife that was used.
        if self.minimal && self.arch == "x86_64" {
            return Err(anyhow!(
                "kernel '{}': `minimal = true` has nothing to do on x86_64 — \
                 CONFIG_LD_DEAD_CODE_DATA_ELIMINATION is gated on \
                 HAVE_LD_DEAD_CODE_DATA_ELIMINATION, which only arm, mips, riscv and \
                 powerpc select. On this arch the linker's knife is clang LTO: declare \
                 `toolchain = \"clang\"` with `lto = true`",
                self.name
            ));
        }
        if self.lto && self.toolchain != "clang" {
            return Err(anyhow!(
                "kernel '{}': `lto = true` needs `toolchain = \"clang\"`. Full kernel LTO \
                 exists only under LLVM=1 — gcc's own LTO is not a mainline kernel option — \
                 and setting {LTO_SYMBOL} against a gcc build would be dropped by \
                 olddefconfig and then refused by the canary, for a reason that has nothing \
                 to do with what was declared",
                self.name
            ));
        }
        if self.solves() {
            if !WIRED_SOLVERS.contains(&self.solver.as_str()) {
                return Err(anyhow!(
                    "kernel '{}': solver {:?} is not wired — declare one of {:?}, or leave \
                     `solver` unset. A typo'd minifier that silently built the UN-minified \
                     kernel is the same silent degrade every other axis here refuses",
                    self.name,
                    self.solver,
                    WIRED_SOLVERS
                ));
            }
            if self.solver_version.trim().is_empty() {
                return Err(anyhow!(
                    "kernel '{}': `solver_version` is empty — it is HALF the formula cache \
                     key, so an empty one makes every kmax release share one cache entry",
                    self.name
                ));
            }
            if self.solver_timeout_secs == 0 {
                return Err(anyhow!(
                    "kernel '{}': `solver_timeout_secs = 0` is not a bound — a SAT solve \
                     with no budget is a container that sits there until the build's own \
                     timeout kills it, with nothing naming the step that hung",
                    self.name
                ));
            }
            if self.solver_timeout_secs >= self.timeout_secs {
                return Err(anyhow!(
                    "kernel '{}': `solver_timeout_secs` ({}) is not under the build's own \
                     `timeout_secs` ({}) — the container would be killed before the solver's \
                     bound could name itself, which turns a solver refusal into \
                     \"the container died\"",
                    self.name,
                    self.solver_timeout_secs,
                    self.timeout_secs
                ));
            }
            // An image that apt-gets a bare distro's compiler has no kmax in it.
            // Refuse HERE, not 148 MB and 1.7 GB of unpacked tree later, on
            // `klocalizer: not found`.
            if !self.toolchain_packages.is_empty()
                && !self.toolchain_packages.iter().any(|p| p == "kmax")
            {
                return Err(anyhow!(
                    "kernel '{}': `solver = {:?}` needs klocalizer IN the image, and this \
                     declaration apt-gets its toolchain ({:?}) with no kmax among it — the \
                     build would fetch the tarball, unpack the tree and then die on \
                     `klocalizer: not found`. Declare `toolchain_packages = []` on an image \
                     that carries kmax (the nordisk toolchain image does)",
                    self.name,
                    self.solver,
                    self.toolchain_packages
                ));
            }
        }
        if self.probe_timeout_secs == 0 {
            return Err(anyhow!(
                "kernel '{}': `probe_timeout_secs = 0` is not a bound — its two sibling \
                 bounds refuse zero and this one accepted it, which is how a probe boot \
                 becomes an unbounded wait",
                self.name
            ));
        }
        if self.mem_limit_mb == 0 {
            return Err(anyhow!(
                "kernel '{}': `mem_limit_mb = 0` is not a bound — an unbounded build \
                 container is how a 33 GB compile took this constellation's box down \
                 on 2026-08-20. Declare the MiB the build may have",
                self.name
            ));
        }
        // The nordisk toolchain is OURS: we build it, so we know its digest, and
        // an unpinned reference to an image we publish is a drift we chose to
        // accept for no reason. (A third-party default image stays a
        // convenience — that asymmetry is deliberate.)
        if self.toolchain_image == KERNEL_TOOLCHAIN_IMAGE {
            if self.toolchain_image_sha256.is_empty() {
                return Err(anyhow!(
                    "kernel '{}': the nordisk toolchain image must be pinned — declare \
                     `toolchain_image_sha256` (build it through the image kind, then read \
                     its MANIFEST DIGEST: `podman image inspect --format '{{{{.Digest}}}}'`). \
                     An unpinned reference to an image WE publish is drift for free. Note \
                     the digest is NOT the image id — both are 64 hex characters and only \
                     the manifest digest works as `name@sha256:…`; an image id there is \
                     read as a registry pull and fails. An image id can still be used by \
                     leaving `toolchain_image` empty and putting the id here",
                    self.name
                ));
            }
            if !self.toolchain_packages.is_empty() {
                return Err(anyhow!(
                    "kernel '{}': the nordisk toolchain image is SELF-CONTAINED — declare \
                     `toolchain_packages = []`. Naming packages here apt-gets inside it, \
                     which both says the image is wrong and forces a network on a build \
                     that would otherwise run airgapped: {:?}",
                    self.name,
                    self.toolchain_packages
                ));
            }
        }
        if self.cpus == 0 {
            return Err(anyhow!(
                "kernel '{}': `cpus = 0` is not a bound — `make -j` is derived from \
                 this, and a build must not size itself to whichever host runs it",
                self.name
            ));
        }
        Ok(())
    }

    /// [`validate`](Self::validate) plus the ON-DISK half: a declared
    /// `kconfig_fragment` must exist in this checkout (refused BEFORE anything
    /// is fetched or a container starts — the container-kind `validate_in`
    /// precedent).
    pub fn validate_in(&self, repo_root: &Path) -> Result<()> {
        self.validate()?;
        if let Some(frag) = &self.kconfig_fragment {
            let p = repo_root.join(frag);
            if !p.is_file() {
                return Err(anyhow!(
                    "kernel '{}': kconfig_fragment {:?} is not in this checkout ({})",
                    self.name,
                    frag,
                    p.display()
                ));
            }
        }
        Ok(())
    }

    /// **Apply a resolved CLI/pane decision to this declaration** — the one
    /// writer for the seam between `--opt kernel.*` and a build:
    ///
    /// * the preset becomes the decision's (a `kernel.preset=` override, or the
    ///   declared one echoed back);
    /// * the veto fragment rides [`decision_fragment`](Self::decision_fragment)
    ///   when it actually vetoes anything
    ///   ([`KernelDecision::changes_anything`](crate::kernel_features::KernelDecision::changes_anything))
    ///   — a comment-only fragment is dropped so an idle decision leaves the
    ///   content id untouched.
    ///
    /// The decision was already refused-or-resolved by
    /// [`resolve_kernel_cli`](crate::kernel_features::resolve_kernel_cli); this
    /// method only lands it, it judges nothing.
    pub fn apply_decision(&mut self, d: &crate::kernel_features::KernelDecision) {
        self.preset = d.preset.as_str().to_string();
        self.decision_fragment = d.changes_anything().then(|| d.fragment.clone());
        self.probe = d.probe;
    }

    /// **Land the consumers' requirements on this declaration** — after
    /// [`apply_decision`](Self::apply_decision), because the preset the
    /// requirements are judged against is the DECIDED one. Refuses BY NAME
    /// (symbol · consumer · feature · reason · what stands against it) when two
    /// requirers disagree, or when the preset/profile fragments VETO a required
    /// symbol: a feature that needs `CONFIG_IPV6=y` on a `nuked` kernel
    /// (`# CONFIG_IPV6 is not set`) is not merged over the veto, it is refused.
    ///
    /// A NEGATIVE requirement (`=n`) over a preset `=y` is not a veto met, it
    /// is a NARROWING — the model's "toggling a fact flips modules OUT of the
    /// bzImage" — and it lands: the requirements fragment merges after the
    /// preset layers (later wins), so `target.consoles = [vga]` takes
    /// `SERIAL_8250` out of a `nuked` kernel whose base fragment had it in.
    /// The `=n` still refuses what a LATER layer (the repo's veto file, the
    /// operator's decision) turns back on — [`merged_fragment`] checks that by
    /// name. An empty set clears the field.
    pub fn apply_requirements(&mut self, r: &crate::kernel_requires::KernelRequirements) -> Result<()> {
        if r.is_empty() {
            self.requirements = None;
            return Ok(());
        }
        let conflicts = r.conflicts();
        if !conflicts.is_empty() {
            return Err(anyhow!(
                "kernel '{}': its consumers' requirements contradict each other — {}",
                self.name,
                conflicts.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
            ));
        }
        let preset = self.preset().ok_or_else(|| anyhow!("kernel '{}': unknown preset {:?}", self.name, self.preset))?;
        let profile = self
            .hardening_profile()
            .ok_or_else(|| anyhow!("kernel '{}': unknown hardening profile {:?}", self.name, self.profile))?;
        let mut frags = preset.fragments();
        frags.extend(profile.fragments());
        let layer = merge_kconfig(&frags);
        // Only a POSITIVE row over a preset veto is a contradiction; a `=n`
        // row over a preset `=y` narrows the kernel and merges (later wins).
        let against: Vec<_> = r
            .contradictions(&layer, &format!("the `{}` preset fragment", preset.as_str()))
            .into_iter()
            .filter(|c| c.wanted.0 != crate::kernel_requires::KconfigValue::N)
            .collect();
        if !against.is_empty() {
            return Err(anyhow!(
                "kernel '{}': a consumer requires what the preset vetoes — {} — pick a preset that grants it, \
                 or drop the feature; a requirement is never merged over a veto",
                self.name,
                against.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
            ));
        }
        self.requirements = Some(r.clone());
        Ok(())
    }

    /// **The user fragment a build merges LAST** — the declared
    /// `kconfig_fragment` file (read from this checkout) with the operator's
    /// [`decision_fragment`](Self::decision_fragment) appended after it, so the
    /// merge order is: preset, profile, the repo's committed veto file, and the
    /// person at the terminal — later wins at every step
    /// ([`merged_fragment`] applies this as its final fragment).
    pub fn user_fragment_in(&self, repo_root: &Path) -> Result<Option<String>> {
        let file = match &self.kconfig_fragment {
            Some(rel) => Some(std::fs::read_to_string(repo_root.join(rel)).with_context(
                || format!("kernel '{}': read kconfig_fragment {rel}", self.name),
            )?),
            None => None,
        };
        Ok(match (file, &self.decision_fragment) {
            (Some(f), Some(d)) => Some(format!("{f}\n{d}")),
            (Some(f), None) => Some(f),
            (None, Some(d)) => Some(d.clone()),
            (None, None) => None,
        })
    }

    /// The canonical bytes the content id hashes — every field that changes the
    /// produced kernel (version, tarball pin, profile, fragments, toolchain,
    /// make target/arch). Tagged, so an empty field and an absent one are the
    /// same bytes (the [`IsoBuild::canonical_bytes`](crate::iso::IsoBuild::canonical_bytes)
    /// discipline).
    pub fn canonical_bytes(&self) -> Vec<u8> {
        // ★ MEASURED 2026-08-23 by a reader, not a test: `preset` and `base`
        // were MISSING here, so the same declaration flipped from `normal` to
        // `nuked` produced the IDENTICAL content id — a 13 714 432-byte kernel
        // and a 1 725 440-byte one sharing one content address, with
        // `build_kernel` sealing an outcome keyed on it. Every axis that changes
        // the ARTIFACT belongs in this string; `mem_limit_mb`, `cpus` and the
        // disk floor stay out because they change only how a build is RUN.
        let mut s = format!(
            "kernel#name:{}#version:{}#url:{}#sha256:{}#preset:{}#base:{}#profile:{}#profile_run:{}#minimal:{}#toolchain:{}#lto:{}#target:{}#arch:{}#image:{}",
            self.name,
            self.version,
            self.source_url(),
            self.tarball_sha256,
            self.preset,
            self.base(),
            self.profile,
            self.profile_run,
            self.minimal,
            self.toolchain,
            self.lto,
            self.make_target,
            self.arch,
            self.toolchain_image_ref(),
        );
        // MINIFY changes the SYMBOL SET, so it changes the artifact. Appended
        // only when it is on, so every kernel built before the axis existed
        // keeps the content id it was sealed under.
        if self.solves() {
            s.push_str(&format!("#solver:{}#solverver:{}", self.solver, self.solver_version));
        }
        if let Some(frag) = &self.kconfig_fragment {
            s.push_str(&format!("#fragment:{frag}"));
        }
        // The operator's decision changes the artifact, so it changes the id —
        // hashed rather than inlined (the fragment is prose-commented and long).
        // Appended only when present, so every kernel built before the axis
        // existed keeps the content id it was sealed under.
        if let Some(d) = &self.decision_fragment {
            s.push_str(&format!("#decision:{}", nornir_hash::sha256_hex(d.as_bytes())));
        }
        if !self.toolchain_packages.is_empty() {
            s.push_str(&format!("#packages:{}", self.toolchain_packages.join(",")));
        }
        // A probe kernel is another artifact; appended only when present, so
        // every kernel built before the axis existed keeps its content id.
        if let Some(p) = self.probe {
            s.push_str(&format!("#probe:{}", p.word()));
        }
        s.into_bytes()
    }

    /// A content-addressed id (lowercase-hex SHA-256 over
    /// [`canonical_bytes`](Self::canonical_bytes)).
    pub fn content_id(&self) -> String {
        nornir_hash::sha256_hex(&self.canonical_bytes())
    }
}

// ── the pure kconfig merge ───────────────────────────────────────────────────

/// The kconfig symbol a fragment line sets, if any: `CONFIG_X=…` or the
/// `# CONFIG_X is not set` spelling. Comments and blanks carry no symbol.
pub fn kconfig_symbol(line: &str) -> Option<&str> {
    let t = line.trim();
    if let Some(rest) = t.strip_prefix("# ") {
        if let Some(sym) = rest.strip_suffix(" is not set") {
            if sym.starts_with("CONFIG_") && !sym.contains(char::is_whitespace) {
                return Some(sym);
            }
        }
        return None;
    }
    if t.starts_with("CONFIG_") {
        if let Some(eq) = t.find('=') {
            return Some(&t[..eq]);
        }
    }
    None
}

/// **Merge kconfig fragments — a pure function over kconfig text.** Directive
/// lines (`CONFIG_X=…` / `# CONFIG_X is not set`) are kept in first-seen order;
/// a LATER fragment's directive for the same symbol REPLACES the earlier one in
/// place (so `hardened` can veto a base value, and the user fragment — merged
/// last — can veto both). Comments and blank lines are dropped: the output is
/// the machine half, the commentary lives in the committed fragment files.
pub fn merge_kconfig(fragments: &[&str]) -> String {
    // First-seen order, later value wins IN PLACE: `order` holds the symbols as
    // they first appeared, `value` the last directive seen for each.
    let mut order: Vec<&str> = Vec::new();
    let mut value: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new();
    for frag in fragments {
        for line in frag.lines() {
            if let Some(sym) = kconfig_symbol(line) {
                if !value.contains_key(sym) {
                    order.push(sym);
                }
                value.insert(sym, line.trim());
            }
        }
    }
    let mut out = String::new();
    for sym in order {
        out.push_str(value[sym]);
        out.push('\n');
    }
    out
}

/// The one merged fragment a [`KernelBuild`] applies over `defconfig`: its
/// profile's committed fragments, then the declaration's own
/// `kconfig_fragment` text (merged LAST, so a repo can veto a profile line —
/// visibly, in its own committed file).
pub fn merged_fragment(k: &KernelBuild, user_fragment: Option<&str>) -> Result<String> {
    let profile = k.hardening_profile().ok_or_else(|| {
        anyhow!(
            "kernel '{}': unknown hardening profile {:?}",
            k.name,
            k.profile
        )
    })?;
    let preset = k.preset().ok_or_else(|| {
        anyhow!("kernel '{}': unknown preset {:?}", k.name, k.preset)
    })?;
    // Preset first (what is IN it), hardening after (later wins), user veto
    // last — so a hardened nuked kernel keeps the contract AND the absences.
    let mut frags = preset.fragments();
    frags.extend(profile.fragments());
    if k.profile_run {
        frags.push(PROFILE_FRAGMENT);
    }
    if k.minimal {
        frags.push(MINIMAL_FRAGMENT);
    }
    // LTO is set HERE rather than in a fragment file, because it is only legal
    // under the clang toolchain and `validate` has already refused the other
    // combination — one place decides it, so the picture and the build agree.
    if k.lto {
        frags.push(LTO_SYMBOL);
    }
    // The consumers' requirements: after every preset layer (already judged
    // not to contradict them — `apply_requirements`), before the repo's veto
    // file and the operator's decision. A requirement that repeats a preset
    // line changes no byte of the merge.
    let requirements = k.requirements.as_ref().map(|r| r.fragment()).unwrap_or_default();
    if !requirements.is_empty() {
        frags.push(&requirements);
    }
    if let Some(u) = user_fragment {
        frags.push(u);
    }
    let merged = merge_kconfig(&frags);
    // The veto file / the decision merged LAST may have contradicted a
    // requirement — later wins in the merge, so it is checked here BY NAME
    // rather than silently honoured: `--opt kernel.tun=off` against a consumer
    // whose `wireguard` needs TUN is a refusal, not a kernel that lies about it.
    if let Some(r) = &k.requirements {
        let broken = r.contradictions(&merged, "the repo's veto file / the operator's decision (merged last)");
        if !broken.is_empty() {
            return Err(anyhow!(
                "kernel '{}': the last merge layer contradicts a consumer's requirement — {}",
                k.name,
                broken.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
            ));
        }
    }
    // A DEV probe kernel's lines go on after the contradiction check: a probe
    // measures the row, it does not ship, and a release build of it is refused
    // by name before the container starts (`refuse_probe_from_env`).
    if let Some(p) = k.probe {
        return Ok(merge_kconfig(&[&merged, p.fragment()]));
    }
    Ok(merged)
}

// ── the in-container build script ────────────────────────────────────────────

/// The `/bin/sh -eu` script the build container runs. The CONTAINER may shell —
/// that is its purpose; the host only authors this text. Steps: install the
/// toolchain packages (only when declared — a self-contained image skips apt and
/// runs airgapped), unpack the staged tarball, `defconfig`, apply the merged
/// fragment (`scripts/kconfig/merge_config.sh -m` + `olddefconfig`), CANARY-CHECK
/// that every requested option survived (an option `olddefconfig` silently
/// dropped is a hardening claim the artifact would not honour — refused, exit
/// 90), then `make -j` and copy `bzImage` + `.config` to `/work/out/`.
///
/// `KBUILD_BUILD_*` (user, host, timestamp and the build number) and `SOURCE_DATE_EPOCH`
/// are pinned so two builds of the same declaration differ only where their inputs do,
/// also when the second runs in the first one's kept tree.
/// The starting `.config` (`tinyconfig`/`defconfig`) as the container saw it,
/// copied out beside `kernel.config`. The kconfig solver's `base` input.
pub const KERNEL_BASE_CONFIG: &str = "kernel.base.config";

pub fn build_script(k: &KernelBuild) -> String {
    let mut s = String::new();
    s.push_str("set -eu\n");
    if !k.toolchain_packages.is_empty() {
        s.push_str("export DEBIAN_FRONTEND=noninteractive\n");
        s.push_str("apt-get update\n");
        s.push_str(&format!(
            "apt-get install -y --no-install-recommends {}\n",
            k.toolchain_packages.join(" ")
        ));
    }
    s.push_str("mkdir -p /work/src /work/out\n");
    s.push_str(&format!("tar -xf \"/work/{}\" -C /work/src\n", k.tarball_file_name()));
    s.push_str(&format!("cd \"/work/src/linux-{}\"\n", k.version));
    s.push_str("export KBUILD_BUILD_USER=nordisk KBUILD_BUILD_HOST=draupnir\n");
    s.push_str("export KBUILD_BUILD_TIMESTAMP='Thu Jan  1 00:00:00 UTC 1970'\n");
    // The build number: without it `make` counts `.version` up in a reused tree, and the
    // same inputs give another bzImage (lane B4). The epoch agrees with the timestamp.
    s.push_str("export KBUILD_BUILD_VERSION=1\n");
    s.push_str("export SOURCE_DATE_EPOCH=0\n");
    s.push_str(&format!("make ARCH={} {}{}\n", k.arch, k.llvm_flag(), k.base()));
    // The STARTING point leaves the container too (lane AP, 2026-08-30). The
    // kconfig solver has to tell a symbol the base already carried from one
    // something in the fragment pulled in, and `olddefconfig` keeps a PROMPT
    // symbol's incoming value — without this file that whole class of answer is
    // a guess. It costs one `cp` of ~70 KB.
    s.push_str(&format!("cp .config /work/out/{}\n", KERNEL_BASE_CONFIG));
    s.push_str("scripts/kconfig/merge_config.sh -m .config /work/fragment.config\n");
    // MINIFY rides HERE, between the merge and olddefconfig: a solver that ran
    // after olddefconfig would be solving a config the kernel had already
    // pruned, which is the very drop the pass exists to prevent.
    s.push_str(&solver_script(k));
    s.push_str(&format!("make ARCH={} {}olddefconfig\n", k.arch, k.llvm_flag()));
    s.push_str(&solver_writeback_script(k));
    // The canary loop: every requested `=y` must be in the final .config, and
    // every `is not set` must not have crept back — loudly, by line.
    // The canary reports EVERY failure, then exits once. It used to `exit 90`
    // on the first line — fine for a `defconfig` build, where a refusal is a
    // one-off surprise, and useless for a `tinyconfig` one, where the refusals
    // ARE the deliverable: the list of dependencies the contract needs and the
    // fragment does not yet name. MEASURED 2026-08-22: a slim build refused in
    // 18 s naming CONFIG_EFI_STUB and nothing else, so closing the closure that
    // way would have cost one container run per missing symbol.
    s.push_str("kernel_config_refused=0\n");
    s.push_str("while IFS= read -r line; do\n");
    s.push_str("  case \"$line\" in\n");
    s.push_str("    CONFIG_*=y)\n");
    s.push_str(
        &format!("      grep -qxF \"$line\" .config || {{ echo \"{KERNEL_CONFIG_REFUSED}: $line did not survive olddefconfig\" >&2; kernel_config_refused=$((kernel_config_refused+1)); }} ;;\n"),
    );
    s.push_str("    '# CONFIG_'*' is not set')\n");
    s.push_str("      sym=${line#\"# \"}; sym=${sym%\" is not set\"}\n");
    s.push_str(
        &format!("      if grep -q \"^$sym=\" .config; then echo \"{KERNEL_CONFIG_REFUSED}: $line crept back in\" >&2; kernel_config_refused=$((kernel_config_refused+1)); fi ;;\n"),
    );
    s.push_str("  esac\n");
    s.push_str("done < /work/fragment.config\n");
    s.push_str(
        &format!("if [ \"$kernel_config_refused\" -ne 0 ]; then echo \"{KERNEL_CONFIG_REFUSED}-TOTAL: $kernel_config_refused option(s) did not hold — the list above IS the dependency closure this fragment still owes\" >&2; exit {KERNEL_CONFIG_REFUSED_EXIT}; fi\n"),
    );
    // `-j` from the DECLARED cpu bound, never `$(nproc)`: the container is
    // capped at `cpus` cores, so a host-sized `-j` would oversubscribe the cap
    // and make the build's memory high-water mark depend on the machine.
    s.push_str(&format!(
        "make ARCH={} {}-j{} {}\n",
        k.arch,
        k.llvm_flag(),
        k.cpus,
        k.make_target
    ));
    s.push_str("cp arch/x86/boot/bzImage /work/out/bzImage\n");
    s.push_str("cp .config /work/out/kernel.config\n");
    s
}

/// **The MINIFY pass — the SAT pass over the Kconfig DAG**, rendered as the
/// shell the container runs BETWEEN `merge_config.sh` and `olddefconfig`.
/// Empty when no solver is declared, so an ordinary build's script is
/// byte-identical to what it always was.
///
/// What it does, and why in this order:
///
/// 1. **the formula.** `klocalizer` reads the kernel's own Kconfig through
///    `kextract` and turns it into a z3 formula with `kclause`. That is the
///    expensive step (~46 s, ~20 MB on linux 6.12.104), so it is generated into
///    `/work/kclause` — a directory the HOST stages from, and publishes to, the
///    content-addressed cache ([`kclause_cache_entry`]) — and skipped entirely
///    when a formula is already staged there.
/// 2. **the match config.** klocalizer's `--repair` reads a `.config` as SOFT
///    assumptions and drops only the ones an unsat core says it must. A kernel
///    `.config` names a few thousand of ~17 400 boolean symbols, so everything
///    it does NOT name is free — and MEASURED 2026-08-23, that is the difference
///    between a closure of **78 symbols** and one of **472**, the latter full of
///    mlx5, i40e and reiserfs that nothing asked for. So the match config is the
///    merged `.config` CLOSED: every symbol the tree knows and the config never
///    mentions is written down as `is not set`.
/// 3. **the solve.** The fragment's own `=y` symbols go in as HARD constraints
///    (`-D`), the closed match config as soft ones, and what comes back is a
///    `.config` whose `depends on` / `select` chains are already closed.
///    `--disable-downloading-formulas` because this container is AIRGAPPED —
///    without it klocalizer reaches for a remote formula cache that is not
///    there.
/// 4. **the write-back.** After `olddefconfig`, whatever the solver added over
///    the pre-solve config, minus what the fragment already named, is the
///    dependency CLOSURE — written to `/work/out/deps-solved.config` with a
///    header, which is a fragment an operator can commit. That file is the
///    automation of `src/kconfig/deps-tinyconfig.config`, whose own header
///    describes the loop a person ran by hand.
///
/// Both klocalizer calls are wrapped in `timeout`, and an overrun prints
/// [`SOLVER_REFUSED_MARKER`] and exits [`SOLVER_REFUSED_EXIT`]: a hung solve
/// must name ITSELF, not ride the build's timeout and arrive as "the container
/// died".
/// One solver refusal, rendered at `indent`, reading `$<status>`.
///
/// It distinguishes a TIMEOUT (`timeout` exits 124) from any other non-zero,
/// because "did not answer in 1800 s" and "answered UNSAT in four seconds" are
/// different facts and must not arrive as the same one — the probe's own
/// 2026-08-22 lesson, one layer up.
///
/// Note what is NOT here: backticks. An earlier draft quoted the knob name as
/// `` `solver_timeout_secs` `` inside a DOUBLE-quoted `echo`, which is command
/// substitution — the refusal would have tried to RUN the field name. Every
/// message below is single-quoted shell.
fn refusal(indent: &str, status: &str, t: u64, what: &str) -> String {
    format!(
        "{indent}if [ \"${status}\" -eq 124 ]; then\n\
         {indent}  echo '{SOLVER_REFUSED_MARKER}: {what} within {t}s (solver_timeout_secs) — raise that bound, or drop solver and close the list from the canary refusals by hand' >&2\n\
         {indent}else\n\
         {indent}  echo \"{SOLVER_REFUSED_MARKER}: {what} (exit ${status}, not a timeout) — an UNSAT answer means the fragment asks for symbols that cannot all be on together on this arch; the merged fragment is at /work/fragment.config\" >&2\n\
         {indent}fi\n\
         {indent}exit {SOLVER_REFUSED_EXIT}\n"
    )
}

pub fn solver_script(k: &KernelBuild) -> String {
    if !k.solves() {
        return String::new();
    }
    let src = format!("/work/src/linux-{}", k.version);
    let t = k.solver_timeout_secs;
    let common = format!(
        "-a {} --linux-ksrc {src} --formulas /work/kclause --disable-downloading-formulas",
        k.arch
    );
    let mut s = String::new();
    s.push_str("# ── MINIFY: the SAT pass over the Kconfig DAG ─────────────────────────\n");
    s.push_str("mkdir -p /work/kclause /work/out\n");
    // The container REPORTS its kmax; the host checks it against the declared
    // half of the formula cache key before it publishes anything.
    s.push_str("klocalizer --version > /work/out/solver-version\n");
    s.push_str("grep -E '^CONFIG_[A-Za-z0-9_]+=y$' .config | sort > /work/out/kernel-presolve.sym\n");
    s.push_str(
        r"sed -n 's/^\(CONFIG_[A-Za-z0-9_]*\)=y$/\1/p' /work/fragment.config | sort > /work/out/kernel-wanted.sym",
    );
    s.push('\n');
    // The fragment's `=y` set becomes the HARD constraints. `set --` keeps them
    // as real argv words: a symbol list in a string would word-split.
    s.push_str("set --\n");
    s.push_str("while IFS= read -r sym; do set -- \"$@\" -D \"$sym\"; done < /work/out/kernel-wanted.sym\n");
    s.push_str("if [ -z \"$(find /work/kclause -name kextract -type f)\" ]; then\n");
    s.push_str(&format!(
        "  if timeout {t} klocalizer {common} \"$@\" --save-smt /work/out/kernel-formula.smt2; then :; else\n"
    ));
    s.push_str("    kernel_solver_status=$?\n");
    s.push_str(&refusal(
        "    ",
        "kernel_solver_status",
        t,
        "kextract+kclause did not produce a Kconfig formula for this tree",
    ));
    s.push_str("  fi\n");
    s.push_str("fi\n");
    s.push_str("kernel_kextract=\"$(find /work/kclause -name kextract -type f | head -1)\"\n");
    s.push_str(&format!(
        "[ -n \"$kernel_kextract\" ] || {{ echo \"{SOLVER_REFUSED_MARKER}: no kextract formula under /work/kclause after generating one — the solver has nothing to read the Kconfig DAG from\" >&2; exit {SOLVER_REFUSED_EXIT}; }}\n"
    ));
    // The CLOSED match config — see the doc comment: what the .config never
    // mentions is what a bare --repair leaves free, and free is how mlx5 gets in.
    s.push_str(
        r#"sed -n 's/^config \(CONFIG_[A-Za-z0-9_]*\) \(bool\|tristate\)$/\1/p' "$kernel_kextract" | sort -u > /work/out/kernel-universe.sym"#,
    );
    s.push('\n');
    s.push_str(
        r"sed -n -e 's/^\(CONFIG_[A-Za-z0-9_]*\)=.*$/\1/p' -e 's/^# \(CONFIG_[A-Za-z0-9_]*\) is not set$/\1/p' .config | sort -u > /work/out/kernel-mentioned.sym",
    );
    s.push('\n');
    s.push_str("cp .config /work/out/kernel-match.config\n");
    s.push_str(
        "comm -23 /work/out/kernel-universe.sym /work/out/kernel-mentioned.sym | sed 's/^/# /; s/$/ is not set/' >> /work/out/kernel-match.config\n",
    );
    s.push_str(&format!(
        "if timeout {t} klocalizer {common} -m /work/out/kernel-match.config \"$@\" -o /work/out/kernel-solved.config; then :; else\n"
    ));
    s.push_str("  kernel_solver_status=$?\n");
    s.push_str(&refusal("  ", "kernel_solver_status", t, "klocalizer did not solve the Kconfig DAG"));
    s.push_str("fi\n");
    s.push_str("cp /work/out/kernel-solved.config .config\n");
    s
}

/// The closure write-back, rendered AFTER `olddefconfig` (so what it records is
/// what the kernel's own tooling agreed to) and BEFORE the canary (so a build
/// that then refuses still leaves the operator the list). Empty when no solver
/// is declared.
pub fn solver_writeback_script(k: &KernelBuild) -> String {
    if !k.solves() {
        return String::new();
    }
    let mut s = String::new();
    s.push_str("grep -E '^CONFIG_[A-Za-z0-9_]+=y$' .config | sort > /work/out/kernel-postsolve.sym\n");
    s.push_str("{\n");
    s.push_str(&format!(
        "  echo '# nordisk SOLVED dependency closure — {} over the Kconfig DAG.'\n",
        k.solver
    ));
    s.push_str(&format!(
        "  echo '# kernel {} · base {} · preset {} · arch {}'\n",
        k.version,
        k.base(),
        k.preset,
        k.arch
    ));
    s.push_str("  echo '#'\n");
    s.push_str("  echo '# Every line below is a PARENT the solver had to turn on for the'\n");
    s.push_str("  echo '# fragment above it to survive olddefconfig. This is the same list'\n");
    s.push_str("  echo '# src/kconfig/deps-tinyconfig.config was closed by hand from twelve'\n");
    s.push_str("  echo '# canary refusals — committing this file is that loop, automated.'\n");
    s.push_str("  echo '#'\n");
    s.push_str("  echo '# It carries the canary'\"'\"'s OWN blind spot: the solver closes only what'\n");
    s.push_str("  echo '# the fragment NAMES, so an option nobody wrote down (CONFIG_BINFMT_ELF'\n");
    s.push_str("  echo '# is the one that matters) is invisible to it too. Probe-boot, not this'\n");
    s.push_str("  echo '# file, is what proves the kernel can execute PID 1.'\n");
    s.push_str(
        "  comm -13 /work/out/kernel-presolve.sym /work/out/kernel-postsolve.sym | grep -vxFf /work/fragment.config || true\n",
    );
    s.push_str("} > /work/out/deps-solved.config\n");
    s
}

/// **The shared kernel source cache directory.** Resolution, in order:
/// `$NORDISK_KERNEL_CACHE`, `$XDG_CACHE_HOME/nordisk/kernel`,
/// `$HOME/.cache/nordisk/kernel`, and finally `.nornir-cache/kernel` under the
/// repo root when a machine has no home at all (a CI container).
///
/// Tarballs live under `tarballs/<sha256>.tar.xz` — keyed by the PIN itself, so
/// the cache is content-addressed and a stale entry is not expressible: a file
/// whose bytes do not hash to its own name is not a hit, it is garbage that gets
/// re-fetched.
pub fn kernel_cache_dir(repo_root: &Path) -> std::path::PathBuf {
    if let Some(d) = std::env::var_os("NORDISK_KERNEL_CACHE") {
        return std::path::PathBuf::from(d);
    }
    if let Some(d) = std::env::var_os("XDG_CACHE_HOME") {
        return std::path::PathBuf::from(d).join("nordisk").join("kernel");
    }
    if let Some(h) = std::env::var_os("HOME") {
        return std::path::PathBuf::from(h).join(".cache").join("nordisk").join("kernel");
    }
    repo_root.join(".nornir-cache").join("kernel")
}

/// **The kclause formula cache entry** — the expensive half of the MINIFY pass,
/// content-addressed in the SAME shared cache the tarball lives in
/// (`<cache>/kclause/<tarball_sha256>.kmax-<version>/`).
///
/// The key is BOTH halves on purpose. A kclause formula is a function of the
/// tree *and* of the extractor that produced it: keying on the tarball sha
/// alone lets kmax 4.10 and a later kmax share one entry, and a formula
/// attributed to the wrong extractor is a cache that lies — worse than no cache,
/// because the build believes it. (Written first the wrong way, then fixed by
/// the test that named this.)
///
/// MEASURED 2026-08-23, linux 6.12.104, nordisk toolchain image: the formula is
/// `kextract` 7.7 MB + `kclause` 12.2 MB ≈ 20 MB, ~46 s to build cold, and it is
/// then reused by every variant on the same pin — the same argument the tarball
/// cache made on 2026-08-22.
pub fn kclause_cache_entry(cache_dir: &Path, tarball_sha256: &str, solver_version: &str) -> PathBuf {
    cache_dir.join("kclause").join(format!("{tarball_sha256}.kmax-{solver_version}"))
}

/// Copy a directory tree, hard-linking each file where the filesystem allows it
/// and copying where it does not — the same "free when possible" staging the
/// tarball cache uses. Used to stage a cached formula INTO a build workdir and
/// to publish a freshly generated one back out.
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
    std::fs::create_dir_all(to).with_context(|| format!("create {}", to.display()))?;
    for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? {
        let entry = entry?;
        let src = entry.path();
        let dst = to.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            copy_tree(&src, &dst)?;
        } else {
            let _ = std::fs::remove_file(&dst);
            if std::fs::hard_link(&src, &dst).is_err() {
                std::fs::copy(&src, &dst)
                    .with_context(|| format!("copy {} -> {}", src.display(), dst.display()))?;
            }
        }
    }
    Ok(())
}

/// Free bytes on the filesystem holding `path`, or `None` when this build cannot
/// ask (the dep-light default: `statvfs` rides the `kernel-build` feature, which
/// is the only configuration that runs a real build anyway).
///
/// `None` means NOT CHECKED, and the caller must not read it as "fine". It is
/// also, honestly, a SILENT skip today: the pin carries no disk field, so
/// nothing on the receipt distinguishes "checked and fine" from "could not
/// check". This doc used to claim such a line existed — see the note on
/// [`KernelBuild::min_free_disk_mb`].
#[cfg(feature = "kernel-build")]
pub fn available_disk_bytes(path: &Path) -> Option<u64> {
    use std::os::unix::ffi::OsStrExt;
    let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
    // SAFETY: `c` is a valid NUL-terminated path and `st` is fully written by
    // statvfs before it is read; nothing here outlives the call.
    unsafe {
        let mut st: libc::statvfs = std::mem::zeroed();
        if libc::statvfs(c.as_ptr(), &mut st) != 0 {
            return None;
        }
        Some((st.f_bavail as u64) * (st.f_frsize as u64))
    }
}

/// Dep-light stand-in: the check is honestly UNAVAILABLE, not passing.
#[cfg(not(feature = "kernel-build"))]
pub fn available_disk_bytes(_path: &Path) -> Option<u64> {
    None
}

/// **What a HEADROOM RESERVATION learned about a path** — the answer `statvfs`
/// structurally cannot give.
///
/// `statvfs` describes the FILESYSTEM. On 2026-08-22 it reported 23 GiB free, the
/// preflight passed, and ninety seconds later the build died on
/// `tar: Cannot write: Disk quota exceeded` — `EDQUOT`, a quota against the
/// WRITING USER, which no `statvfs` field mentions. The only way to learn whether
/// a path will take N bytes is to ask the kernel to set N bytes aside for it, and
/// see whether it says yes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiskReservation {
    /// The bytes WERE reserved (and released again immediately) — the path can
    /// take them, quota and storage-driver limit included.
    Reserved(u64),
    /// The kernel refused. `errno_name` is the refusal by name; `EDQUOT` is the
    /// one the old preflight could never see, `ENOSPC` the one it usually could.
    Refused { errno: i32, errno_name: &'static str },
    /// **Nothing was asked, and this is NOT a pass.** Either the filesystem does
    /// not implement preallocation (`EOPNOTSUPP` on tmpfs and some network
    /// filesystems) or this build does not link the syscall (the dep-light
    /// default — see [`DiskReserver`]).
    NotChecked(&'static str),
}

impl DiskReservation {
    /// The refusal, or `None` when nothing was refused. **A `NotChecked` is not a
    /// refusal and not a pass** — a caller must not collapse the three states into
    /// a bool, which is how the original blind spot was written.
    pub fn refusal(&self) -> Option<&'static str> {
        match self {
            DiskReservation::Refused { errno_name, .. } => Some(errno_name),
            _ => None,
        }
    }
    /// The bytes actually reserved, for the receipt. `None` means the question was
    /// not answered.
    pub fn reserved_bytes(&self) -> Option<u64> {
        match self {
            DiskReservation::Reserved(n) => Some(*n),
            _ => None,
        }
    }
}

/// **The seam that asks a path whether it can really take N bytes.**
///
/// A seam and not a free function for the reason every other leg of this
/// orchestrator is one: the refusal it drives is a REFUSAL TO BUILD, and a refusal
/// nothing can drive in a test is a refusal nobody has seen fire. The live leg
/// ([`FallocateReserver`]) needs `libc`, which rides the `kernel-build` feature;
/// the guard for the refusal must run in the DEFAULT test configuration, so it
/// runs against a fake.
pub trait DiskReserver {
    /// Ask for `bytes` of real, allocated headroom under `dir`, then release it.
    fn reserve(&self, dir: &Path, bytes: u64) -> DiskReservation;
}

/// The live reservation: `posix_fallocate` over a scratch file in `dir`, unlinked
/// immediately afterwards.
///
/// `posix_fallocate` ALLOCATES BLOCKS, so it is charged against the user's quota
/// and against a storage driver's own limit, and it returns `EDQUOT` / `ENOSPC`
/// straight away without writing a byte — the whole reservation costs one syscall
/// rather than 3 GiB of I/O. What it still cannot see is a backing store that
/// fills AFTER the check; that residue is what [`out_of_room`] catches.
pub struct FallocateReserver;

#[cfg(feature = "kernel-build")]
impl DiskReserver for FallocateReserver {
    fn reserve(&self, dir: &Path, bytes: u64) -> DiskReservation {
        use std::os::fd::AsRawFd;
        if bytes == 0 {
            return DiskReservation::Reserved(0);
        }
        let path = dir.join(".kernel-disk-reservation-probe");
        let f = match std::fs::File::create(&path) {
            Ok(f) => f,
            Err(e) => {
                let errno = e.raw_os_error().unwrap_or(0);
                return DiskReservation::Refused { errno, errno_name: errno_name(errno) };
            }
        };
        // SAFETY: `f` is an open, owned, writable file descriptor and the length
        // is non-negative; posix_fallocate touches nothing else and returns the
        // errno directly rather than setting the global one.
        let rc = unsafe { libc::posix_fallocate(f.as_raw_fd(), 0, bytes as libc::off_t) };
        drop(f);
        let _ = std::fs::remove_file(&path);
        match rc {
            0 => DiskReservation::Reserved(bytes),
            libc::EOPNOTSUPP | libc::ENOSYS | libc::EINVAL => {
                DiskReservation::NotChecked("this filesystem does not implement preallocation")
            }
            errno => DiskReservation::Refused { errno, errno_name: errno_name(errno) },
        }
    }
}

/// Dep-light stand-in: the reservation is honestly UNAVAILABLE, not passing.
#[cfg(not(feature = "kernel-build"))]
impl DiskReserver for FallocateReserver {
    fn reserve(&self, _dir: &Path, _bytes: u64) -> DiskReservation {
        DiskReservation::NotChecked("built without the `kernel-build` feature")
    }
}

/// The refusals worth naming, by name. A bare `errno 122` in a build refusal is a
/// number the reader then has to look up; `EDQUOT` is the whole diagnosis.
pub fn errno_name(errno: i32) -> &'static str {
    // Spelled without `libc` so the DEFAULT build can name a refusal a fake
    // produced. The four values are stable Linux ABI.
    match errno {
        28 => "ENOSPC",
        122 => "EDQUOT",
        13 => "EACCES",
        30 => "EROFS",
        27 => "EFBIG",
        _ => "errno",
    }
}

/// **The declaration that builds [`KERNEL_TOOLCHAIN_IMAGE`]** — file mode over a
/// materialized [`KERNEL_TOOLCHAIN_CONTAINERFILE`], so the image rides the
/// ORDINARY image kind (`build_container`) and gets the ordinary provenance
/// seal. `containerfile_rel` is the repo-relative path the text was written to
/// ([`materialize_kernel_toolchain_containerfile`]).
///
/// No second builder, no second engine, no shell: the kernel kind contributes
/// the TEXT and lets the existing container path do the building.
pub fn kernel_toolchain_container(containerfile_rel: &str) -> crate::container::ContainerBuild {
    crate::container::ContainerBuild {
        name: KERNEL_TOOLCHAIN_IMAGE.to_string(),
        containerfile: containerfile_rel.to_string(),
        tags: vec![format!("{KERNEL_TOOLCHAIN_IMAGE}:latest")],
        ..Default::default()
    }
}

/// **The digest a container recipe must produce, read off the kernel rows that pin it**
/// (2026-09-22). Every `[[build.kernel]]` row of `set` whose `toolchain_image` is `image` and
/// that declares `toolchain_image_sha256` contributes its pin. `None` when no row pins it (the
/// image is not a kernel toolchain, or nothing is pinned — then there is nothing to be wrong
/// about); refused when two rows pin the SAME image to two digests, because a build that
/// satisfied one would move the tag under the other.
///
/// The kernel rows are the one authority: the container cell carries no pin of its own, so the
/// two can never drift apart.
pub fn toolchain_pin_for(set: &crate::variant::BuildVariantSet, image: &str) -> Result<Option<String>> {
    let mut pins: Vec<(&str, String)> = set
        .kernels
        .iter()
        .filter(|k| k.toolchain_image == image && !k.toolchain_image_sha256.trim().is_empty())
        .map(|k| (k.name.as_str(), normalize_sha256(&k.toolchain_image_sha256)))
        .collect();
    pins.sort_by(|a, b| a.1.cmp(&b.1));
    match pins.as_slice() {
        [] => Ok(None),
        [(_, first), rest @ ..] if rest.iter().all(|(_, p)| p == first) => Ok(Some(first.clone())),
        _ => Err(anyhow!(
            "the kernel rows pin the toolchain image '{image}' to different digests ({}); a build \
             of it can satisfy at most one of them, so it is refused rather than tagged",
            pins.iter().map(|(n, p)| format!("{n} → {}", &p[..p.len().min(12)])).collect::<Vec<_>>().join(", ")
        )),
    }
}

/// `sha256:ABC…` / `abc…` → `abc…`: the one spelling pins are compared in.
pub fn normalize_sha256(s: &str) -> String {
    s.trim().strip_prefix("sha256:").unwrap_or(s.trim()).to_ascii_lowercase()
}

/// Write [`KERNEL_TOOLCHAIN_CONTAINERFILE`] into `dir` and return the path.
/// The crate SHIPS the text (one authority); a consumer materializes it where
/// its own build context lives.
pub fn materialize_kernel_toolchain_containerfile(dir: &Path) -> Result<std::path::PathBuf> {
    std::fs::create_dir_all(dir)
        .with_context(|| format!("kernel toolchain: create {}", dir.display()))?;
    let p = dir.join("kernel-toolchain.Containerfile");
    std::fs::write(&p, KERNEL_TOOLCHAIN_CONTAINERFILE)
        .with_context(|| format!("kernel toolchain: write {}", p.display()))?;
    Ok(p)
}

// ── the RUN loop: the only instrument that sees a WORKLOAD (design §4) ──────

/// The marker each profiled function line carries on the serial console.
pub const RUN_FN_MARKER: &str = "NORDISK-RUN-FN";
/// Printed once the profiler has been read out, so a truncated transcript is
/// distinguishable from a workload that touched nothing.
pub const RUN_DONE_MARKER: &str = "NORDISK-RUN-COMPLETE";

/// **The RUN's PID 1** — enables ftrace's function profiler, performs a small
/// declared workload, disables it, and prints every function's HIT COUNT.
///
/// The workload is deliberately the appliance's own shape rather than a
/// benchmark: mount the pseudo-filesystems, look at the devices the boot
/// contract promises, open and read a file, touch the network stack. What that
/// measures is "what does BOOTING AND EXISTING touch", which for an appliance
/// whose job is a tunnel is most of the truth. It is not a claim about any
/// other workload, and the report says so.
pub const RUN_INIT_C: &str = r#"
#include <stdio.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/reboot.h>
#include <unistd.h>
#include <fcntl.h>

static int write_one(const char *path, const char *val) {
    int fd = open(path, O_WRONLY);
    if (fd < 0) return -1;
    ssize_t n = write(fd, val, strlen(val));
    close(fd);
    return n > 0 ? 0 : -1;
}

/* The tracefs mount point differs across docs and kernels; try the modern one
   first and fall back, then REPORT which worked. A path that silently was not
   there would read as a workload that executed nothing. */
static const char *mount_tracing(void) {
    /* MEASURED 2026-08-23: this used to mkdir /sys/kernel/tracing and mount
       there, which needs CONFIG_SYSFS and a /sys/kernel that only exists once
       sysfs is mounted. A tinyconfig kernel need not have sysfs at all, so the
       mkdir failed, both mounts failed, and the guest correctly reported "no
       tracefs and no debugfs" on a kernel that DID carry the profiler.
       tracefs can be mounted anywhere; a plain top-level directory needs
       nothing from the kernel but the filesystem itself. */
    mkdir("/tracing", 0755);
    if (mount("tracefs", "/tracing", "tracefs", 0, NULL) == 0)
        return "/tracing";
    mkdir("/debug", 0755);
    if (mount("debugfs", "/debug", "debugfs", 0, NULL) == 0)
        return "/debug/tracing";
    /* Last resort: the conventional paths, for a kernel that HAS sysfs and a
       reader who goes looking by hand. */
    mkdir("/sys", 0755);
    if (mount("sysfs", "/sys", "sysfs", 0, NULL) == 0) {
        mkdir("/sys/kernel", 0755);
        mkdir("/sys/kernel/tracing", 0755);
        if (mount("tracefs", "/sys/kernel/tracing", "tracefs", 0, NULL) == 0)
            return "/sys/kernel/tracing";
    }
    return NULL;
}

int main(void) {
    char path[256];
    struct stat st;
    setvbuf(stdout, NULL, _IONBF, 0);
    mkdir("/dev", 0755);
    mount("devtmpfs", "/dev", "devtmpfs", 0, NULL);
    mkdir("/proc", 0755);
    mount("proc", "/proc", "proc", 0, NULL);

    const char *tr = mount_tracing();
    if (!tr) {
        printf("NORDISK-RUN-REFUSED no tracefs and no debugfs - this kernel was not built with the profiler\n");
        sync();
        reboot(RB_POWER_OFF);
        for (;;) pause();
    }
    printf("NORDISK-RUN-TRACING %s\n", tr);

    snprintf(path, sizeof path, "%s/function_profile_enabled", tr);
    if (write_one(path, "1") != 0) {
        printf("NORDISK-RUN-REFUSED %s is not writable - CONFIG_FUNCTION_PROFILER is missing\n", path);
        sync();
        reboot(RB_POWER_OFF);
        for (;;) pause();
    }

    /* the workload: an appliance being an appliance */
    stat("/dev/console", &st);
    stat("/dev/net/tun", &st);
    stat("/dev/vda", &st);
    int fd = open("/proc/version", O_RDONLY);
    if (fd >= 0) { char b[512]; ssize_t r = read(fd, b, sizeof b); (void)r; close(fd); }
    int sk = socket(AF_INET, SOCK_DGRAM, 0);
    if (sk >= 0) close(sk);
    sync();

    write_one(path, "0");

    /* ── THE FULL DATA GOES TO THE DISK, NOT THE CONSOLE ──
       The guest has a virtio disk attached. A serial console is a terrible
       transport for 18 000 names, but /dev/vda is right there, and the
       container reads the image back afterwards. No filesystem is needed: the
       payload is written raw at offset 0 behind a magic + length header, which
       is the whole protocol. */
    int disk = open("/dev/vda", O_WRONLY);
    if (disk >= 0) {
        char hdr[64];
        /* Reserve the header, write the payload after it, then seek back and
           stamp the real length — so a truncated write can never look complete. */
        off_t body = 64;
        lseek(disk, body, SEEK_SET);
        unsigned long written = 0, count = 0;
        snprintf(path, sizeof path, "%s/available_filter_functions", tr);
        FILE *av2 = fopen(path, "r");
        if (av2) {
            char b3[512];
            while (fgets(b3, sizeof b3, av2)) {
                size_t n2 = strlen(b3);
                while (n2 && (b3[n2-1] == 10 || b3[n2-1] == 13)) b3[--n2] = 0;
                /* `symbol` or `symbol [module]`; the name is the first token. */
                char *sp = strchr(b3, ' ');
                if (sp) *sp = 0;
                n2 = strlen(b3);
                b3[n2] = 10;
                ssize_t w = write(disk, b3, n2 + 1);
                if (w > 0) { written += (unsigned long)w; count++; }
            }
            fclose(av2);
        }
        int hn = snprintf(hdr, sizeof hdr, "NORDISK-RUN-DISK1 %lu %lu\n", count, written);
        while (hn < (int)sizeof hdr) hdr[hn++] = 32;
        lseek(disk, 0, SEEK_SET);
        ssize_t hw = write(disk, hdr, sizeof hdr);
        (void)hw;
        fsync(disk);
        close(disk);
        printf("NORDISK-RUN-DISK %lu\n", count);
    } else {
        printf("NORDISK-RUN-DISK-ABSENT /dev/vda could not be opened for writing\n");
    }

    /* How many functions COULD have been traced. The hit list below names only
       functions that ran, so the zero-hit set — the drop candidates design §4
       is after — is this count minus that list. Reported as a NUMBER because the
       names are tens of thousands of lines and a serial console is not a
       transport for them. */
    snprintf(path, sizeof path, "%s/available_filter_functions", tr);
    FILE *av = fopen(path, "r");
    if (av) {
        unsigned long n = 0;
        char b2[512];
        while (fgets(b2, sizeof b2, av)) n++;
        fclose(av);
        printf("NORDISK-RUN-TRACEABLE %lu\n", n);
    }

    /* the measurement */
    snprintf(path, sizeof path, "%s/trace_stat/function0", tr);
    FILE *f = fopen(path, "r");
    if (!f) {
        printf("NORDISK-RUN-REFUSED %s absent - the profiler ran but wrote no counters\n", path);
    } else {
        char line[512];
        while (fgets(line, sizeof line, f)) {
            size_t n = strlen(line);
            while (n && (line[n-1] == 10 || line[n-1] == 13)) line[--n] = 0;
            printf("NORDISK-RUN-FN %s\n", line);
        }
        fclose(f);
    }
    printf("NORDISK-RUN-COMPLETE\n");
    sync();
    reboot(RB_POWER_OFF);
    for (;;) pause();
}
"#;

/// The in-container RUN script: compile the profiling PID 1, pack it, boot the
/// COLLECTED `bzImage` under QEMU, and let the orchestrator judge the
/// transcript. Same shape as the probe — the two differ only in their PID 1 and
/// in what they read out.
pub fn run_script(k: &KernelBuild) -> String {
    let mut s = String::new();
    s.push_str("set -eu
");
    s.push_str("cd /work
");
    s.push_str("mkdir -p run/initramfs
");
    s.push_str("cat > run/init.c <<'RUN_EOF'
");
    s.push_str(RUN_INIT_C);
    s.push_str("
RUN_EOF
");
    s.push_str("gcc -static -Os -o run/initramfs/init run/init.c
");
    s.push_str("( cd run/initramfs && find . | cpio -o -H newc --quiet ) > run/initramfs.cpio
");
    // 64 MiB: the traceable set is ~19 000 names, well under a megabyte, and a
    // disk too small would truncate the ONE artifact this step exists to
    // produce. Sparse, so it costs nothing until written.
    s.push_str("dd if=/dev/zero of=run/traceable.img bs=1M count=64 status=none\n");
    s.push_str(&format!(
        "timeout {} qemu-system-x86_64 -nographic -no-reboot -m 512 \
",
        k.probe_timeout_secs
    ));
    s.push_str("  -kernel /work/out/bzImage -initrd run/initramfs.cpio \
");
    s.push_str("  -append 'console=ttyS0 panic=1' \
");
    s.push_str("  -device virtio-net-pci,netdev=n0 -netdev user,id=n0 \
");
    s.push_str("  -drive file=run/traceable.img,if=virtio,format=raw \\\n");
    s.push_str("  2>&1 | tee /work/out/run-serial.log\n");
    // Lift the payload off the disk image into plain files the host reads. The
    // header is a fixed 64 bytes: `NORDISK-RUN-DISK1 <count> <bytes>`, so the
    // body is everything from byte 65 for exactly <bytes>. A truncated write
    // cannot look complete, because the length is stamped LAST.
    s.push_str("head -c 64 run/traceable.img > /work/out/run-traceable.hdr\n");
    s.push_str("tail -c +65 run/traceable.img | head -c \"$(cut -d' ' -f3 /work/out/run-traceable.hdr)\" > /work/out/run-traceable.txt\n");
    // ── function -> .o -> obj-$(CONFIG_X): the mapping that turns a list of
    //    names into a list of SYMBOLS a person can click off ──
    //
    // `nm` over every built object gives symbol -> object; the object's own
    // directory Makefile gives object -> the CONFIG that compiles it. Both are
    // facts in the tree, so nothing here infers a symbol from a name. A function
    // whose object no Makefile claims is emitted with `-` rather than dropped:
    // an unexplained function is a finding, and silence would hide it.
    //
    // The tree is found by GLOB, not by the declared version: a RUN declaration
    // describes a kernel that is ALREADY BUILT and legitimately carries no
    // version, so `cd /work/src/linux-$version` sent the script somewhere that
    // does not exist. MEASURED — the live run passes `0.0.0`.
    //
    // And the whole block is NON-FATAL BY CONSTRUCTION. It enriches a
    // measurement that has already succeeded and already been written to disk;
    // the first version ran under `set -eu`, failed, and threw away a perfectly
    // good 18 760-function measurement. An optional step must never be able to
    // discard the mandatory one.
    s.push_str("map_ok=0\n");
    s.push_str("for tree in /work/src/linux-*; do\n");
    s.push_str("  [ -d \"$tree\" ] || continue\n");
    s.push_str("  [ -f \"$tree/vmlinux\" ] || continue\n");
    s.push_str("  (\n");
    s.push_str("    cd \"$tree\" || exit 1\n");
    s.push_str("    find . -name '*.o' ! -name '*.mod.o' -print0 \\\n");
    s.push_str("      | xargs -0 -r nm -A --defined-only 2>/dev/null \\\n");
    s.push_str("      | awk -F: '{split($2,a,\" \"); if (a[2] ~ /^[TtWw]$/) print a[3], $1}' \\\n");
    s.push_str("      | sort -u > /work/out/sym-to-obj.txt\n");
    s.push_str("    { find . -name Makefile; find . -name Kbuild; } \\\n");
    s.push_str("      | while IFS= read -r mk; do d=$(dirname \"$mk\"); \\\n");
    s.push_str("          sed -n 's/^[[:space:]]*obj-\\$(\\(CONFIG_[A-Z0-9_]*\\))[[:space:]]*+*=[[:space:]]*\\(.*\\)/\\1 \\2/p; s/^[[:space:]]*obj-y[[:space:]]*+*=[[:space:]]*\\(.*\\)/CONFIG_Y \\1/p' \"$mk\" \\\n");
    s.push_str("          | while read -r cfg objs; do for o in $objs; do case \"$o\" in *.o) echo \"$d/$o $cfg\";; esac; done; done; \\\n");
    s.push_str("        done | sort -u > /work/out/obj-to-config.txt\n");
    s.push_str("    awk 'NR==FNR{c[$1]=$2; next} {print $1, $2, ($2 in c ? c[$2] : \"-\")}' \\\n");
    s.push_str("      /work/out/obj-to-config.txt /work/out/sym-to-obj.txt > /work/out/fn-to-config.txt\n");
    s.push_str("  ) && map_ok=1\n");
    s.push_str("  break\n");
    s.push_str("done\n");
    s.push_str("if [ \"$map_ok\" = 1 ]; then\n");
    s.push_str("  echo \"NORDISK-RUN-MAPPED $(wc -l < /work/out/fn-to-config.txt)\"\n");
    s.push_str("else\n");
    s.push_str("  echo 'NORDISK-RUN-MAP-ABSENT no built tree with a vmlinux under /work/src - the mapping needs the BUILD tree, not just the bzImage'\n");
    s.push_str("fi\n");
    // The measurement is already on disk; this step is an enrichment and must
    // not be able to fail the run.
    s.push_str("exit 0\n");
    s
}

/// What the RUN measured: every function the workload executed, with its hit
/// count, plus the counters' own provenance.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct KernelRun {
    /// Where the counters were read from (`/sys/kernel/tracing` or the debugfs
    /// fallback) — a path that silently was not there would read as a workload
    /// that executed nothing.
    pub tracing_path: String,
    /// The profiler was read out to the end.
    pub complete: bool,
    /// `(function, hits)`, in the order the kernel reported them.
    pub hits: Vec<(String, u64)>,
    /// How many functions the kernel could have traced. The zero-hit set — the
    /// drop candidates — is this MINUS [`hits`](Self::hits); the names are tens
    /// of thousands of lines and a serial console is not a transport for them,
    /// so the magnitude is reported and the names are not.
    pub traceable: u64,
    /// **Every traceable function's NAME**, lifted off the virtio disk rather
    /// than the serial console. Empty when the guest could not write the disk —
    /// which is a stated absence, not an empty measurement.
    pub traceable_names: Vec<String>,
    /// **`(function, object, CONFIG)`** — the mapping the container derived
    /// from the BUILD TREE: `nm` over every object for symbol→object, and each
    /// directory's Makefile for `obj-$(CONFIG_X) += foo.o`. Both are facts in
    /// the tree; nothing here infers a symbol from a name. `CONFIG` is `-` when
    /// no Makefile claims the object — an unexplained function is a finding, so
    /// it is carried rather than dropped.
    ///
    /// Empty when the run had only a `bzImage` and no build tree to read.
    pub mapping: Vec<(String, String, String)>,
    /// A refusal the guest printed for itself (missing profiler, absent
    /// counters) — carried as a VALUE so the caller names it.
    pub refused: Option<String>,
}

impl KernelRun {
    /// Read a serial transcript. Pure, so the judging is testable without a
    /// daemon or a kernel.
    pub fn read(lines: &[String]) -> Self {
        let mut r = KernelRun::default();
        for l in lines {
            if let Some(rest) = l.split("NORDISK-RUN-TRACING ").nth(1) {
                r.tracing_path = rest.trim().to_string();
            }
            if let Some(rest) = l.split("NORDISK-RUN-REFUSED ").nth(1) {
                r.refused = Some(rest.trim().to_string());
            }
            if let Some(rest) = l.split("NORDISK-RUN-TRACEABLE ").nth(1) {
                if let Ok(n) = rest.trim().parse::<u64>() {
                    r.traceable = n;
                }
            }
            if l.contains(RUN_DONE_MARKER) {
                r.complete = true;
            }
            if let Some(rest) = l.split("NORDISK-RUN-FN ").nth(1) {
                // `  Function      Hit    Time  Avg  s^2` — the header and the
                // dashes are skipped by requiring a parsed count.
                let mut it = rest.split_whitespace();
                if let (Some(f), Some(h)) = (it.next(), it.next()) {
                    if let Ok(hits) = h.parse::<u64>() {
                        r.hits.push((f.to_string(), hits));
                    }
                }
            }
        }
        r
    }

    /// Total functions executed at least once.
    pub fn executed(&self) -> usize {
        self.hits.iter().filter(|(_, h)| *h > 0).count()
    }

    /// **The drop candidates' magnitude**: traceable functions this workload
    /// never entered. `None` when the guest could not report the traceable set,
    /// because "zero of an unknown total" is not a measurement.
    pub fn never_executed(&self) -> Option<u64> {
        (self.traceable > 0).then(|| self.traceable.saturating_sub(self.executed() as u64))
    }

    /// **THE REPORT §4 ASKED FOR**: every CONFIG symbol whose functions this
    /// workload never entered once — the symbols a person could click OFF.
    ///
    /// A symbol counts as TOUCHED if ANY function in ANY object it compiles was
    /// hit. That direction matters: a symbol is a drop candidate only when
    /// nothing under it ran, and one hit anywhere is enough to keep it.
    ///
    /// Returns `(never_touched, touched)` counts alongside the names, because a
    /// list of 150 symbols means nothing without the 156 it came from.
    /// Empty when [`mapping`](Self::mapping) is empty — no tree, no report.
    pub fn unused_configs(&self) -> (Vec<&str>, usize) {
        use std::collections::{BTreeMap, BTreeSet};
        let ran: BTreeSet<&str> = self
            .hits
            .iter()
            .filter(|(_, h)| *h > 0)
            .map(|(f, _)| f.as_str())
            .collect();
        let mut touched: BTreeMap<&str, bool> = BTreeMap::new();
        for (func, _obj, cfg) in &self.mapping {
            if cfg == "-" {
                continue;
            }
            let e = touched.entry(cfg.as_str()).or_insert(false);
            if ran.contains(func.as_str()) {
                *e = true;
            }
        }
        let hot = touched.values().filter(|v| **v).count();
        (touched.iter().filter(|(_, v)| !**v).map(|(k, _)| *k).collect(), hot)
    }

    /// **The drop candidates THEMSELVES** — every traceable function this
    /// workload never entered, by name.
    ///
    /// This is what the RUN loop is FOR, and it exists because the names ride
    /// the virtio disk the guest already has attached rather than the serial
    /// console. Empty when the guest could not write that disk; a caller must
    /// not read an empty vec as "everything ran".
    pub fn never_executed_names(&self) -> Vec<&str> {
        let ran: std::collections::BTreeSet<&str> = self
            .hits
            .iter()
            .filter(|(_, h)| *h > 0)
            .map(|(f, _)| f.as_str())
            .collect();
        self.traceable_names
            .iter()
            .map(String::as_str)
            .filter(|f| !ran.contains(f))
            .collect()
    }
}

/// **Boot the built kernel and read what it EXECUTED** — design §4's RUN loop.
///
/// Requires a kernel built with `profile_run = true`; a kernel without the
/// profiler refuses from inside the guest, by name, rather than reporting an
/// empty measurement (which would read exactly like a workload that touched
/// nothing).
pub fn run_kernel_with(
    runner: &dyn KernelContainerRunner,
    k: &KernelBuild,
    repo_root: &Path,
) -> Result<KernelRun> {
    let out_dir = kernel_output_dir(repo_root, k);
    let workdir = out_dir.join("work");
    let bzimage = out_dir.join("bzImage");
    if !bzimage.is_file() {
        return Err(anyhow!(
            "kernel '{}': nothing to run — {} is not on disk. Build it before running it",
            k.name,
            bzimage.display()
        ));
    }
    std::fs::create_dir_all(workdir.join("out"))
        .with_context(|| format!("create {}", workdir.join("out").display()))?;
    std::fs::copy(&bzimage, workdir.join("out").join("bzImage"))
        .with_context(|| format!("kernel '{}': stage bzImage for the run", k.name))?;

    let req = KernelRunRequest {
        name: format!("kernel-run-{}", k.name),
        image: k.toolchain_image_ref(),
        script: run_script(k),
        binds: vec![format!("{}:/work", workdir.display())],
        timeout: Duration::from_secs(k.probe_timeout_secs.saturating_add(60)),
        network: false,
        mem_limit_mb: k.mem_limit_mb,
        cpus: k.cpus,
    };
    let outcome = runner
        .run(&req)
        .map_err(|e| match k.toolchain_repin_hint() {
            Some(hint) => anyhow!("{e:#}\n\n{hint}"),
            None => e,
        })
        .with_context(|| format!("kernel '{}': profile run", k.name))?;
    let transcript: Vec<String> =
        outcome.stdout.iter().chain(outcome.stderr.iter()).cloned().collect();
    // The probe's lesson, applied without having to learn it twice: a
    // transcript is evidence only if the step that produced it SUCCEEDED, and
    // this script's own source carries every marker the reader looks for.
    if outcome.exit_code != Some(0) {
        if out_of_room(&transcript) {
            return Err(anyhow!(
                "kernel '{}': the RUN ran out of room to write — see the build path's note;                  nothing was measured",
                k.name
            ));
        }
        let tail: Vec<&str> = transcript.iter().rev().take(20).map(String::as_str).rev().collect();
        return Err(anyhow!(
            "kernel '{}': the run step itself failed (exit {:?}) — nothing was measured.              Marker text below is the RUN'S OWN SOURCE quoted back, not a measurement:
{}",
            k.name,
            outcome.exit_code,
            tail.join("
")
        ));
    }
    let mut run = KernelRun::read(&transcript);
    // The names come off the DISK the guest wrote, not the console it printed
    // to. Absence here is stated, never silently an empty measurement.
    let lifted = workdir.join("out").join("run-traceable.txt");
    if let Ok(text) = std::fs::read_to_string(&lifted) {
        run.traceable_names =
            text.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect();
    }
    // The tree-derived mapping, when the run had a build tree to read.
    if let Ok(text) = std::fs::read_to_string(workdir.join("out").join("fn-to-config.txt")) {
        run.mapping = text
            .lines()
            .filter_map(|l| {
                let mut it = l.split_whitespace();
                match (it.next(), it.next(), it.next()) {
                    (Some(f), Some(o), Some(c)) => {
                        Some((f.to_string(), o.to_string(), c.to_string()))
                    }
                    _ => None,
                }
            })
            .collect();
    }
    if let Some(why) = &run.refused {
        return Err(anyhow!(
            "kernel '{}': the guest refused to measure itself — {why}. Build it with              `profile_run = true`; a kernel without CONFIG_FUNCTION_PROFILER reports an              EMPTY measurement, which reads exactly like a workload that executed nothing",
            k.name
        ));
    }
    if !run.complete {
        let tail: Vec<&str> = transcript.iter().rev().take(20).map(String::as_str).rev().collect();
        return Err(anyhow!(
            "kernel '{}': the run never reached {RUN_DONE_MARKER} — the counters were not              read out, so a short list here is truncation and not a small workload.              Serial tail:
{}",
            k.name,
            tail.join("
")
        ));
    }
    Ok(run)
}

/// **Sweep old kept build workdirs, newest-N-survive.** Returns what it removed.
///
/// ★ MEASURED 2026-08-23, expensively. `KERNEL_LIVE_KEEP` exists so an artifact
/// survives for inspection (applied output, not a green line) — and it had NO
/// retention policy at all. Eight kept builds at 1.9–2.5 GB each is ~15 GB;
/// nothing ever swept them, the scratch filesystem hit its quota, and the box's
/// entire tooling stopped working until a human ran `rm -rf` by hand. A
/// convenience with no retention policy is a slow leak that eventually takes
/// something unrelated down with it.
///
/// Only directories that LOOK like kept kernel builds are touched — a
/// `.kernel-out/` inside them — so pointing this at the wrong path removes
/// nothing. `keep_last == 0` is refused rather than treated as "remove
/// everything": that reading is how a sweep becomes a disaster.
pub fn sweep_kept_builds(root: &Path, keep_last: usize) -> Result<Vec<PathBuf>> {
    if keep_last == 0 {
        return Err(anyhow!(
            "sweep_kept_builds({}): `keep_last = 0` is refused — a sweep that keeps nothing \
             is a delete, and a delete should be spelled as one",
            root.display()
        ));
    }
    let mut kept: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
    let Ok(dir) = std::fs::read_dir(root) else {
        return Ok(Vec::new());
    };
    for e in dir.flatten() {
        let p = e.path();
        // The marker of a kept BUILD, not of a directory that merely sits here.
        if !p.join(".kernel-out").is_dir() {
            continue;
        }
        let when = e
            .metadata()
            .and_then(|m| m.modified())
            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
        kept.push((when, p));
    }
    // Newest first, so `skip(keep_last)` leaves exactly the newest N standing.
    kept.sort_by_key(|(when, _)| std::cmp::Reverse(*when));
    let mut removed = Vec::new();
    for (_, p) in kept.into_iter().skip(keep_last) {
        std::fs::remove_dir_all(&p)
            .with_context(|| format!("sweep kept build {}", p.display()))?;
        removed.push(p);
    }
    Ok(removed)
}

/// **Did this container die because it ran out of room to write?**
///
/// ★ MEASURED 2026-08-23, on the session that wrote this code. The disk
/// preflight ([`KernelBuild::min_free_disk_mb`]) called `statvfs`, saw 23 GiB
/// free, and waved the build through — and the build died ninety seconds later
/// with `tar: … Cannot write: Disk quota exceeded`. `statvfs` reports the
/// FILESYSTEM's free space; this was `EDQUOT`, a QUOTA against the writing
/// user or project, which no amount of free filesystem says anything about. The
/// same blind spot covers a container storage driver's own limit and a bind
/// mount whose backing store fills after the check.
///
/// So the preflight is necessary and NOT sufficient, and the honest place to
/// catch the rest is here: a build that fails this way must say the sentence
/// out loud instead of handing back thirty lines of `tar` and letting the
/// reader guess. Kept as a pure function over the transcript so a test can hand
/// it the exact stderr this box produced.
pub fn out_of_room(transcript: &[String]) -> bool {
    // The kernel's own spellings, in the two errnos that mean "there was no
    // room": ENOSPC and EDQUOT. Matched case-insensitively because tar, gcc,
    // ld and the shell each phrase it differently.
    const MARKS: &[&str] = &[
        "disk quota exceeded",
        "no space left on device",
        "quota exceeded",
        "write error: no space",
    ];
    transcript.iter().any(|l| {
        let l = l.to_ascii_lowercase();
        MARKS.iter().any(|m| l.contains(m))
    })
}

// ── the PROBE: the only step that can catch what the canary cannot ──────────

/// The marker the probe's PID 1 prints on the serial console. Deliberately not a
/// word the kernel or QEMU could print on their own.
pub const PROBE_MARKER: &str = "NORDISK-PROBE-PID1-ALIVE";
/// The marker printed after PID 1 has read `/proc/self/exe` and looked at the
/// devices the boot contract promises — a second, LATER line, so "it booted" and
/// "the machine it booted into is the declared one" cannot be the same claim.
pub const PROBE_DEVICES_MARKER: &str = "NORDISK-PROBE-DEVICES";

/// **The probe's PID 1** — a static C program, compiled by the toolchain image's
/// own gcc, that prints [`PROBE_MARKER`], reports the devices the boot contract
/// promises, and powers the guest off.
///
/// Why a C program and not a shell script: there is no shell in the initramfs,
/// and putting one there would test the shell. This binary IS the test of
/// `CONFIG_BINFMT_ELF` — the option the canary can NEVER catch, because the
/// canary only checks what the fragment names, and a kernel that cannot exec
/// PID 1 is not a smaller kernel, it is a brick.
pub const PROBE_INIT_C: &str = r#"
#include <dirent.h>
#include <stdio.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/reboot.h>
#include <unistd.h>

/* Report a device node the boot contract promises. Absence is NAMED, never
   silent: a probe that only printed what it found would read the same on a
   kernel with no virtio at all. */
static void look(const char *what, const char *path) {
    struct stat st;
    printf("NORDISK-PROBE-DEV %s %s\n", what, stat(path, &st) == 0 ? "present" : "ABSENT");
}

/* Leg (c): the consoles the KERNEL actually registered, read from its own
   /proc/consoles. Applied output, not a claim: a `console=` on the cmdline that
   pointed at a UART that is not there leaves no row here, which is exactly the
   UpCloud dark boot with a name. */
static void consoles(void) {
    FILE *f = fopen("/proc/consoles", "r");
    char line[256];
    if (!f) { printf("NORDISK-PROBE-CONSOLE (no /proc/consoles)\n"); return; }
    while (fgets(line, sizeof line, f)) {
        char *sp = strchr(line, ' ');
        if (sp) *sp = 0;
        printf("NORDISK-PROBE-CONSOLE %s\n", line);
    }
    fclose(f);
}

/* Leg (b): every interface the kernel's netdev core registered. A built-in
   driver that bound its device is the ONLY way a name appears here — a driver
   compiled as a module in an initramfs, or compiled out, leaves nothing, which
   is the measured "e1000=m guest is dead metal" failure. */
static void nets(void) {
    DIR *d = opendir("/sys/class/net");
    struct dirent *e;
    if (!d) { printf("NORDISK-PROBE-NET (no /sys/class/net)\n"); return; }
    while ((e = readdir(d))) {
        if (e->d_name[0] == '.') continue;
        printf("NORDISK-PROBE-NET %s\n", e->d_name);
    }
    closedir(d);
}

int main(void) {
    /* Unbuffered: a panic after this line must not eat the evidence. */
    setvbuf(stdout, NULL, _IONBF, 0);
    printf("%s\n", "NORDISK-PROBE-PID1-ALIVE");
    mkdir("/dev", 0755);
    mkdir("/proc", 0755);
    mkdir("/sys", 0755);
    /* devtmpfs is where PID 1 gets a /dev at all; CONFIG_DEVTMPFS(_MOUNT) is on
       the boot contract, so a failure here is a finding, not an accident. */
    mount("devtmpfs", "/dev", "devtmpfs", 0, NULL);
    mount("proc", "/proc", "proc", 0, NULL);
    mount("sysfs", "/sys", "sysfs", 0, NULL);
    look("console", "/dev/console");
    look("tun", "/dev/net/tun");
    /* Leg (a), one node per bus the `target` group can spell. Every one is
       reported present OR ABSENT, so the transcript answers the question for a
       preset that wanted a different bus than the one this kernel got. */
    look("virtio-blk", "/dev/vda");
    look("scsi-sd", "/dev/sda");
    look("nvme", "/dev/nvme0n1");
    look("xen-blk", "/dev/xvda");
    look("cdrom", "/dev/sr0");
    consoles();
    nets();
    printf("%s\n", "NORDISK-PROBE-DEVICES");
    sync();
    reboot(RB_POWER_OFF);
    for (;;) pause();
}
"#;

/// **The machine a closure probe boots a kernel on** — one cloud preset's
/// storage controller, NIC, chipset and CPU, as QEMU can render them.
///
/// Derived from the preset's per-facet [`MimicAxes`](crate::target::presets::MimicAxes)
/// rather than from [`TargetPreset::qemu_shape`](crate::target::presets::TargetPreset::qemu_shape),
/// because a closure proof needs LESS than a whole-machine mimic: `aws-nitro` has no
/// QEMU NIC, and its NVMe root disk is still exactly what QEMU models and exactly
/// the closure under test (the release-3 axes record the same fact, 2026-09-05).
///
/// Two rules, both MEASURED by lane AN on 2026-08-30 (the closures were composed,
/// built and booted on these shapes; edda tag `saved/kconfig-solver-2026-08-30`):
///
/// * **the storage leg must be the real device.** It is the floor — a guest with no
///   block device cannot be asked whether it found its root — and a SUBSTITUTE disk
///   proves nothing about a kernel composed for the real one: that kernel carries
///   the real driver and not the stand-in's.
/// * **a substitute NIC is NO NIC.** ENA and gVNIC were first given an `e1000e`
///   "for the same leg", and the kernel composed for `aws-nitro` (which carries
///   `ena` and not `e1000e`) booted with `/sys/class/net = [lo]`. A stand-in only
///   means anything when the kernel under test carries the stand-in's driver, and a
///   kernel that did would be a different kernel. So a NIC facet that is not
///   `Exact` renders `-nic none`, and leg (b) is reported UNPROVEN, by name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeMachine {
    /// `-machine`.
    pub machine: &'static str,
    /// `-cpu`, when the preset names one.
    pub cpu: Option<&'static str>,
    /// The `-device` the boot disk is.
    pub disk_device: &'static str,
    /// The node the guest kernel presents for it — what PID 1 stats for leg (a).
    pub disk_node: &'static str,
    /// The NIC `-device`, or `None` when the cloud's NIC has no QEMU model: the
    /// guest gets no network device and leg (b) is UNPROVEN, never failed.
    pub nic_device: Option<&'static str>,
    /// Every facet stood in for, as `(facet, missing, with)` — a green from this
    /// machine must be quoted with it.
    pub substitutions: Vec<(&'static str, &'static str, &'static str)>,
    /// Facets this rendering creates NO device for — the boot says nothing about
    /// them, in either direction.
    pub unrendered: Vec<&'static str>,
}

impl ProbeMachine {
    /// The `-cpu` argument, if any.
    pub fn cpu_arg(&self) -> Option<String> {
        self.cpu.map(str::to_string)
    }

    /// **The probe machine for a target preset**, or the sentence that says why
    /// none can be rendered here.
    pub fn for_preset(p: &crate::target::presets::TargetPreset) -> std::result::Result<Self, String> {
        use crate::target::presets::{DiskBus, MimicAxis};
        let axes = p.axes();
        let mut substitutions = Vec::new();
        let mut unrendered = Vec::new();
        let disk_device = match axes.disk {
            MimicAxis::Exact(d) => d,
            MimicAxis::Substitute { missing, with } => {
                return Err(format!(
                    "the boot disk is `{missing}` and QEMU can only stand `{with}` in for it — a kernel \
                     composed for `{missing}` carries that driver and not the stand-in's, so the storage \
                     leg cannot be proven here"
                ));
            }
            MimicAxis::Absent(why) => return Err(format!("the boot disk cannot be rendered: {why}")),
        };
        let disk_node = match p.boot_disk {
            DiskBus::VirtioBlk => "/dev/vda",
            DiskBus::Nvme => "/dev/nvme0n1",
            DiskBus::VirtioScsi | DiskBus::Ide => "/dev/sda",
            other => return Err(format!("no probe row knows the node `{}` presents", other.as_str())),
        };
        let machine = match axes.chipset {
            MimicAxis::Exact(m) => m,
            MimicAxis::Substitute { missing, with } => {
                substitutions.push(("chipset", missing, with));
                with
            }
            MimicAxis::Absent(why) => return Err(format!("the chipset cannot be rendered: {why}")),
        };
        let nic_device = match axes.nic {
            MimicAxis::Exact(n) => Some(n),
            MimicAxis::Substitute { .. } | MimicAxis::Absent(_) => {
                unrendered.push("nic");
                None
            }
        };
        Ok(ProbeMachine { machine, cpu: p.cpu.qemu_arg(), disk_device, disk_node, nic_device, substitutions, unrendered })
    }
}

/// The in-container probe script: compile PID 1 statically, pack a one-file
/// initramfs, boot the built `bzImage` under QEMU with a serial console, and let
/// the ORCHESTRATOR judge the transcript.
///
/// QEMU runs with `-no-reboot` and a bounded `-append panic=1` so a kernel that
/// panics ENDS instead of looping until the timeout — a hang and a panic are
/// different facts and must not arrive as the same one.
pub fn probe_script(k: &KernelBuild) -> String {
    probe_script_for(k, None)
}

/// **[`probe_script`] shaped by a target preset** — the machine, CPU model,
/// storage controller and NIC the named cloud has ([`ProbeMachine::for_preset`]).
///
/// `None` renders the HISTORICAL argv byte for byte (no `-machine`, no `-cpu`,
/// virtio-blk + virtio-net), so every existing probe is unchanged.
///
/// # Why the shape has to come from the preset (lane AN, 2026-08-30)
///
/// The old script hardcoded `-device virtio-net-pci` and `-drive if=virtio`,
/// which is the `qemu-local` machine. Booting a kernel composed FOR AWS Nitro
/// on that machine proves nothing about Nitro: the guest would report
/// `/dev/vda present` from a driver Nitro does not have and say nothing at all
/// about the `nvme` closure under test. A closure proof has to run on the
/// machine the closure was composed for, or it is measuring the wrong box.
pub fn probe_script_for(k: &KernelBuild, shape: Option<&ProbeMachine>) -> String {
    let mut s = String::new();
    s.push_str("set -eu\n");
    s.push_str("cd /work\n");
    s.push_str("mkdir -p probe/initramfs\n");
    s.push_str("cat > probe/init.c <<'PROBE_EOF'\n");
    s.push_str(PROBE_INIT_C);
    s.push_str("\nPROBE_EOF\n");
    s.push_str("gcc -static -Os -o probe/initramfs/init probe/init.c\n");
    s.push_str(
        "( cd probe/initramfs && find . | cpio -o -H newc --quiet ) > probe/initramfs.cpio\n",
    );
    s.push_str(&format!(
        "timeout {} qemu-system-x86_64 -nographic -no-reboot -m 512 \\\n",
        k.probe_timeout_secs
    ));
    match shape {
        None => {
            s.push_str("  -kernel /work/out/bzImage -initrd probe/initramfs.cpio \\\n");
            s.push_str("  -append 'console=ttyS0 panic=1' \\\n");
            // virtio-net + a virtio disk, so the contract's own devices are
            // actually PRESENT to be found. A probe on a machine with no virtio
            // would report ABSENT for a kernel that is perfectly correct.
            s.push_str("  -device virtio-net-pci,netdev=n0 -netdev user,id=n0 \\\n");
            s.push_str("  -drive file=probe/blank.img,if=virtio,format=raw \\\n");
        }
        Some(sh) => {
            s.push_str(&format!("  -machine {} \\\n", sh.machine));
            if let Some(cpu) = sh.cpu_arg() {
                s.push_str(&format!("  -cpu {cpu} \\\n"));
            }
            s.push_str("  -kernel /work/out/bzImage -initrd probe/initramfs.cpio \\\n");
            // `console=ttyS0` is the PROBE'S OWN channel and is added on top of
            // whatever the preset declares — it is how the transcript exists at
            // all. For a preset whose real console is VGA only, that means the
            // kernel under test must still carry SERIAL_8250, and the caller is
            // told so rather than the run silently proving a console the
            // machine does not have.
            s.push_str("  -append 'console=ttyS0 panic=1' \\\n");
            match sh.nic_device {
                Some(nic) => s.push_str(&format!("  -device {nic},netdev=n0 -netdev user,id=n0 \\\n")),
                // No NIC device exists to create (Hyper-V, Xen). The guest gets
                // none, and the judge must report the network leg UNPROVEN
                // rather than read `no eth0` as a closure failure.
                None => s.push_str("  -nic none \\\n"),
            }
            match sh.disk_device {
                "nvme" => s.push_str("  -drive file=probe/blank.img,if=none,format=raw,id=d0 -device nvme,drive=d0,serial=nordiskprobe \\\n"),
                "ide-hd" => s.push_str("  -drive file=probe/blank.img,if=none,format=raw,id=d0 -device ide-hd,drive=d0 \\\n"),
                "virtio-blk-pci" => s.push_str("  -drive file=probe/blank.img,if=virtio,format=raw \\\n"),
                "virtio-scsi-pci" => s.push_str("  -device virtio-scsi-pci,id=scsi0 -drive file=probe/blank.img,if=none,format=raw,id=d0 -device scsi-hd,drive=d0,bus=scsi0.0 \\\n"),
                other => s.push_str(&format!("  -drive file=probe/blank.img,if=none,format=raw,id=d0 -device {other},drive=d0 \\\n")),
            }
        }
    }
    s.push_str("  2>&1 | tee /work/out/probe-serial.log\n");
    s
}

/// **Boot the built kernel and read the serial console** — the step that answers
/// what the canary structurally cannot.
///
/// The canary checks that every option the FRAGMENT NAMES survived
/// `olddefconfig`. It cannot check an option nobody wrote down: a `tinyconfig`
/// build with no `CONFIG_BINFMT_ELF` passes every canary line and cannot execute
/// PID 1. This is that check, and it is the reason qemu rides in the toolchain
/// image.
pub fn probe_kernel_with(
    runner: &dyn KernelContainerRunner,
    k: &KernelBuild,
    repo_root: &Path,
) -> Result<KernelProbe> {
    probe_kernel_shaped(runner, k, repo_root, None)
}

/// **[`probe_kernel_with`] on the machine a named target preset IS** — the door
/// that turns a derived Kconfig closure into a measured one.
///
/// `shape` comes from [`ProbeMachine::for_preset`]; `None` is the historical
/// `qemu-local` argv.
pub fn probe_kernel_shaped(
    runner: &dyn KernelContainerRunner,
    k: &KernelBuild,
    repo_root: &Path,
    shape: Option<&ProbeMachine>,
) -> Result<KernelProbe> {
    let out_dir = kernel_output_dir(repo_root, k);
    let workdir = out_dir.join("work");
    let bzimage = out_dir.join("bzImage");
    if !bzimage.is_file() {
        return Err(anyhow!(
            "kernel '{}': nothing to probe — {} is not on disk. Build it before booting it",
            k.name,
            bzimage.display()
        ));
    }
    // The probe boots the artifact that was COLLECTED, so what is judged is what
    // a consumer would get, not a leftover in the build tree.
    std::fs::create_dir_all(workdir.join("out"))
        .with_context(|| format!("create {}", workdir.join("out").display()))?;
    std::fs::copy(&bzimage, workdir.join("out").join("bzImage"))
        .with_context(|| format!("kernel '{}': stage bzImage for the probe", k.name))?;
    // A blank disk so the declared block device has something to be. `nvme`
    // refuses a zero-length backing file, so the image is a real (small) one.
    std::fs::create_dir_all(workdir.join("probe"))
        .with_context(|| format!("kernel '{}': stage the probe disk", k.name))?;
    for img in ["blank.img"] {
        std::fs::write(workdir.join("probe").join(img), vec![0u8; 4 * 1024 * 1024])
            .with_context(|| format!("kernel '{}': stage the probe disk {img}", k.name))?;
    }

    let req = KernelRunRequest {
        name: format!("kernel-probe-{}", k.name),
        image: k.toolchain_image_ref(),
        script: probe_script_for(k, shape),
        binds: vec![format!("{}:/work", workdir.display())],
        timeout: Duration::from_secs(k.probe_timeout_secs.saturating_add(60)),
        network: false,
        mem_limit_mb: k.mem_limit_mb,
        cpus: k.cpus,
    };
    let outcome = runner
        .run(&req)
        .map_err(|e| match k.toolchain_repin_hint() {
            Some(hint) => anyhow!("{e:#}\n\n{hint}"),
            None => e,
        })
        .with_context(|| format!("kernel '{}': probe boot", k.name))?;
    let transcript: Vec<String> =
        outcome.stdout.iter().chain(outcome.stderr.iter()).cloned().collect();
    // ★ MEASURED 2026-08-22, a false green in this very function: the probe used
    // to go straight to reading markers. `gcc` failed to compile PID 1 (a real
    // newline had got into a C string literal), and gcc's diagnostics QUOTE THE
    // OFFENDING SOURCE LINE — which contains the marker strings. So the compiler
    // error was parsed as a successful boot, and a kernel that had never been
    // started reported `booted: true`. A transcript is only evidence if the step
    // that produced it SUCCEEDED.
    if outcome.exit_code != Some(0) {
        let tail: Vec<&str> = transcript.iter().rev().take(20).map(String::as_str).rev().collect();
        return Err(anyhow!(
            "kernel '{}': the probe step itself failed (exit {:?}) — nothing was booted, so \
             nothing about this kernel is known. Marker text in the output below is the \
             PROBE'S OWN SOURCE being quoted back, not a boot:\n{}",
            k.name,
            outcome.exit_code,
            tail.join("\n")
        ));
    }
    let probe = KernelProbe::read(&transcript);
    if !probe.booted {
        let tail: Vec<&str> =
            transcript.iter().rev().take(20).map(String::as_str).rev().collect();
        return Err(anyhow!(
            "kernel '{}': the built kernel did NOT reach PID 1 — no {PROBE_MARKER} on the \
             serial console. This is what the canary cannot see: an option nobody wrote \
             down (CONFIG_BINFMT_ELF is the usual one on a tinyconfig base) is missing, so \
             the kernel boots and can execute nothing. Serial tail:\n{}",
            k.name,
            tail.join("\n")
        ));
    }
    Ok(probe)
}

/// What the serial console said — a VALUE, so the caller decides what a missing
/// device means (a `nuked` kernel is SUPPOSED to have no `/dev/vda` if nothing
/// declared virtio-blk).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct KernelProbe {
    /// PID 1 ran: the kernel booted AND could execute an ELF binary.
    pub booted: bool,
    /// PID 1 got as far as reporting devices (so it did not die mid-way).
    pub reported_devices: bool,
    /// Each device the contract promises, and whether it was there.
    pub devices: Vec<(String, bool)>,
    /// Leg (b): every interface `/sys/class/net` held — a built-in driver that
    /// BOUND its device is the only way a name gets here.
    pub nets: Vec<String>,
    /// Leg (c): every console the kernel registered, off `/proc/consoles`.
    pub consoles: Vec<String>,
}

impl KernelProbe {
    /// Read a serial transcript. Pure — the judging happens away from the
    /// container, so a test can hand it a transcript and no daemon exists.
    pub fn read(lines: &[String]) -> Self {
        let mut p = KernelProbe::default();
        for l in lines {
            if l.contains(PROBE_MARKER) {
                p.booted = true;
            }
            if l.contains(PROBE_DEVICES_MARKER) {
                p.reported_devices = true;
            }
            if let Some(rest) = l.split("NORDISK-PROBE-DEV ").nth(1) {
                let mut it = rest.split_whitespace();
                if let (Some(what), Some(state)) = (it.next(), it.next()) {
                    p.devices.push((what.to_string(), state == "present"));
                }
            }
            if let Some(rest) = l.split("NORDISK-PROBE-NET ").nth(1) {
                if let Some(n) = rest.split_whitespace().next() {
                    p.nets.push(n.to_string());
                }
            }
            if let Some(rest) = l.split("NORDISK-PROBE-CONSOLE ").nth(1) {
                if let Some(n) = rest.split_whitespace().next() {
                    p.consoles.push(n.to_string());
                }
            }
        }
        p
    }

    /// Whether a named device was reported present.
    pub fn has(&self, what: &str) -> bool {
        self.devices.iter().any(|(n, ok)| n == what && *ok)
    }

    /// Whether the netdev core registered an interface that is not `lo`.
    pub fn has_net(&self) -> bool {
        self.nets.iter().any(|n| n != "lo")
    }

    /// **Judge this transcript against the machine it was booted on** — the
    /// three legs a closure has to close, each named.
    ///
    /// * (a) the declared boot disk's node exists — the storage closure bound;
    /// * (b) an interface other than `lo` registered — SKIPPED when the shape
    ///   created no NIC at all ([`ProbeMachine::nic_device`] is `None`), because
    ///   "no eth0 on a machine with no NIC" is not a finding;
    /// * (c) at least one console registered.
    ///
    /// `Err` names the leg and quotes what the guest actually reported, so a
    /// failure is readable without the transcript.
    pub fn closes(&self, who: &str, shape: &ProbeMachine) -> Result<()> {
        if !self.booted {
            return Err(anyhow!("{who}: leg (0) — PID 1 never ran"));
        }
        if !self.reported_devices {
            return Err(anyhow!("{who}: leg (0) — PID 1 died between booting and reporting"));
        }
        let want = match shape.disk_node {
            "/dev/vda" => "virtio-blk",
            "/dev/sda" => "scsi-sd",
            "/dev/nvme0n1" => "nvme",
            "/dev/xvda" => "xen-blk",
            "/dev/sr0" => "cdrom",
            other => return Err(anyhow!("{who}: no probe row knows the node {other}")),
        };
        if !self.has(want) {
            return Err(anyhow!(
                "{who}: leg (a) FAILED — the machine was given a `{}` and the kernel never \
                 presented {} ({want} ABSENT). The declared storage closure did not bind. \
                 Devices: {:?}",
                shape.disk_device,
                shape.disk_node,
                self.devices
            ));
        }
        if shape.nic_device.is_some() && !self.has_net() {
            return Err(anyhow!(
                "{who}: leg (b) FAILED — the machine was given a `{}` and no interface but `lo` \
                 registered. The NIC driver is missing or is a module an initramfs cannot load. \
                 /sys/class/net: {:?}",
                shape.nic_device.unwrap_or("-"),
                self.nets
            ));
        }
        if self.consoles.is_empty() {
            return Err(anyhow!("{who}: leg (c) FAILED — the kernel registered no console at all"));
        }
        Ok(())
    }
}

// ── the container-run + fetch seams ──────────────────────────────────────────

/// What the kernel builder asks a container engine to run — the WHOLE request,
/// pure data, so a fake runner can assert every field with no daemon.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KernelRunRequest {
    /// The instance name (`kernel-<variant>`).
    pub name: String,
    /// The pinned toolchain image reference ([`KernelBuild::toolchain_image_ref`]).
    pub image: String,
    /// The `/bin/sh -eu` script ([`build_script`]).
    pub script: String,
    /// Host bind mounts, podman `-v` strings — exactly one: `<workdir>:/work`.
    pub binds: Vec<String>,
    /// The bound ([`KernelBuild::timeout`]); overrun must stop the container and
    /// return an error.
    pub timeout: Duration,
    /// Whether the container gets a network ([`KernelBuild::needs_network`]);
    /// `false` runs airgapped (`--network none`).
    pub network: bool,
    /// The memory bound in MiB ([`KernelBuild::mem_limit_mb`]) — podman
    /// `--memory`, bollard `HostConfig.memory`. Never absent: a build container
    /// that may grow without a ceiling is the 2026-08-20 defect.
    pub mem_limit_mb: u32,
    /// The cpu bound in whole cores ([`KernelBuild::cpus`]) — podman `--cpus`,
    /// bollard `HostConfig.nano_cpus`. The same number the script's `-j` uses.
    pub cpus: u32,
}

/// What came back from the container run — the exec-outcome shape
/// (a non-zero exit is a VALUE here; the ORCHESTRATOR turns it into a named
/// refusal, so the policy lives in one place).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct KernelRunOutcome {
    /// The container's exit code (`Some(0)` = clean), `None` if the engine
    /// reported none.
    pub exit_code: Option<i64>,
    /// Captured stdout lines.
    pub stdout: Vec<String>,
    /// Captured stderr lines.
    pub stderr: Vec<String>,
}

/// **The container seam** the kernel builder drives — one method, so a test
/// injects a fake and proves the orchestration calls (and a red arm) with no
/// daemon. The production impl is [`DraupnirKernelRunner`] (feature
/// `kernel-build`), over draupnir's `backend-oci` bollard engine.
pub trait KernelContainerRunner {
    /// Run the build container to completion within `req.timeout`.
    fn run(&self, req: &KernelRunRequest) -> Result<KernelRunOutcome>;
    /// The engine label the provenance records.
    fn engine(&self) -> &str {
        "draupnir-oci"
    }
    /// **The image store behind this runner**, when it has one — the seam the
    /// OFFLINE toolchain preflight
    /// ([`resolve_toolchain`](crate::toolchain_archive::resolve_toolchain))
    /// drives (`#14`, lane AO).
    ///
    /// `None` is the honest default and NOT a fallback: a runner that cannot be
    /// asked whether an image is present has nothing to say about it, so the
    /// preflight records
    /// [`ToolchainSource::Unchecked`](crate::toolchain_archive::ToolchainSource::Unchecked)
    /// and claims nothing. A runner that CAN answer gets the airgap guarantee:
    /// a missing toolchain becomes a named refusal instead of a registry fetch.
    fn image_store(&self) -> Option<&dyn crate::toolchain_archive::ToolchainImageStore> {
        None
    }
}

/// **The download seam** — host-side, pure Rust (the pure-Rust law holds on the
/// host; the container only compiles). The production impl is [`UreqFetcher`]
/// (feature `kernel-build`). Verification is NOT the fetcher's job: the
/// orchestrator sha256-checks whatever lands, so a fetcher cannot vouch for
/// itself.
pub trait TarballFetcher {
    /// Download `url` to `dest` (atomically enough that a partial file does not
    /// masquerade as a tarball — write then rename, or fail).
    fn fetch(&self, url: &str, dest: &Path) -> Result<()>;
}

/// Lowercase-hex sha256 of a file, streamed (a kernel tarball is ~150 MB — it
/// is hashed, not slurped). The shared `nornir_hash::sha256_file`, with the
/// path folded into the error.
pub fn sha256_file_hex(path: &Path) -> Result<String> {
    nornir_hash::sha256_file(path).with_context(|| format!("sha256 {}", path.display()))
}

// ── the pin record ───────────────────────────────────────────────────────────

/// The pin-file schema tag.
pub const KERNEL_PIN_SCHEMA: &str = "nornir.kernel-pin/1";

/// **The pin file** written beside the built kernel
/// (`<name>.kernel-pin.json`): every sha256 that identifies what was built,
/// from what, with what — the receipt a later boot pins against.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KernelPin {
    /// Schema tag ([`KERNEL_PIN_SCHEMA`]).
    pub schema: String,
    /// The declared variant name.
    pub name: String,
    /// The upstream kernel version.
    pub kernel_version: String,
    /// The tarball URL actually fetched.
    pub source_url: String,
    /// The tarball's declared-and-verified sha256.
    pub tarball_sha256: String,
    /// The effective toolchain image reference (digest-pinned when declared).
    pub toolchain_image: String,
    /// **How the toolchain got onto the box this build ran on** — one of
    /// [`ToolchainSource::as_str`](crate::toolchain_archive::ToolchainSource::as_str):
    /// `already-present` · `staged-archive` · `unpinned` · `unchecked`
    /// (`#14`, lane AO 2026-08-30).
    ///
    /// Absent from every pin written before then, and `#[serde(default)]` for
    /// exactly that reason. It exists because `toolchain_image` records WHICH
    /// toolchain a build claims and nothing recorded whether that claim was
    /// ever checked — and `unchecked` (a runner with no image store) and
    /// `staged-archive` (verified, offline, by content address) are the two ends
    /// of a range that used to read identically on a receipt.
    #[serde(default)]
    pub toolchain_source: String,
    /// The toolchain image's **id** (config digest, bare hex) when the preflight
    /// resolved one — the identity that survives a `docker-archive` round trip,
    /// unlike the manifest digest in `toolchain_image`. Empty when no pin was
    /// declared or no store could be asked.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub toolchain_image_id: String,
    /// The hardening profile name.
    pub profile: String,
    /// The preset the kernel was built from — the axis that decides its
    /// CONTENTS. Absent until 2026-08-23: the receipt recorded the hardening
    /// profile and NOT the thing that decides whether this is a 13.7 MB kernel
    /// or a 1.7 MB one, so an auditor holding the pin could not tell which was
    /// in their hand.
    #[serde(default)]
    pub preset: String,
    /// The RESOLVED kconfig starting point (`defconfig` / `tinyconfig`).
    #[serde(default)]
    pub base: String,
    /// The compiler the kernel was built with.
    #[serde(default)]
    pub toolchain: String,
    /// Whether full clang LTO was on.
    #[serde(default)]
    pub lto: bool,
    /// **Whether the free-disk preflight actually ran, and what it saw.**
    ///
    /// `None` when it could not run at all (the dep-light build cannot call
    /// `statvfs`) or was disabled with `min_free_disk_mb = 0`. Present with the
    /// observed free bytes when it ran. Added because the receipt could not
    /// distinguish a build that PASSED the check from one that SKIPPED it —
    /// and because the check has a blind spot ([`out_of_room`]), a reader needs
    /// to know which of the two they are holding.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub free_disk_bytes_at_start: Option<u64>,
    /// **Bytes the preflight actually RESERVED and released before building** —
    /// the half `free_disk_bytes_at_start` cannot state.
    ///
    /// `statvfs` describing 23 GiB of free filesystem is not the same claim as the
    /// path agreeing to take 3 GiB: the first passed on 2026-08-22 and the build
    /// died on `EDQUOT` ninety seconds later. `Some(n)` means a
    /// [`DiskReservation::Reserved`] of `n` bytes succeeded against the actual
    /// workdir — quota and storage-driver limit included. `None` means the
    /// question was NOT answered (disabled with `min_free_disk_mb = 0`, a
    /// filesystem with no preallocation, or a build without the `kernel-build`
    /// feature) and must never be read as a pass.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub disk_bytes_reserved: Option<u64>,
    /// The MINIFY solver this kernel was passed through, empty when none. An
    /// auditor holding the receipt must be able to tell a solved symbol set from
    /// a declared one — the `preset` lesson of 2026-08-23, not repeated.
    #[serde(default)]
    pub solver: String,
    /// The kmax release that produced the formula (empty when no solver ran).
    #[serde(default)]
    pub solver_version: String,
    /// sha256 of the MERGED kconfig fragment that was applied.
    pub fragment_sha256: String,
    /// sha256 of the produced `bzImage`.
    pub bzimage_sha256: String,
    /// sha256 of the produced `.config`.
    pub config_sha256: String,
}

/// The output directory a built kernel lands in:
/// `<repo_root>/.kernel-out/<name>/` (`bzImage`, `kernel.config`,
/// `<name>.kernel-pin.json`; the fetch/work area is `work/` inside it).
pub fn kernel_output_dir(repo_root: &Path, k: &KernelBuild) -> PathBuf {
    repo_root.join(".kernel-out").join(&k.name)
}

/// The directory under [`kernel_output_dir`] that holds one slot per STEP PIN.
pub const KERNEL_PIN_SLOTS_DIR: &str = "pins";

/// **The slot a built kernel is ALSO filed under, keyed by its step pin**:
/// `<repo_root>/.kernel-out/<name>/pins/<ledger key>/` (`bzImage`,
/// `kernel.config`, `<name>.kernel-pin.json`, `kernel.why.toml`).
///
/// [`kernel_output_dir`] is one slot per kernel NAME and the build's workdir;
/// two consumers of one donor kernel with different requirement closures
/// (tunnr-etcd's XFS and NVMe media, 2026-10-29) are two merges, and the
/// second build overwrote the first. The pinned slot keeps both: the reader
/// ([`resolve_kernel_for`]) serves the name slot when it holds the chosen
/// merge and this slot otherwise.
pub fn kernel_pinned_dir(repo_root: &Path, k: &KernelBuild, ledger_key: &str) -> PathBuf {
    kernel_output_dir(repo_root, k).join(KERNEL_PIN_SLOTS_DIR).join(ledger_key)
}

/// File a just-built kernel's artifacts from the name slot into its pinned
/// slot: staged beside it, then renamed into place, so a reader never sees a
/// half-filed slot.
fn file_under_pin(out_dir: &Path, pinned: &Path, files: &[&Path]) -> Result<()> {
    let parent = pinned.parent().context("a pinned slot has a parent")?;
    std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
    let staging = parent.join(format!(
        ".{}.part",
        pinned.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
    ));
    let _ = std::fs::remove_dir_all(&staging);
    std::fs::create_dir_all(&staging).with_context(|| format!("create {}", staging.display()))?;
    for f in files {
        let name = f.file_name().context("an artifact has a file name")?;
        std::fs::copy(f, staging.join(name))
            .with_context(|| format!("file {} under its pin {}", f.display(), pinned.display()))?;
    }
    let base = out_dir.join(KERNEL_BASE_CONFIG);
    if base.is_file() {
        let _ = std::fs::copy(&base, staging.join(KERNEL_BASE_CONFIG));
    }
    let _ = std::fs::remove_dir_all(pinned);
    std::fs::rename(&staging, pinned)
        .with_context(|| format!("move {} into place at {}", staging.display(), pinned.display()))?;
    Ok(())
}

/// Whether the kernel slot `dir` holds a pin whose merge is `fragment_sha256`.
fn slot_holds(dir: &Path, kernel_name: &str, fragment_sha256: &str) -> bool {
    std::fs::read_to_string(dir.join(format!("{kernel_name}.kernel-pin.json")))
        .ok()
        .and_then(|t| serde_json::from_str::<KernelPin>(&t).ok())
        .is_some_and(|p| p.fragment_sha256 == fragment_sha256)
        && dir.join("bzImage").is_file()
}

/// **A donor's built kernel, resolved and PROVEN** — what
/// [`resolve_built_kernel`] hands a consumer: the artifact paths plus the pin
/// they were verified against.
#[derive(Debug, Clone)]
pub struct ResolvedBuiltKernel {
    /// The built `bzImage`, sha256-verified against the pin.
    pub bzimage: PathBuf,
    /// The built kernel's `.config` beside it.
    pub config: PathBuf,
    /// The pin the artifact was verified against — provenance for the consumer's
    /// own receipt.
    pub pin: KernelPin,
}

/// **Resolve a `[[build.kernel]]` another cell CONSUMES** — the read side of
/// [`crate::installer::KernelDonor`], and the one place "the built kernel IS the
/// chosen kernel" is checked.
///
/// `donor_root` is the donor checkout (resolved beside the consumer by the
/// caller); `donor_label` is its name for refusal texts; `decision` is the
/// operator's `--opt kernel.*` veto, when one was typed — resolved against the
/// DONOR's declaration by the same door the kernel cell's own build uses.
///
/// Four refusals, each BY NAME, because each names a different repair:
///
/// * the donor declares no build space — wrong checkout, not a missing build;
/// * the donor declares no such kernel — `require_kernel`'s own listing;
/// * the kernel is declared but NOT BUILT — the refusal spells the exact
///   command that builds it (`dwarves buildthing <donor> --variant <kernel>`,
///   veto included). "A consumer never builds the donor's kernel" was the rule
///   until 2026-08-26; it is retired as a DECISION by [`resolve_kernel`], which
///   hands the same line back as a typed [`BuildStep`](crate::build_dag::BuildStep)
///   so a door holding `--build` can run it as a step (design f3) — this
///   reader, without `--build`, still refuses by name;
/// * the built artifact DISAGREES with the choice — the pin's fragment sha (the
///   merge of preset + profile + committed veto + the operator's decision, the
///   same bytes [`build_kernel_with`] pinned) does not match what the chosen
///   declaration merges to, or the bzImage on disk no longer matches its own
///   pin. Handing over that file anyway would be a medium whose receipt lies.
pub fn resolve_built_kernel(
    donor_root: &Path,
    donor_label: &str,
    kernel_name: &str,
    decision: Option<&crate::kernel_features::KernelDecision>,
) -> Result<ResolvedBuiltKernel> {
    resolve_built_kernel_with(donor_root, donor_label, kernel_name, decision, &NoLedger)
}

/// **The LEDGER a built kernel may be looked up in BY PIN** — the second,
/// preferred source [`resolve_built_kernel_with`] asks before the donor's local
/// `.kernel-out/`: the suite warehouse / dwarves' build ledger, where a kernel
/// built once (on any box) sits behind the key its pin makes. A preset with no
/// vetoes always merges to the same fragment sha → the same key → the same
/// bzImage, so picking it costs no build. The seam is a trait because this
/// crate is dep-light and owns no store: dwarves fills it with its action cache
/// + artifact store; [`NoLedger`] answers "nothing" and the local fallback
/// stands.
pub trait KernelLedger {
    /// The built kernel filed under `key` ([`ledger_key`]), materialised on
    /// disk: `Ok(None)` when the ledger holds nothing for it (the caller falls
    /// back), `Err` when the ledger could not be asked — which is NOT a miss.
    fn lookup(&self, key: &str) -> Result<Option<ResolvedBuiltKernel>>;
}

/// The ledger that holds nothing — [`resolve_built_kernel`]'s default.
pub struct NoLedger;

impl KernelLedger for NoLedger {
    fn lookup(&self, _key: &str) -> Result<Option<ResolvedBuiltKernel>> {
        Ok(None)
    }
}

/// The ledger-key schema tag — bump when the key's inputs change.
pub const KERNEL_LEDGER_KEY_SCHEMA: &str = "nornir.kernel-ledger-key/1";

/// **The key a built kernel is filed under** — sha256 over everything that
/// decides the bzImage's bytes: the upstream version and tarball pin, the
/// toolchain image, the hardening profile, and the MERGED fragment's sha
/// (preset + profile + the repo's veto file + the operator's decision — the
/// same bytes the pin records as `fragment_sha256`). Two kernels with the same
/// fragment but different versions never collide; a preset with no vetoes
/// keys the same on every box.
pub fn ledger_key(k: &KernelBuild, fragment_sha256: &str) -> String {
    let canonical = format!(
        "{KERNEL_LEDGER_KEY_SCHEMA}\u{2}version={}\u{2}tarball={}\u{2}toolchain={}\u{2}profile={}\u{2}fragment={fragment_sha256}",
        k.version,
        k.tarball_sha256,
        k.toolchain_image_ref(),
        k.profile,
    );
    nornir_hash::sha256_hex(canonical.as_bytes())
}

/// **The key a DECLARED kernel keys as** — the merged fragment of `k` in
/// `repo_root` (decision applied by the caller), hashed into [`ledger_key`].
/// What a build FILES under after it produced the bzImage, and what a consumer
/// LOOKS UP before building — one function, so they cannot disagree.
pub fn ledger_key_of(k: &KernelBuild, repo_root: &Path) -> Result<String> {
    let fragment = merged_fragment(k, k.user_fragment_in(repo_root)?.as_deref())?;
    Ok(ledger_key(k, &nornir_hash::sha256_hex(fragment.as_bytes())))
}

/// **What a kernel must carry to boot a nordisk PID-1 at all** — the options
/// the installer / appliance boot chain reads before it has done anything:
///
/// | option | who dies without it |
/// |---|---|
/// | `CONFIG_PROC_FS` | `korp-start`/`korp-installer` read `korp.*` selectors off `/proc/cmdline` |
/// | `CONFIG_SYSFS` | the installer's volume finder walks `/sys/class/block`; hwdetect walks `/sys/block` |
/// | `CONFIG_DEVTMPFS` | PID-1 opens `/dev/console`, the medium and the target disk |
/// | `CONFIG_BLK_DEV_INITRD` | the initramfs whose `/init` IS the PID-1 |
/// | `CONFIG_BINFMT_ELF` | exec of PID-1 itself (tinyconfig leaves ELF off) |
/// | `CONFIG_SMP` | every vCPU the plan pays for (tinyconfig leaves SMP off; UpCloud 2xCPU served on one core, 2026-08-29) |
///
/// A `[[build.kernel]]` that merges to a `.config` missing any of these is a
/// brick with a receipt. MEASURED 2026-08-27: gunnar-installer-…-nowg.iso
/// shipped on tunnr's `nuked` kernel whose `.config` read `# CONFIG_PROC_FS is
/// not set` / `# CONFIG_SYSFS is not set`; under OVMF the installer printed
/// `mounts proc=MISSING sys=MISSING dev=OK` and died on a payload it could not
/// see. The pin verified (bzImage sha, fragment sha) — every check passed and
/// none of them asked whether the kernel could boot the thing it was for. This
/// list is that question, asked once, where a consumer resolves a donor.
///
/// The check reads the built `.config` (the `kernel.config` beside the
/// bzImage, `config_sha256`-pinned), not the fragment: what survived
/// `olddefconfig` is what boots.
pub const PID1_BOOT_CONTRACT: &[&str] = &[
    "CONFIG_PROC_FS",
    "CONFIG_SYSFS",
    "CONFIG_DEVTMPFS",
    "CONFIG_BLK_DEV_INITRD",
    "CONFIG_BINFMT_ELF",
    // Not a "cannot boot" option, but a "boots and wastes the machine" one:
    // MEASURED 2026-08-29, a two-vCPU UpCloud plan served on one core because
    // tinyconfig had SMP off. Refused by name so it can never ship again.
    "CONFIG_SMP",
];

/// The [`PID1_BOOT_CONTRACT`] options a `.config` text does NOT set to `y`.
/// Pure over the text so a test can hand it any `.config`.
pub fn pid1_boot_contract_missing(config_text: &str) -> Vec<&'static str> {
    let set: std::collections::HashSet<&str> = config_text
        .lines()
        .filter_map(|l| l.strip_suffix("=y"))
        .collect();
    PID1_BOOT_CONTRACT.iter().copied().filter(|sym| !set.contains(sym)).collect()
}

/// **Refuse a built kernel that cannot boot a PID-1** — [`PID1_BOOT_CONTRACT`]
/// against the built `.config`. Called by [`verify_built_kernel`] on both the
/// ledger road and the `.kernel-out/` road, so no consumer composes a medium
/// on a kernel that will print `mounts sys=MISSING` at its customer.
pub fn check_pid1_boot_contract(config: &Path, kernel_name: &str, where_from: &str) -> Result<()> {
    let text = std::fs::read_to_string(config)
        .with_context(|| format!("kernel '{kernel_name}': read the built .config {}", config.display()))?;
    let missing = pid1_boot_contract_missing(&text);
    if missing.is_empty() {
        return Ok(());
    }
    Err(anyhow!(
        "kernel '{kernel_name}' ({where_from}) cannot boot a PID-1: its built .config lacks {} — \
         an initramfs PID-1 mounts /proc, /sys and /dev itself and reads its selectors off \
         /proc/cmdline, so a medium composed on this kernel dies at `mounts … MISSING` on the \
         customer's console (measured 2026-08-27, gunnar-installer-…-nowg.iso). The measured boot \
         contract (kconfig/base.config) + the tinyconfig closure (kconfig/deps-tinyconfig.config) declare \
         every option in this contract; rebuild the kernel from a fragment that carries them ({})",
        missing.iter().map(|s| format!("{s}=y")).collect::<Vec<_>>().join(", "),
        config.display()
    ))
}

/// The [`PID1_BOOT_CONTRACT`] as `.config` text — every option `=y`, one per
/// line. What a fixture that fabricates a built kernel writes as its
/// `kernel.config`, so the fabricated state passes the same gate a real build's
/// output does; a fixture writing `CONFIG_TEST=y` alone is now refused by name.
pub fn pid1_boot_contract_config() -> String {
    let mut s = String::new();
    for sym in PID1_BOOT_CONTRACT {
        s.push_str(sym);
        s.push_str("=y\n");
    }
    s
}

#[cfg(test)]
mod pid1_boot_contract_tests {
    use super::*;

    /// RED: the exact shape that shipped on 2026-08-27 — a tinyconfig kernel with
    /// procfs and sysfs `not set` — is refused, and the refusal names BOTH.
    #[test]
    fn a_nuked_config_without_sysfs_is_refused_by_name() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = dir.path().join("kernel.config");
        std::fs::write(
            &cfg,
            "CONFIG_DEVTMPFS=y\nCONFIG_BLK_DEV_INITRD=y\nCONFIG_BINFMT_ELF=y\n\
             # CONFIG_PROC_FS is not set\n# CONFIG_SYSFS is not set\n",
        )
        .unwrap();
        let err = check_pid1_boot_contract(&cfg, "tunnr-kernel", "the directory").unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("CONFIG_SYSFS=y") && msg.contains("CONFIG_PROC_FS=y"), "{msg}");
        assert!(!msg.contains("CONFIG_DEVTMPFS=y"), "only the MISSING ones are named: {msg}");
        assert!(msg.contains("mounts"), "the symptom the operator saw is in the sentence: {msg}");
    }

    /// GREEN: the contract text passes itself, and `=m` is NOT `=y` — a module
    /// an initramfs cannot load is the same absence the whole crate is built
    /// around (the AHCI/E1000 lesson), so it must not satisfy the gate.
    #[test]
    fn the_contract_passes_and_a_module_does_not_count() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = dir.path().join("kernel.config");
        std::fs::write(&cfg, pid1_boot_contract_config()).unwrap();
        check_pid1_boot_contract(&cfg, "k", "here").unwrap();
        assert!(pid1_boot_contract_missing(&pid1_boot_contract_config()).is_empty());
        let as_module = pid1_boot_contract_config().replace("CONFIG_SYSFS=y", "CONFIG_SYSFS=m");
        assert_eq!(pid1_boot_contract_missing(&as_module), vec!["CONFIG_SYSFS"]);
    }

    /// The tinyconfig closure now DECLARES the two options the shipped kernel
    /// lacked, for every preset that starts from tinyconfig (slim, nuked) — so
    /// the canary asserts them at build time and the gate above never fires on
    /// a kernel this crate's own presets produced.
    #[test]
    fn every_tinyconfig_preset_declares_procfs_and_sysfs() {
        for preset in [Preset::Slim, Preset::Nuked] {
            let merged = merge_kconfig(&preset.fragments());
            for line in ["CONFIG_PROC_FS=y", "CONFIG_SYSFS=y"] {
                assert!(
                    merged.lines().any(|l| l == line),
                    "preset {} merges without `{line}`:\n{merged}",
                    preset.as_str()
                );
            }
            // And the contract as a whole is met by what the presets declare.
            let missing = pid1_boot_contract_missing(&merged);
            assert!(missing.is_empty(), "preset {} leaves {missing:?} undeclared", preset.as_str());
        }
    }
}

/// **Verify a ledger hit the way a local build is verified** — the bzImage on
/// disk matches its own pin, the pin's fragment sha is the CHOSEN merge, and
/// the built `.config` carries the [`PID1_BOOT_CONTRACT`].
/// A mismatch anywhere is the same named refusal the local road gives, with
/// `where_from` naming the source (`the ledger` / the directory).
pub fn verify_built_kernel(
    got: &ResolvedBuiltKernel,
    kernel_name: &str,
    expected_fragment_sha256: &str,
    chosen_preset: &str,
    build_cmd: &str,
    where_from: &str,
) -> Result<()> {
    let sha = sha256_file_hex(&got.bzimage)?;
    if sha != got.pin.bzimage_sha256 {
        return Err(anyhow!(
            "kernel '{kernel_name}': {} ({where_from}) does not match its own pin (sha256 {sha}, pin \
             says {}) — the artifact changed since it was built. Rebuild it: `{build_cmd}`",
            got.bzimage.display(),
            got.pin.bzimage_sha256
        ));
    }
    if expected_fragment_sha256 != got.pin.fragment_sha256 {
        return Err(anyhow!(
            "kernel '{kernel_name}': the built kernel ({where_from}) is not the CHOSEN kernel — the pin \
             was built from preset '{}' (fragment {}), the choice here merges to preset '{}' \
             (fragment {expected_fragment_sha256}). Build the chosen one first: `{build_cmd}`",
            got.pin.preset,
            got.pin.fragment_sha256,
            chosen_preset
        ));
    }
    check_pid1_boot_contract(&got.config, kernel_name, where_from)?;
    Ok(())
}

/// [`verify_built_kernel`] plus the consumers' REQUIREMENTS against the built
/// `.config` — the forward index enforced where a consumer resolves its donor:
/// a required `=y` that `olddefconfig` dropped, or a required `n` that crept
/// back, refuses by symbol · consumer · feature before any medium is composed.
pub fn verify_built_kernel_for(
    got: &ResolvedBuiltKernel,
    kernel_name: &str,
    expected_fragment_sha256: &str,
    chosen_preset: &str,
    build_cmd: &str,
    where_from: &str,
    requires: Option<&crate::kernel_requires::KernelRequirements>,
) -> Result<()> {
    // THE CLOSURE FIRST (lane AK, 2026-08-30). The fragment sha and the
    // closure both refuse the same wrong donor, but they say different things,
    // and only one of them is actionable: a `slim` kernel built standalone
    // against a `data.fs = xfs` recipe refuses as *"the pin was built from
    // preset 'slim' … the choice here merges to preset 'slim'"* — two hashes,
    // the same preset name twice, and no word about `/data`. The closure check
    // names CONFIG_XFS_FS · the consumer · `data.fs` · the measured reason ·
    // the chained build that repairs it. So it is asked BEFORE the pin, and the
    // pin refusal stays for what it alone can see (a veto file, an operator's
    // decision, a different preset).
    if let Some(r) = requires {
        let text = std::fs::read_to_string(&got.config)
            .with_context(|| format!("kernel '{kernel_name}': read the built .config {}", got.config.display()))?;
        r.check_config(&text, kernel_name, where_from, Some(build_cmd))?;
    }
    verify_built_kernel(got, kernel_name, expected_fragment_sha256, chosen_preset, build_cmd, where_from)?;
    Ok(())
}

/// [`resolve_built_kernel`] with a [`KernelLedger`] asked FIRST: the chosen
/// declaration's [`ledger_key_of`] is looked up, a hit is verified exactly as
/// a local build is ([`verify_built_kernel`]) and handed over — no build, no
/// `.kernel-out/` needed; a miss falls back to the donor's local `.kernel-out/`
/// with the four refusals the plain reader has always given.
///
/// The same reader as [`resolve_kernel`], with the
/// [`NeedsBuild`](KernelResolution::NeedsBuild) outcome turned back into the
/// refusal it has always been — a caller with no `--build` refuses by name with
/// the step's own line.
pub fn resolve_built_kernel_with(
    donor_root: &Path,
    donor_label: &str,
    kernel_name: &str,
    decision: Option<&crate::kernel_features::KernelDecision>,
    ledger: &dyn KernelLedger,
) -> Result<ResolvedBuiltKernel> {
    resolve_built_kernel_for(donor_root, donor_label, kernel_name, decision, None, ledger)
}

/// [`resolve_built_kernel_with`] for a consumer that KNOWS what it requires of
/// the kernel — the chosen merge includes the requirements (so the pin the
/// artifact must carry is the one a chained build with these consumers
/// produced) and the built `.config` is checked against them.
pub fn resolve_built_kernel_for(
    donor_root: &Path,
    donor_label: &str,
    kernel_name: &str,
    decision: Option<&crate::kernel_features::KernelDecision>,
    requires: Option<&crate::kernel_requires::KernelRequirements>,
    ledger: &dyn KernelLedger,
) -> Result<ResolvedBuiltKernel> {
    match resolve_kernel_for(donor_root, donor_label, kernel_name, decision, requires, ledger)? {
        KernelResolution::Built(k) => Ok(k),
        KernelResolution::NeedsBuild { why, .. } => Err(anyhow!(why)),
    }
}

/// **What the ONE kernel reader answered** — either the built, pin-verified
/// kernel, or the step that would build it and why it is needed.
///
/// "A consumer never builds the donor's kernel" is retired as a DECISION here,
/// not yet as behaviour: the reader no longer decides; it hands the caller a
/// typed [`BuildStep`] whose `Display` is the exact line
/// (`dwarves buildthing tunnr --variant tunnr-kernel --opt kernel.preset=nuked
/// --opt kernel.unix=off`). A caller holding `--build` runs the step; a caller
/// without it refuses by name with `why` — the same line in both.
#[derive(Debug, Clone)]
pub enum KernelResolution {
    /// The built kernel, verified against its pin and the chosen merge.
    Built(ResolvedBuiltKernel),
    /// No acceptable build exists — not built, or built from another choice, or
    /// the artifact drifted from its own pin. `step` builds exactly the chosen
    /// kernel (its `pin` is what the result must satisfy); `why` is the refusal
    /// a door without `--build` speaks, verbatim.
    NeedsBuild {
        /// The step that repairs it.
        step: crate::build_dag::BuildStep,
        /// The named refusal, `step`'s line included.
        why: String,
    },
}

impl KernelResolution {
    /// The built kernel, or the refusal — what a door without `--build` does.
    pub fn built(self) -> Result<ResolvedBuiltKernel> {
        match self {
            KernelResolution::Built(k) => Ok(k),
            KernelResolution::NeedsBuild { why, .. } => Err(anyhow!(why)),
        }
    }
}

/// **The step that builds exactly the chosen kernel** — the coordinate
/// `donor_label/kernel_name`, typ kernel, the decision as the door's `kernel.*`
/// flags (the HANDLE, not the `CONFIG_` symbol — a repair the door refuses is
/// no repair), and the pin the result must satisfy. `Display` is the repair
/// line the reader has always spelled.
pub fn kernel_build_step(
    donor_label: &str,
    kernel_name: &str,
    decision: Option<&crate::kernel_features::KernelDecision>,
    pin: Option<crate::build_dag::StepPin>,
) -> crate::build_dag::BuildStep {
    kernel_build_step_for(donor_label, kernel_name, decision, None, pin)
}

/// [`kernel_build_step`] carrying the consumers' requirements — the step a
/// runner hands the kernel arm, so the build merges what the consumers asked.
pub fn kernel_build_step_for(
    donor_label: &str,
    kernel_name: &str,
    decision: Option<&crate::kernel_features::KernelDecision>,
    requires: Option<&crate::kernel_requires::KernelRequirements>,
    pin: Option<crate::build_dag::StepPin>,
) -> crate::build_dag::BuildStep {
    use crate::build_dag::{BuildStep, Coordinate};
    use crate::flags::Flag;
    let mut step = BuildStep::new(Coordinate::new(donor_label, kernel_name), crate::variant::ArtifactKind::Kernel);
    if let Some(d) = decision {
        step.opts.push(Flag::choice("kernel.preset", d.preset.as_str()));
        for row in &d.clicked_off {
            step.opts.push(Flag::opt(format!("kernel.{}", crate::kernel_features::option_name(row)), false));
        }

The file goes on; the console shows all of it.