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

//! **The kernel CLOSURE — what a feature needs of the kernel, declared next to
//! the feature, and read back symbol-by-symbol.** (2026-08-29, the owner's
//! question: *"how do we know what the kernel needs? is it recursive? do we need
//! an inverted index? BIJECTIVITY."*)
//!
//! Before this module the answer was: **we did not know.** `--opt dhcp=on` and
//! `--opt wireguard=off` reached the APP build (cargo features, `korp.env.*` on
//! the cmdline) and never the kernel step; the kernel step took tunnr's
//! `preset = "nuked"` — three hand-tuned fragment files whose every line is a
//! MEASURED refusal — and nothing tied a line in `base.config` to the feature
//! that needs it. A `nuked` kernel built for `wireguard=off` still carries
//! `CONFIG_TUN=y`, because the fragment says so and no feature ever said why.
//!
//! # The two indexes
//!
//! * **forward** — a feature (an [`OptionSpec`](crate::options::OptionSpec) row,
//!   the PID-1 boot contract, a `target.*` fact) declares
//!   [`KconfigReq`]s: `symbol = value, because`. The planner walks
//!   variant → features → sub-build-things ([`crate::build_dag::BuildDag`]) and
//!   UNIONs every enabled feature's requirements of every consumer into the one
//!   [`KernelRequirements`] the kernel step receives; the merge appends them
//!   after the preset fragments and REFUSES by name when a preset line or another
//!   requirer contradicts ([`KernelRequirements::contradictions`]).
//! * **inverted** — [`WhyIndex`]: every symbol the merged fragment sets → the
//!   `(consumer, feature, reason)` rows that required it, or `preset-only` when a
//!   fragment set it and no feature asked. Emitted as `kernel.why.toml` beside
//!   the built `.config`. A `preset-only` symbol is a candidate to nuke; a
//!   required symbol missing from the built `.config` is a refusal
//!   ([`KernelRequirements::missing_in_config`]).
//!
//! # Bijectivity — what holds and what cannot
//!
//! Over the MERGED FRAGMENT the picture is exact: `feature ∪ preset-only` IS the
//! set of symbols the fragment names ([`WhyIndex::fragment_is_partitioned`]).
//! Over the BUILT `.config` it is not, and cannot be: `olddefconfig` derives
//! thousands of symbols from the base (`tinyconfig`/`defconfig`) and from
//! Kconfig `select`/`depends` (`CONFIG_VT` selects `INPUT`, measured 2026-08-28).
//! Those are the third class, `kconfig-derived` — named by nobody, pulled in by
//! the kernel's own tree. The law that DOES hold over the `.config`: every
//! `feature`-required `=y/=m` is present (else refusal), every `=n` is absent.
//! The rest is the size a solver (`minimal`, klocalizer) works on, not a
//! declaration gap.

use std::collections::BTreeMap;
use std::fmt;

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

/// The file the inverted index is written to, beside `kernel.config`.
pub const WHY_FILE: &str = "kernel.why.toml";

/// The pseudo-feature every consumer that boots a nordisk PID-1 carries — the
/// [`appliance_boot_requirements`] are declared under this name.
pub const APPLIANCE_BOOT_FEATURE: &str = "appliance-boot";
/// The one contract row a consumer may LIFT by requiring it `=y` itself
/// (`requires::ApplianceBootContract`, lane HOLGER-MEDIA): `CONFIG_MULTIUSER`,
/// which user namespaces and cgroups depend on.
pub const SINGLE_USER_SYMBOL: &str = "CONFIG_MULTIUSER";

/// The appliance-boot contract's `=n` rows a consumer's own ROW may lift by
/// requiring the symbol `=y` (never a feature): the one-user default, IPv4
/// only for a Kubernetes node (owner 2026-10-29: IPv6 compiled in, booted
/// with `ipv6.disable=1`), and the framebuffer console for the same node
/// (owner 2026-10-30, option A: its console dashboard must show under UEFI,
/// where there is no VGA text mode — efifb on the firmware's GOP framebuffer
/// and fbcon on top; the contract's row is `CONFIG_FB=n`, which `FB_EFI` and
/// `FRAMEBUFFER_CONSOLE` depend on, and the row requires all three).
/// `CONFIG_DRM` and `CONFIG_LOGO` are vetoed by the same contract and are
/// deliberately NOT here: no row lifts them.
pub const ROW_LIFTABLE_SYMBOLS: [&str; 5] = [SINGLE_USER_SYMBOL, "CONFIG_IPV6", "CONFIG_FB", "CONFIG_FB_EFI", "CONFIG_FRAMEBUFFER_CONSOLE"];

/// **The phrase a caller may classify this refusal by**, shared so that the
/// message and the classifier cannot drift apart.
///
/// dwarves' `build_steps::kernel_step_need` sorts a resolver refusal into "the
/// plan should build the donor" versus "this is an error the step surfaces",
/// and it does so **by substring**. Its own doc states the intent: *"a kernel
/// built before a consumer's closure grew is … a rebuild, never a stale
/// `.kernel-out` under 'building nothing'"*.
///
/// That intent broke silently on 2026-08-30. Lane AK moved this closure check
/// to run BEFORE the pin check — rightly, because it is the actionable one —
/// and in doing so changed the refusal a stale-closure kernel produces from
/// *"is not the CHOSEN kernel"* (which the classifier recognised, and rebuilt)
/// to the wording below (which it did not, and so aborted the whole plan).
/// MEASURED 2026-09-04: `dwarves buildthing gunnar --variant
/// gunnar-appliance-iso --list --build kernel` refused instead of planning, on
/// **one** wrong symbol out of 51 — and the refusal's own text named the
/// chained build that would have repaired it.
///
/// So the phrase is a constant on this side and the classifier matches against
/// THIS, not against a copy of it. Re-word the sentence freely; the coupling
/// holds because there is only one of it.
pub const CLOSURE_REFUSAL: &str = "does NOT satisfy its consumers' requirements";

/// What a Kconfig symbol must be.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum KconfigValue {
    /// Built in.
    Y,
    /// A module (an appliance initramfs cannot load one — declared here only so
    /// a distro-shaped kernel can be described honestly).
    M,
    /// Absent: `# CONFIG_X is not set`, or the symbol not in the `.config` at all.
    N,
    /// A DECIMAL int option, unquoted in the `.config` (`CONFIG_NR_CPUS=64`).
    ///
    /// Lane AE, 2026-08-30, answering "is `NR_CPUS` a value the surface can
    /// carry?": it is now. Before this variant every non-`y/m/n` value was a
    /// [`Str`](Self::Str) and [`directive`](Self::directive) QUOTED it, so a
    /// requirement `CONFIG_NR_CPUS=64` would have merged the line
    /// `CONFIG_NR_CPUS="64"` — which kconfig reads as a string option that does
    /// not exist, drops on `olddefconfig`, and the canary then refuses. An int
    /// option was therefore not expressible at all and `NR_CPUS` had to stay a
    /// fragment line nobody owned. It is a `feature` row now.
    ///
    /// The remaining gap, named rather than hidden: a HEX int option
    /// (`CONFIG_PHYSICAL_START=0x1000000`) still parses to `Str` and would be
    /// quoted the same way. Nothing declares one today; the day something does,
    /// it grows a variant here beside this one, not a special case elsewhere.
    Int(i64),
    /// A string option, quoted in the `.config` (`CONFIG_FAT_DEFAULT_IOCHARSET="cp437"`).
    Str(String),
}

impl KconfigValue {
    /// The spelling on the wire and in a toml row: `y` · `m` · `n` · anything else
    /// is a string value.
    pub fn parse(s: &str) -> Self {
        match s.trim() {
            "y" => KconfigValue::Y,
            "m" => KconfigValue::M,
            "n" => KconfigValue::N,
            other => KconfigValue::of_value(other),
        }
    }
    /// A `.config` VALUE half (everything right of the `=`) as this type: a
    /// quoted token is a string, a bare decimal one is an int, anything else is
    /// a string. The one place the two are told apart, so a declared row
    /// (`value = "64"`) and a built line (`CONFIG_NR_CPUS=64`) agree.
    fn of_value(v: &str) -> Self {
        let t = v.trim();
        if t.starts_with('"') {
            return KconfigValue::Str(t.trim_matches('"').to_string());
        }
        match t.parse::<i64>() {
            Ok(n) => KconfigValue::Int(n),
            Err(_) => KconfigValue::Str(t.to_string()),
        }
    }
    /// The wire spelling.
    pub fn as_str(&self) -> String {
        match self {
            KconfigValue::Y => "y".into(),
            KconfigValue::M => "m".into(),
            KconfigValue::N => "n".into(),
            KconfigValue::Int(n) => n.to_string(),
            KconfigValue::Str(s) => s.clone(),
        }
    }
    /// The kconfig DIRECTIVE line for `symbol` — the exact spelling
    /// [`crate::kernel::merge_kconfig`] keys on, so a requirement that repeats a
    /// fragment line merges to the same bytes (and the same pin).
    pub fn directive(&self, symbol: &str) -> String {
        match self {
            KconfigValue::Y => format!("{symbol}=y"),
            KconfigValue::M => format!("{symbol}=m"),
            KconfigValue::N => format!("# {symbol} is not set"),
            KconfigValue::Int(n) => format!("{symbol}={n}"),
            KconfigValue::Str(s) => format!("{symbol}=\"{s}\""),
        }
    }
    /// The value a merged fragment / built `.config` DIRECTIVE line states.
    pub fn of_directive(line: &str) -> Option<(&str, KconfigValue)> {
        let sym = crate::kernel::kconfig_symbol(line)?;
        let t = line.trim();
        if t.starts_with("# ") {
            return Some((sym, KconfigValue::N));
        }
        let v = &t[sym.len() + 1..];
        Some((
            sym,
            match v {
                "y" => KconfigValue::Y,
                "m" => KconfigValue::M,
                "n" => KconfigValue::N,
                other => KconfigValue::of_value(other),
            },
        ))
    }
}

impl Serialize for KconfigValue {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&self.as_str())
    }
}
impl<'de> Deserialize<'de> for KconfigValue {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        Ok(KconfigValue::parse(&s))
    }
}

impl fmt::Display for KconfigValue {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.as_str())
    }
}

/// **One requirement a feature places on the kernel** — typed, next to the
/// feature that needs it (`OptionSpec.kernel_requires`, or a contract table in
/// this crate). `reason` is the measured why, one sentence.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KconfigReq {
    /// `CONFIG_X`.
    pub symbol: String,
    /// What it must be.
    pub value: KconfigValue,
    /// Why — the sentence the inverted index prints.
    #[serde(default)]
    pub reason: String,
}

impl KconfigReq {
    /// `CONFIG_X=y` with its why.
    pub fn y(symbol: &str, reason: &str) -> Self {
        KconfigReq { symbol: symbol.into(), value: KconfigValue::Y, reason: reason.into() }
    }
    /// `# CONFIG_X is not set` with its why — the negative law.
    pub fn n(symbol: &str, reason: &str) -> Self {
        KconfigReq { symbol: symbol.into(), value: KconfigValue::N, reason: reason.into() }
    }
    /// `CONFIG_X=<n>` with its why — a DECIMAL int option ([`KconfigValue::Int`]).
    pub fn int(symbol: &str, value: i64, reason: &str) -> Self {
        KconfigReq { symbol: symbol.into(), value: KconfigValue::Int(value), reason: reason.into() }
    }
    /// `CONFIG_X="<s>"` with its why — a string option ([`KconfigValue::Str`]).
    pub fn text(symbol: &str, value: &str, reason: &str) -> Self {
        KconfigReq { symbol: symbol.into(), value: KconfigValue::Str(value.into()), reason: reason.into() }
    }
    /// A declaration-time check: a symbol that is not `CONFIG_*` is a typo the
    /// canary would never see (it would merge a line the kernel ignores).
    pub fn validate(&self, who: &str) -> Result<()> {
        if !self.symbol.starts_with("CONFIG_") || self.symbol.contains(char::is_whitespace) || self.symbol.len() == 7 {
            bail!("{who}: kernel_requires symbol {:?} is not a CONFIG_* symbol", self.symbol);
        }
        Ok(())
    }
}

/// **Who required a symbol** — the row of the inverted index.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Requirer {
    /// `repo/variant` of the consumer whose feature this is.
    pub consumer: String,
    /// The feature name on that consumer (`dhcp`, `wireguard`, `appliance-boot`, `target.consoles`).
    pub feature: String,
    /// The declared why.
    pub reason: String,
}

/// **The union of every requirement the plan collected** — symbol → the
/// `(value, requirer)` rows. The forward index, as data.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct KernelRequirements {
    /// Symbol → every requirement placed on it (several requirers may agree).
    pub rows: BTreeMap<String, Vec<(KconfigValue, Requirer)>>,
}

/// A refusal: one symbol, two answers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Contradiction {
    /// `CONFIG_X`.
    pub symbol: String,
    /// What the requirer wants and who.
    pub wanted: (KconfigValue, Requirer),
    /// What stands against it: `(value, by)` — `by` is another requirer's
    /// `consumer:feature`, or the fragment layer (`preset fragment`, `the operator's decision`).
    pub against: (KconfigValue, String),
}

impl fmt::Display for Contradiction {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} must be `{}` for {}:{} ({}) but {} says `{}`",
            self.symbol, self.wanted.0, self.wanted.1.consumer, self.wanted.1.feature, self.wanted.1.reason, self.against.1, self.against.0
        )
    }
}

impl KernelRequirements {
    /// Nothing required.
    pub fn is_empty(&self) -> bool {
        self.rows.is_empty()
    }

    /// Place one requirement.
    pub fn require(&mut self, consumer: &str, feature: &str, req: &KconfigReq) {
        let r = Requirer { consumer: consumer.into(), feature: feature.into(), reason: req.reason.clone() };
        let rows = self.rows.entry(req.symbol.clone()).or_default();
        if !rows.iter().any(|(v, q)| *v == req.value && *q == r) {
            rows.push((req.value.clone(), r));
        }
    }

    /// Place every requirement of one feature.
    pub fn require_all(&mut self, consumer: &str, feature: &str, reqs: &[KconfigReq]) {
        for r in reqs {
            self.require(consumer, feature, r);
        }
    }

    /// Union another consumer's requirements in (the recursive walk's join).
    pub fn union(&mut self, other: &KernelRequirements) {
        for (sym, rows) in &other.rows {
            let mine = self.rows.entry(sym.clone()).or_default();
            for row in rows {
                if !mine.contains(row) {
                    mine.push(row.clone());
                }
            }
        }
    }

    /// Distinct `(consumer, feature)` pairs that required anything.
    pub fn features(&self) -> Vec<(String, String)> {
        let mut out: Vec<(String, String)> = Vec::new();
        for rows in self.rows.values() {
            for (_, r) in rows {
                let k = (r.consumer.clone(), r.feature.clone());
                if !out.contains(&k) {
                    out.push(k);
                }
            }
        }
        out.sort();
        out
    }

    /// **Requirer against requirer**: one symbol, two values.
    pub fn conflicts(&self) -> Vec<Contradiction> {
        let mut out = Vec::new();
        for (sym, rows) in &self.rows {
            let Some(first) = rows.first() else { continue };
            for other in rows.iter().skip(1) {
                if other.0 != first.0 {
                    out.push(Contradiction {
                        symbol: sym.clone(),
                        wanted: other.clone(),
                        against: (first.0.clone(), format!("{}:{}", first.1.consumer, first.1.feature)),
                    });
                }
            }
        }
        out
    }

    /// **Requirer against a fragment layer**: every symbol the merged text
    /// `fragment` sets to a value a requirer does not want. `layer` names the
    /// text for the refusal (`preset fragment`, `the operator's decision`).
    pub fn contradictions(&self, fragment: &str, layer: &str) -> Vec<Contradiction> {
        let set: BTreeMap<&str, KconfigValue> = fragment.lines().filter_map(KconfigValue::of_directive).collect();
        let mut out = Vec::new();
        for (sym, rows) in &self.rows {
            if let Some(has) = set.get(sym.as_str()) {
                for row in rows {
                    if row.0 != *has {
                        out.push(Contradiction { symbol: sym.clone(), wanted: row.clone(), against: (has.clone(), layer.to_string()) });
                    }
                }
            }
        }
        out
    }

    /// The kconfig text of the requirements — one directive per symbol, sorted,
    /// in the spelling the merge keys on. Only legal after [`conflicts`](Self::conflicts)
    /// is empty (the first requirer's value is written).
    pub fn fragment(&self) -> String {
        let mut s = String::new();
        for (sym, rows) in &self.rows {
            if let Some((v, _)) = rows.first() {
                s.push_str(&v.directive(sym));
                s.push('\n');
            }
        }
        s
    }

    /// **Required but not in the built `.config`** — the forward index enforced
    /// on what survived `olddefconfig`. `=y/=m/str` must be present verbatim;
    /// `n` must be `is not set` or absent.
    pub fn missing_in_config(&self, config_text: &str) -> Vec<(String, KconfigValue, Requirer, String)> {
        let set: BTreeMap<&str, KconfigValue> = config_text.lines().filter_map(KconfigValue::of_directive).collect();
        let mut out = Vec::new();
        for (sym, rows) in &self.rows {
            let has = set.get(sym.as_str());
            for (want, who) in rows {
                let ok = match (want, has) {
                    (KconfigValue::N, None) | (KconfigValue::N, Some(KconfigValue::N)) => true,
                    (w, Some(h)) => w == h,
                    (_, None) => false,
                };
                if !ok {
                    out.push((sym.clone(), want.clone(), who.clone(), has.map(|h| h.as_str()).unwrap_or_else(|| "absent".into())));
                }
            }
        }
        out
    }

    /// **The chained build that makes this closure reach the donor** — one
    /// line per distinct consumer, `dwarves buildthing <repo> --variant
    /// <variant> --build kernel`.
    ///
    /// It is the repair a closure failure actually has, and it is NOT the
    /// donor's own line: a standalone `dwarves buildthing tunnr --variant
    /// tunnr-kernel --opt kernel.preset=slim` produces a kernel with **no
    /// requirer closure at all** — which is precisely how lane AH's `slim` and
    /// `normal` media installed (B1 PASS) and then died on
    /// `mount /dev/vda3 as xfs: ENODEV`. The requirements are the CONSUMER's,
    /// so the consumer's door is where the build must start.
    pub fn chained_build_cmds(&self) -> Vec<String> {
        let mut out: Vec<String> = Vec::new();
        for (consumer, _) in self.features() {
            let Some((repo, variant)) = consumer.split_once('/') else { continue };
            let line = format!("{} {repo} --variant {variant} --build {}", crate::build_dag::BUILD_VERB, crate::tree::KERNEL_PATH);
            if !out.contains(&line) {
                out.push(line);
            }
        }
        out
    }

    /// The refusal text for [`missing_in_config`](Self::missing_in_config), or `Ok`.
    ///
    /// `build_cmd` is the DONOR's own line when the caller holds one; it is
    /// printed as what will NOT repair this, because the repair is the chained
    /// build ([`chained_build_cmds`](Self::chained_build_cmds)). One row per
    /// wrong symbol, naming symbol · consumer · feature · reason · what the
    /// built `.config` actually has — the shape the installer compose path's
    /// "not the CHOSEN kernel" refusal set.
    pub fn check_config(&self, config_text: &str, kernel_name: &str, where_from: &str, build_cmd: Option<&str>) -> Result<()> {
        let missing = self.missing_in_config(config_text);
        if missing.is_empty() {
            return Ok(());
        }
        let mut msg = format!(
            "kernel '{kernel_name}' ({where_from}) {CLOSURE_REFUSAL} — {} of {} required symbol(s) are wrong in the BUILT .config, so a medium composed on this kernel would install and then fail at what the recipe declares:",
            missing.len(),
            self.rows.len()
        );
        for (s, v, r, has) in &missing {
            msg.push_str(&format!(
                "\n  {s} must be `{v}` for {}:{} ({}) — the built .config has {has}",
                r.consumer, r.feature, r.reason
            ));
        }
        let chained = self.chained_build_cmds();
        if !chained.is_empty() {
            msg.push_str(
                "\nThese requirements reach the kernel ONLY through the consumer's CHAINED build — a standalone donor build carries no requirer closure at all. Build it that way: ",
            );
            msg.push_str(&chained.iter().map(|c| format!("`{c}`")).collect::<Vec<_>>().join(" and "));
        }
        if let Some(cmd) = build_cmd {
            msg.push_str(&format!("\n(the donor's own line is `{cmd}` — on its own it will NOT carry these requirements)"));
        }
        Err(anyhow!(msg))
    }

    /// The step-log sentence: `12 symbols required by 3 features (gunnar/gunnar-installer-open: appliance-boot · dhcp · wireguard)`.
    pub fn summary(&self) -> String {
        let feats = self.features();
        let mut by: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
        for (c, f) in &feats {
            by.entry(c.as_str()).or_default().push(f.as_str());
        }
        format!(
            "{} symbol(s) required by {} feature(s) ({})",
            self.rows.len(),
            feats.len(),
            by.iter().map(|(c, fs)| format!("{c}: {}", fs.join(" · "))).collect::<Vec<_>>().join("; ")
        )
    }
}

// ── the inverted index ───────────────────────────────────────────────────────

/// Which class a symbol of the picture belongs to.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "class")]
pub enum WhyClass {
    /// One or more features required it — the rows say who.
    Feature {
        /// Everyone who asked.
        requirers: Vec<Requirer>,
    },
    /// A preset/profile/veto fragment set it and no feature asked: a candidate to nuke.
    PresetOnly,
    /// In the built `.config` and named by nobody: the base + Kconfig
    /// `select`/`depends` put it there.
    ///
    /// **Lane AP, 2026-08-30 — this class stops being a shrug.** The Kconfig
    /// solver ([`crate::kconfig_graph`]) reads the donor's own `Kconfig` tree
    /// and walks the `select`/`imply`/`default` edges back to whatever is
    /// ultimately responsible; the answer rides in these fields. They are
    /// OPTIONAL because the solver needs the extracted kernel source and the
    /// base `.config`, and a `WhyIndex` built from the fragment alone (the
    /// planner's, `BuildDag::derive`) has neither — an empty `chain` means
    /// "not solved here", never "nothing pulled it in".
    KconfigDerived {
        /// The symbol or fact ultimately responsible, `CONFIG_`-prefixed for a
        /// symbol. Empty when the solver did not run or could not say.
        #[serde(default, skip_serializing_if = "String::is_empty")]
        head: String,
        /// `declared` · `base-config` · `kconfig-default` · `arch`.
        #[serde(default, skip_serializing_if = "String::is_empty")]
        head_class: String,
        /// The rendered chain, head first.
        #[serde(default, skip_serializing_if = "String::is_empty")]
        chain: String,
        /// EVERY head that can reach this symbol (the shortest one is `head`).
        /// A symbol with exactly one `declared:` head is the strong case: remove
        /// that declaration and the symbol must go.
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        heads: Vec<String>,
        /// Why there is no chain, when there is none — stated, never hidden.
        #[serde(default, skip_serializing_if = "String::is_empty")]
        unexplained: String,
    },
}

impl WhyClass {
    /// A `kconfig-derived` row that the solver has NOT looked at (no kernel
    /// source, or a fragment-only index). Never confuse it with a solved row
    /// that came back empty: that one carries `unexplained`.
    pub fn derived_unsolved() -> Self {
        WhyClass::KconfigDerived {
            head: String::new(),
            head_class: String::new(),
            chain: String::new(),
            heads: Vec::new(),
            unexplained: String::new(),
        }
    }
}

/// One row of `kernel.why.toml`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WhyRow {
    /// `CONFIG_X`.
    pub symbol: String,
    /// Its value in the merged fragment (or the built `.config` for derived rows).
    pub value: KconfigValue,
    /// Who / why.
    #[serde(flatten)]
    pub class: WhyClass,
}

/// **symbol → why** over the merged fragment, optionally joined with the built `.config`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WhyIndex {
    /// The rows, by symbol.
    pub rows: Vec<WhyRow>,
    /// Required symbols the built `.config` does NOT honour (empty = green).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub missing: Vec<String>,
}

impl WhyIndex {
    /// The picture over the MERGED FRAGMENT: every symbol it names is `feature`
    /// or `preset-only` — nothing else exists at this layer.
    pub fn of(merged_fragment: &str, reqs: &KernelRequirements) -> Self {
        let mut rows = Vec::new();
        for line in merged_fragment.lines() {
            let Some((sym, value)) = KconfigValue::of_directive(line) else { continue };
            let class = match reqs.rows.get(sym) {
                Some(r) if !r.is_empty() => WhyClass::Feature { requirers: r.iter().map(|(_, q)| q.clone()).collect() },
                _ => WhyClass::PresetOnly,
            };
            rows.push(WhyRow { symbol: sym.to_string(), value, class });
        }
        // A requirement the fragment does not carry at all (a symbol no preset
        // mentions) is still a feature row — the merge appended it, but a
        // caller may hand us a pre-requirements fragment; state it either way.
        for (sym, r) in &reqs.rows {
            if !rows.iter().any(|w| w.symbol == *sym) {
                if let Some((v, _)) = r.first() {
                    rows.push(WhyRow {
                        symbol: sym.clone(),
                        value: v.clone(),
                        class: WhyClass::Feature { requirers: r.iter().map(|(_, q)| q.clone()).collect() },
                    });
                }
            }
        }
        rows.sort_by(|a, b| a.symbol.cmp(&b.symbol));
        WhyIndex { rows, missing: Vec::new() }
    }

    /// Join the BUILT `.config`: every `=y/=m/str` it carries that the fragment
    /// never named becomes `kconfig-derived`; every requirement it does not
    /// honour is listed under `missing`.
    pub fn with_built_config(mut self, config_text: &str, reqs: &KernelRequirements) -> Self {
        for line in config_text.lines() {
            let Some((sym, value)) = KconfigValue::of_directive(line) else { continue };
            if value == KconfigValue::N {
                continue;
            }
            if !self.rows.iter().any(|w| w.symbol == sym) {
                self.rows.push(WhyRow {
                    symbol: sym.to_string(),
                    value,
                    class: WhyClass::derived_unsolved(),
                });
            }
        }
        self.rows.sort_by(|a, b| a.symbol.cmp(&b.symbol));
        self.missing = reqs.missing_in_config(config_text).into_iter().map(|(s, v, r, has)| format!("{s}={v} ← {}:{} (config has {has})", r.consumer, r.feature)).collect();
        self
    }

    /// **Run the Kconfig solver over every `kconfig-derived` row.**
    ///
    /// `src` is the EXTRACTED kernel source (the `linux-<version>` directory in
    /// the kernel workdir — the tree `olddefconfig` itself read), `base` the
    /// starting `.config` before the fragment was merged (`tinyconfig` /
    /// `defconfig`), `arch` the composer's declared arch.
    ///
    /// This is the only place `kconfig-derived` gains an answer. It is a NO-OP
    /// on a row that is not derived, and a row it cannot explain says so in its
    /// own `unexplained` field rather than going quiet.
    pub fn with_kconfig_chains(
        mut self,
        src: &std::path::Path,
        merged_fragment: &str,
        built_config: &str,
        base_config: &str,
        arch: &str,
        kernel_version: &str,
    ) -> Result<Self> {
        use crate::kconfig_graph::{Cause, KconfigGraph, Solver, kconfig_env};
        let graph = KconfigGraph::read(src, kconfig_env(arch, kernel_version))?;
        let solver = Solver::new(&graph, merged_fragment, built_config, base_config);
        for row in &mut self.rows {
            let WhyClass::KconfigDerived { head, head_class, chain, heads, unexplained } =
                &mut row.class
            else {
                continue;
            };
            let bare = row.symbol.strip_prefix("CONFIG_").unwrap_or(&row.symbol);
            match solver.explain(bare) {
                Cause::Explained { chain: c, heads: hs } => {
                    *head = format!("CONFIG_{}", c.head.name());
                    *head_class = c.head.class().to_string();
                    *chain = c.render();
                    *heads =
                        hs.iter().map(|h| format!("{}:CONFIG_{}", h.class(), h.name())).collect();
                }
                Cause::Unexplained(u) => *unexplained = u.stated(),
            }
        }
        Ok(self)
    }

    /// The derived rows with NO chain — by name, with the reason. An honest
    /// "these 43 have no explanation and here they are" is the deliverable, not
    /// an embarrassment.
    pub fn unexplained_derived(&self) -> Vec<(&str, &str)> {
        self.rows
            .iter()
            .filter_map(|r| match &r.class {
                WhyClass::KconfigDerived { unexplained, .. } if !unexplained.is_empty() => {
                    Some((r.symbol.as_str(), unexplained.as_str()))
                }
                _ => None,
            })
            .collect()
    }

    /// The derived rows whose ONLY head is one declaration — the set the
    /// INVERSE test can make a hard claim about: withdraw that declaration and
    /// every one of these must leave the composed `.config`.
    pub fn sole_declared_head(&self, declaration: &str) -> Vec<&str> {
        self.rows
            .iter()
            .filter_map(|r| match &r.class {
                WhyClass::KconfigDerived { heads, .. }
                    if heads.len() == 1 && heads[0] == format!("declared:{declaration}") =>
                {
                    Some(r.symbol.as_str())
                }
                _ => None,
            })
            .collect()
    }

    /// Counts per class: `(feature, preset_only, kconfig_derived)`.
    pub fn counts(&self) -> (usize, usize, usize) {
        let mut c = (0, 0, 0);
        for r in &self.rows {
            match r.class {
                WhyClass::Feature { .. } => c.0 += 1,
                WhyClass::PresetOnly => c.1 += 1,
                WhyClass::KconfigDerived { .. } => c.2 += 1,
            }
        }
        c
    }

    /// The `preset-only` symbols — set by a fragment, wanted by no feature: the
    /// nuke candidates, by name.
    pub fn preset_only(&self) -> Vec<&str> {
        self.rows.iter().filter(|r| r.class == WhyClass::PresetOnly).map(|r| r.symbol.as_str()).collect()
    }

    /// **The bijection over the fragment layer**: `feature ∪ preset-only` is
    /// exactly the fragment's symbol set, and no symbol is in both. `Err` names
    /// the symbol that breaks it.
    pub fn fragment_is_partitioned(&self, merged_fragment: &str) -> Result<()> {
        let frag: std::collections::BTreeSet<&str> = merged_fragment.lines().filter_map(crate::kernel::kconfig_symbol).collect();
        let mut seen = std::collections::BTreeSet::new();
        for r in &self.rows {
            if matches!(r.class, WhyClass::KconfigDerived { .. }) {
                continue;
            }
            if !seen.insert(r.symbol.as_str()) {
                bail!("{} appears twice in the why-index", r.symbol);
            }
            if !frag.contains(r.symbol.as_str()) {
                bail!("{} is in the why-index but not in the merged fragment", r.symbol);
            }
        }
        for s in frag {
            if !seen.contains(s) {
                bail!("{s} is in the merged fragment but the why-index has no row for it");
            }
        }
        Ok(())
    }

    /// `kernel.why.toml` — the file a human reads beside the `.config`.
    pub fn to_toml(&self) -> String {
        let (f, p, d) = self.counts();
        let mut s = String::new();
        s.push_str("# kernel.why.toml — symbol → who required it. Emitted by nornir-build-thing::kernel_requires.\n");
        s.push_str("# class = \"feature\": a consumer's feature asked (requirers say who and why)\n");
        s.push_str("# class = \"preset-only\": a fragment set it, no feature asked → a candidate to nuke\n");
        s.push_str("# class = \"kconfig-derived\": in the built .config, named by nobody (base + select/depends)\n");
        s.push_str(&format!("[summary]\nfeature = {f}\npreset_only = {p}\nkconfig_derived = {d}\n"));
        if !self.missing.is_empty() {
            s.push_str("missing = [\n");
            for m in &self.missing {
                s.push_str(&format!("  {},\n", toml_str(m)));
            }
            s.push_str("]\n");
        }
        s.push('\n');
        for r in &self.rows {
            s.push_str("[[symbol]]\n");
            s.push_str(&format!("name = {}\nvalue = {}\n", toml_str(&r.symbol), toml_str(&r.value.as_str())));
            match &r.class {
                WhyClass::Feature { requirers } => {
                    s.push_str("class = \"feature\"\n");
                    s.push_str(&format!("requirers = {}\n", toml_requirers(requirers)));
                }
                WhyClass::PresetOnly => s.push_str("class = \"preset-only\"\n"),
                WhyClass::KconfigDerived { head, head_class, chain, heads, unexplained } => {
                    s.push_str("class = \"kconfig-derived\"\n");
                    if !head.is_empty() {
                        s.push_str(&format!("head = {}\nhead_class = {}\nchain = {}\n", toml_str(head), toml_str(head_class), toml_str(chain)));
                    }
                    if heads.len() > 1 {
                        s.push_str(&format!(
                            "heads = [{}]\n",
                            heads.iter().map(|h| toml_str(h)).collect::<Vec<_>>().join(", ")
                        ));
                    }
                    if !unexplained.is_empty() {
                        s.push_str(&format!("unexplained = {}\n", toml_str(unexplained)));
                    }
                }
            }
            s.push('\n');
        }
        s
    }

    /// **`kernel.why.toml`, read back** — the inverse of [`Self::to_toml`] and the
    /// only reader of it, so the file a build leaves beside its `.config` is DATA
    /// to a consumer and not only prose to a human.
    ///
    /// Why a reader of its own and not `toml::from_str`: it refuses anything the
    /// writer never writes BY LINE NUMBER rather than guessing, and it still
    /// reads the files written before 2026-10-29. Those carried one `requirers =
    /// [..]` line PER requirer — a duplicate key, so a symbol two consumers asked
    /// for made the file invalid TOML — and Rust-escaped (`{:?}`) strings. The
    /// writer now emits ONE `requirers` array and TOML basic strings, so its
    /// output is real TOML (`toml::from_str` reads it; held by a test); this
    /// reader takes both shapes.
    ///
    /// The writer omits `heads` when there is only one (it IS the `head`), so a
    /// solved row with a head and no `heads` line reads back with that one head —
    /// which is what makes [`Self::sole_declared_head`] answer the same off a file
    /// as off the index that wrote it. A row written before the solver existed
    /// (no `head`, no `unexplained`) reads as UNSOLVED, never as "nothing pulled
    /// it in" ([`WhyClass::derived_unsolved`]).
    pub fn from_toml(text: &str) -> Result<Self> {
        #[derive(Default)]
        struct Raw {
            name: Option<String>,
            value: Option<String>,
            class: Option<String>,
            requirers: Vec<Requirer>,
            head: String,
            head_class: String,
            chain: String,
            heads: Vec<String>,
            unexplained: String,
        }
        fn finish(r: Raw, line: usize) -> Result<WhyRow> {
            let symbol = r.name.ok_or_else(|| anyhow!("kernel.why.toml: the [[symbol]] ending at line {line} has no `name`"))?;
            let value = r.value.ok_or_else(|| anyhow!("kernel.why.toml: {symbol} has no `value`"))?;
            let class = match r.class.as_deref() {
                Some("feature") => WhyClass::Feature { requirers: r.requirers },
                Some("preset-only") => WhyClass::PresetOnly,
                Some("kconfig-derived") => {
                    let heads = if r.heads.is_empty() && !r.head.is_empty() {
                        vec![format!("{}:{}", r.head_class, r.head)]
                    } else {
                        r.heads
                    };
                    WhyClass::KconfigDerived {
                        head: r.head,
                        head_class: r.head_class,
                        chain: r.chain,
                        heads,
                        unexplained: r.unexplained,
                    }
                }
                Some(other) => bail!("kernel.why.toml: {symbol} has class {other:?}, which no writer emits"),
                None => bail!("kernel.why.toml: {symbol} has no `class`"),
            };
            Ok(WhyRow { symbol, value: KconfigValue::parse(&value), class })
        }

        let mut index = WhyIndex::default();
        let mut row: Option<Raw> = None;
        let mut in_missing = false;
        let mut n = 0;
        for (i, raw_line) in text.lines().enumerate() {
            n = i + 1;
            let line = raw_line.trim();
            if in_missing {
                if line == "]" {
                    in_missing = false;
                } else if !line.is_empty() {
                    let (m, rest) = why_toml::string(line).map_err(|e| anyhow!("kernel.why.toml line {n}: {e}"))?;
                    if !matches!(rest.trim(), "," | "") {
                        bail!("kernel.why.toml line {n}: `missing` row has trailing {rest:?}");
                    }
                    index.missing.push(m);
                }
                continue;
            }
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            if line == "[summary]" {
                continue;
            }
            if line == "[[symbol]]" {
                if let Some(r) = row.take() {
                    index.rows.push(finish(r, n)?);
                }
                row = Some(Raw::default());
                continue;
            }
            let Some((key, val)) = line.split_once(" = ") else {
                bail!("kernel.why.toml line {n}: not `key = value`: {line:?}");
            };
            let Some(r) = row.as_mut() else {
                // The summary: counts are DERIVED from the rows, so they are not
                // read back; `missing` is the one list that lives up here.
                match key {
                    "feature" | "preset_only" | "kconfig_derived" => {}
                    "missing" if val == "[" => in_missing = true,
                    _ => bail!("kernel.why.toml line {n}: {key:?} is not a summary key"),
                }
                continue;
            };
            let at = |e: String| anyhow!("kernel.why.toml line {n} ({key}): {e}");
            let one = |v: &str| -> Result<String> {
                let (s, rest) = why_toml::string(v).map_err(at)?;
                if !rest.trim().is_empty() {
                    return Err(at(format!("trailing {rest:?}")));
                }
                Ok(s)
            };
            match key {
                "name" => r.name = Some(one(val)?),
                "value" => r.value = Some(one(val)?),
                "class" => r.class = Some(one(val)?),
                "head" => r.head = one(val)?,
                "head_class" => r.head_class = one(val)?,
                "chain" => r.chain = one(val)?,
                "unexplained" => r.unexplained = one(val)?,
                "heads" => r.heads = why_toml::string_list(val).map_err(at)?,
                "requirers" => r.requirers.extend(why_toml::requirers(val).map_err(at)?),
                _ => bail!("kernel.why.toml line {n}: {key:?} is not a row key"),
            }
        }
        if in_missing {
            bail!("kernel.why.toml: the `missing` list is never closed");
        }
        if let Some(r) = row.take() {
            index.rows.push(finish(r, n)?);
        }
        Ok(index)
    }
}

/// The three value shapes [`WhyIndex::to_toml`] writes — a Rust-escaped string
/// (`{:?}`), a list of them, and the one-requirer inline table — read back.
mod why_toml {
    /// One `"…"` at the start of `s`, unescaped the way `{:?}` escaped it; the
    /// rest of `s` after the closing quote.
    pub(super) fn string(s: &str) -> Result<(String, &str), String> {
        let s = s.trim_start();
        let body = s.strip_prefix('"').ok_or_else(|| format!("expected a string, got {s:?}"))?;
        let mut out = String::new();
        let mut it = body.char_indices();
        while let Some((i, c)) = it.next() {
            match c {
                '"' => return Ok((out, &body[i + 1..])),
                '\\' => match it.next().map(|(_, e)| e) {
                    Some('n') => out.push('\n'),
                    Some('t') => out.push('\t'),
                    Some('r') => out.push('\r'),
                    Some('0') => out.push('\0'),
                    Some('\\') => out.push('\\'),
                    Some('"') => out.push('"'),
                    Some('\'') => out.push('\''),
                    Some('b') => out.push('\u{8}'),
                    Some('f') => out.push('\u{c}'),
                    Some(u @ ('u' | 'U')) => {
                        let mut hex = String::new();
                        // TOML: `\uXXXX` / `\UXXXXXXXX`; the old Rust-escaped
                        // files: `\u{…}`.
                        if body[i + 2..].starts_with('{') {
                            it.next();
                            loop {
                                match it.next().map(|(_, c)| c) {
                                    Some('}') => break,
                                    Some(h) => hex.push(h),
                                    None => return Err(format!("unterminated \\u escape in {s:?}")),
                                }
                            }
                        } else {
                            for _ in 0..if u == 'u' { 4 } else { 8 } {
                                match it.next().map(|(_, c)| c) {
                                    Some(h) => hex.push(h),
                                    None => return Err(format!("short \\{u} escape in {s:?}")),
                                }
                            }
                        }
                        let ch = u32::from_str_radix(&hex, 16)
                            .ok()
                            .and_then(char::from_u32)
                            .ok_or_else(|| format!("bad \\u{{{hex}}} in {s:?}"))?;
                        out.push(ch);
                    }
                    other => return Err(format!("unknown escape {other:?} in {s:?}")),
                },
                c => out.push(c),
            }
        }
        Err(format!("unterminated string {s:?}"))
    }

    /// `["a", "b"]`.
    pub(super) fn string_list(s: &str) -> Result<Vec<String>, String> {
        let mut rest = s.trim().strip_prefix('[').ok_or_else(|| format!("expected a list, got {s:?}"))?.trim_start();
        let mut out = Vec::new();
        loop {
            if let Some(r) = rest.strip_prefix(']') {
                return if r.trim().is_empty() { Ok(out) } else { Err(format!("trailing {r:?}")) };
            }
            let (v, r) = string(rest)?;
            out.push(v);
            rest = r.trim_start();
            rest = rest.strip_prefix(',').unwrap_or(rest).trim_start();
        }
    }

    /// `[{ consumer = "…", feature = "…", reason = "…" }, { … }]` — every
    /// requirer of one symbol (an old file's one-per-line form is the same
    /// shape with one entry).
    pub(super) fn requirers(s: &str) -> Result<Vec<super::Requirer>, String> {
        let mut rest = s.trim().strip_prefix('[').ok_or_else(|| format!("expected [{{ … }}], got {s:?}"))?.trim_start();
        let mut out = Vec::new();
        loop {
            if let Some(r) = rest.strip_prefix(']') {
                return if r.trim().is_empty() { Ok(out) } else { Err(format!("trailing {r:?}")) };
            }
            let (q, r) = requirer(rest)?;
            out.push(q);
            rest = r.trim_start();
            rest = rest.strip_prefix(',').unwrap_or(rest).trim_start();
        }
    }

    /// One `{ consumer = "…", feature = "…", reason = "…" }` at the start of
    /// `s`; the rest of `s` after its closing brace.
    fn requirer(s: &str) -> Result<(super::Requirer, &str), String> {
        let mut rest = s.trim_start().strip_prefix('{').ok_or_else(|| format!("expected {{ … }}, got {s:?}"))?;
        let (mut consumer, mut feature, mut reason) = (None, None, None);
        loop {
            rest = rest.trim_start();
            if let Some(r) = rest.strip_prefix('}') {
                rest = r;
                break;
            }
            let (key, r) = rest.split_once('=').ok_or_else(|| format!("expected key = \"…\" in {s:?}"))?;
            let (v, r) = string(r)?;
            match key.trim() {
                "consumer" => consumer = Some(v),
                "feature" => feature = Some(v),
                "reason" => reason = Some(v),
                other => return Err(format!("{other:?} is not a requirer key")),
            }
            rest = r.trim_start();
            rest = rest.strip_prefix(',').unwrap_or(rest);
        }
        match (consumer, feature, reason) {
            (Some(consumer), Some(feature), Some(reason)) => Ok((super::Requirer { consumer, feature, reason }, rest)),
            _ => Err(format!("a requirer needs consumer, feature and reason: {s:?}")),
        }
    }
}

/// A TOML basic string: `"`, `\\` and every control character escaped the
/// way TOML spells them (`\n`, `\uXXXX`, …) — NOT Rust's `{:?}`, whose
/// `\u{…}` and `\0` a TOML parser refuses. Shared by the two why-files.
pub(crate) fn toml_str(v: &str) -> String {
    let mut s = String::with_capacity(v.len() + 2);
    s.push('"');
    for c in v.chars() {
        match c {
            '"' => s.push_str("\\\""),
            '\\' => s.push_str("\\\\"),
            '\n' => s.push_str("\\n"),
            '\t' => s.push_str("\\t"),
            '\r' => s.push_str("\\r"),
            '\u{8}' => s.push_str("\\b"),
            '\u{c}' => s.push_str("\\f"),
            c if c.is_control() => s.push_str(&format!("\\u{:04X}", c as u32)),
            c => s.push(c),
        }
    }
    s.push('"');
    s
}

/// **One `requirers` value for every requirer of a row** — a single array of
/// inline tables. Until 2026-10-29 the writers emitted one `requirers = [..]`
/// LINE per requirer, a duplicate key the moment two consumers asked for the
/// same symbol (k8s and cni-cilium both want `CONFIG_BPF_SYSCALL`), which made
/// the file invalid TOML exactly when it had the most to say.
pub(crate) fn toml_requirers(requirers: &[Requirer]) -> String {
    let items: Vec<String> = requirers
        .iter()
        .map(|q| format!("{{ consumer = {}, feature = {}, reason = {} }}", toml_str(&q.consumer), toml_str(&q.feature), toml_str(&q.reason)))
        .collect();
    format!("[{}]", items.join(", "))
}

// ── the declared contracts this crate itself owns ────────────────────────────

/// **What ANY consumer that boots a nordisk PID-1 requires** — the requirements
/// behind `korp-installer` / `korp-start` (this crate's sibling, the PID-1 every
/// installer and live medium execs). Each line is a measured lesson from
/// `kconfig/base.config` / `deps-tinyconfig.config`, restated as a REQUIREMENT
/// so the inverted index can say who wanted it. Declared under
/// [`APPLIANCE_BOOT_FEATURE`] on every consumer with a `kernel_donor`.
///
/// The console and bus rows are the ones the `target.*` facts
/// (`build-thing-target-facts.md`, `target.rs`) will make conditional — today
/// every medium is built for `consoles = [vga, serial]`, `buses = {ide-cdrom,
/// virtio-blk}`, so they are unconditional here and the target lane replaces
/// them row by row.
pub fn appliance_boot_requirements() -> Vec<KconfigReq> {
    vec![
        KconfigReq::y("CONFIG_PROC_FS", "korp-start/korp-installer read korp.* selectors off /proc/cmdline (measured 2026-08-27)"),
        KconfigReq::y("CONFIG_SYSFS", "the installer finds its medium and target disk by walking /sys/class/block (measured 2026-08-27)"),
        KconfigReq::y("CONFIG_DEVTMPFS", "PID 1 opens /dev/console, the medium and the target disk"),
        KconfigReq::y("CONFIG_DEVTMPFS_MOUNT", "devtmpfs is mounted by the kernel before PID 1's first word (edda 1161e68)"),
        KconfigReq::y("CONFIG_BLK_DEV_INITRD", "the PID-1 rides an initramfs (cpio)"),
        KconfigReq::y("CONFIG_BINFMT_ELF", "a kernel that cannot exec PID 1 is a brick"),
        KconfigReq::y("CONFIG_SMP", "every vCPU the plan pays for — a 2xCPU UpCloud plan served on one core with SMP off (measured 2026-08-29)"),
        // Lane AE, 2026-08-30: the SMP CEILING is a VALUE, and the surface can
        // carry one now ([`KconfigValue::Int`]). `SMP=y` alone does not give
        // x86_64's default 64: tinyconfig's base carries `NR_CPUS=1` and
        // `olddefconfig` only lifts it to the SMP range floor — MEASURED
        // `CONFIG_NR_CPUS=2` on the nuked base (lane K, 2026-08-29), which is
        // one core short of every plan above STARTER-2xCPU. The line stays in
        // `kconfig/base.config` as the donor's shape; THIS is the consumer
        // saying why, so the why-index stops calling it preset-only.
        KconfigReq::int("CONFIG_NR_CPUS", 64, "the ceiling SMP needs: olddefconfig lifts tinyconfig's NR_CPUS=1 only to the SMP floor (measured NR_CPUS=2 on the nuked base, 2026-08-29) — 64 is the arch default and covers the whole plan roster"),

        KconfigReq::y("CONFIG_MEMFD_CREATE", "korp-start fexecve()s the verified payload out of memory (measured 2026-08-27)"),
        KconfigReq::y("CONFIG_SHMEM", "/data is RAM-backed on a live medium = tmpfs (measured 2026-08-27)"),
        KconfigReq::y("CONFIG_TMPFS", "/data is RAM-backed on a live medium = tmpfs"),
        KconfigReq::y("CONFIG_FILE_LOCKING", "every nordisk store takes flock(LOCK_EX) first (redb; measured 2026-08-27)"),
        KconfigReq::y("CONFIG_EPOLL", "an async Rust service: epoll is the reactor"),
        KconfigReq::y("CONFIG_EVENTFD", "an async Rust service: eventfd wakes workers"),
        KconfigReq::y("CONFIG_TIMERFD", "an async Rust service: timeouts"),
        KconfigReq::y("CONFIG_SIGNALFD", "graceful shutdown"),
        KconfigReq::y("CONFIG_FUTEX", "every std mutex and Once compiles to futex"),
        KconfigReq::y("CONFIG_POSIX_TIMERS", "service timers"),
        // NOT here, deliberately: CONFIG_UNIX. deps-tinyconfig.config enables it
        // as a closure parent, but no PID-1 code path is MEASURED to open an
        // AF_UNIX socket (pid1's netlink is AF_NETLINK), and `--opt
        // kernel.unix=off` is the model's canonical legal veto. A requirement is
        // a measured need, never a habit.
        //
        // Lane AE (2026-08-30) went looking for an owner for every `preset-only`
        // symbol and left this one on purpose. It is the model's WORKED EXAMPLE
        // of the third answer: not "undeclared" (that is what the lane closed)
        // and not "wanted", but a donor line that no consumer can honestly
        // claim — so it stays preset-only, WITH this reason, and the veto door
        // is how it gets tested rather than a requirement that would only make
        // the index look tidy.
        KconfigReq::y("CONFIG_TTY", "a console at all"),
        KconfigReq::y("CONFIG_PRINTK", "a kernel that can say what killed it (UpCloud 2026-08-28)"),
        KconfigReq::y("CONFIG_VT", "cloud VNC is the only console: VGA text console (UpCloud 2026-08-28)"),
        KconfigReq::y("CONFIG_VT_CONSOLE", "printk to tty0"),
        KconfigReq::y("CONFIG_VGA_CONSOLE", "the VGA text console the operator actually has"),
        KconfigReq::y("CONFIG_SERIAL_8250", "console=ttyS0 on hosts that expose a UART (`target.uarts` owns it once a machine is declared: the PORTS decide the driver, and only a machine with no COM port at all says =n)"),
        KconfigReq::y("CONFIG_SERIAL_8250_CONSOLE", "printk to ttyS0"),
        KconfigReq::y("CONFIG_SERIAL_EARLYCON", "`earlycon` is the ONLY printk between the firmware handing over and the 8250 driver registering — the window every dark boot this constellation has debugged sits in (`target.uarts` owns it once a machine is declared, for the same reason as the driver above)"),
        KconfigReq::y("CONFIG_EFI_STUB", "UEFI/OVMF direct boot of the bzImage"),
        // EFI_STUB's two parents, named. Lane AE, 2026-08-30: they sat in
        // `kconfig/deps-tinyconfig.config` as a closure the fragment owed and
        // NOBODY owned, which is exactly the shape the why-index calls
        // preset-only. `target.firmware` owns them once a consumer declares the
        // group; until then the contract does, because every medium this crate
        // composes carries an ESP.
        KconfigReq::y("CONFIG_EFI", "the medium carries an ESP and the firmware starts the bzImage through it — EFI_STUB's parent"),
        KconfigReq::y("CONFIG_ACPI", "on x86 CONFIG_EFI depends on ACPI: the firmware tables are how an EFI boot finds the machine at all"),
        KconfigReq::y("CONFIG_PCI", "every device the appliance has hangs off the PCI root complex — virtio-blk, virtio-net, and the IDE cdrom a cloud attaches the medium as (target.buses owns this once the group is declared)"),
        KconfigReq::y("CONFIG_BLOCK", "the installer opens the target disk and lays a GPT on it; every filesystem below rides the block layer, and tinyconfig leaves it off"),
        KconfigReq::y("CONFIG_VIRTIO", "the medium and the target disk are virtio"),
        KconfigReq::y("CONFIG_VIRTIO_MENU", "virtio itself is behind a menu symbol — VIRTIO, VIRTIO_PCI, VIRTIO_BLK and VIRTIO_NET are all inside it"),
        KconfigReq::y("CONFIG_VIRTIO_PCI", "virtio rides PCI"),
        KconfigReq::y("CONFIG_VIRTIO_BLK", "target disk vda, medium vdb (target.buses ∋ virtio-blk)"),
        KconfigReq::y("CONFIG_VIRTIO_NET", "the appliance's only NIC: distro kernels carry E1000 as =m and an initramfs has no modules, so an e1000 guest is dead metal ⇒ no eth0 ⇒ PID 1 exits ⇒ panic (target.nic owns this once the group is declared)"),
        // ── the network stack PID 1 itself opens ────────────────────────────
        // Not the app's: PID 1's. `gunnar_server::pid1` configures its own
        // interface over AF_NETLINK/NETLINK_ROUTE (RTM_NEWLINK/NEWADDR/NEWROUTE)
        // because `CONFIG_IP_PNP` is not set on any kernel this constellation
        // ships (measured on oden 2026-08-03), and `pid1::ship_boot_narration`
        // binds a UDP socket to send the boot log to `korp.log_udp=`. Every one
        // of these four was a `deps-tinyconfig.config` closure line with no
        // owner until lane AE (2026-08-30).
        KconfigReq::y("CONFIG_NET", "PID 1 configures its own interface over an AF_NETLINK/NETLINK_ROUTE socket (CONFIG_IP_PNP is not set on any kernel we ship — measured oden 2026-08-03); AF_NETLINK lives inside CONFIG_NET"),
        KconfigReq::y("CONFIG_INET", "the address PID 1 writes is AF_INET (ifaddrmsg.ifa_family) and the boot narration ships from a UDP socket bound to 0.0.0.0:0 (pid1::ship_boot_narration); every door the appliance opens afterwards is TCP over IPv4"),
        KconfigReq::y("CONFIG_NETDEVICES", "there is a NIC at all — the menu the appliance's one driver lives under"),
        KconfigReq::y("CONFIG_NET_CORE", "the netdev core the driver registers through and PID 1 then brings up (RTM_NEWLINK eth0 IFF_UP)"),
        KconfigReq::y("CONFIG_ISO9660_FS", "the medium is ISO-9660 (=m on distro kernels, unusable from an initramfs)"),
        KconfigReq::y("CONFIG_VFAT_FS", "the ESP and the data partition are FAT"),
        KconfigReq::y("CONFIG_FAT_FS", "VFAT_FS's parent — vfat is FAT plus an NLS table"),
        KconfigReq::y("CONFIG_NLS", "VFAT_FS's other parent: the codepage tables below live under it"),
        KconfigReq::text("CONFIG_FAT_DEFAULT_IOCHARSET", "cp437", "MEASURED 2026-08-13, the first boot of a real installed disk: the distro default \"iso8859-1\" against a =m NLS table gave `FAT-fs (vda1): IO charset iso8859-1 not found` → EINVAL. cp437 is FAT's own codepage and every name the installer writes is ASCII"),
        KconfigReq::y("CONFIG_NLS_CODEPAGE_437", "FAT's own codepage; a =m NLS table refused the first installed disk (2026-08-13)"),
        KconfigReq::y("CONFIG_NLS_ISO8859_1", "FAT_DEFAULT_IOCHARSET's fallback table, built in for the same reason"),
        // ── the negative law (owner, 2026-08-29, in caps): NO IPv6 in the appliance kernel.
        // The veto line lives in kconfig/nuked.config (edda 1161e68); this row is the
        // REQUIREMENT that makes a feature asking for IPV6=y a named refusal.
        KconfigReq::n("CONFIG_IPV6", "the appliance speaks IPv4 only — PF_INET6, SEG6, IOAM and sit were code nobody drives (UpCloud VGA log 2026-08-29; veto in nuked.config, edda 1161e68)"),
        KconfigReq::n("CONFIG_WIREGUARD", "the data plane is userspace boringtun over TUN; a kernel WireGuard would be a second data plane nobody drives (.nornir/wg-appliance.md)"),
        // ── ONE user, and that user is root (lane AE, 2026-08-30) ───────────
        // Same shape as the two rows above: the veto line lives in
        // `kconfig/nuked.config`, and THIS row is the requirement that makes a
        // feature asking for `CONFIG_MULTIUSER=y` a named refusal instead of a
        // silent regrowth. MEASURED on the appliance's own built .config: the
        // only two subsystems in 6.12.104 that `depends on MULTIUSER` and that
        // this kernel could plausibly want — `CONFIG_NAMESPACES` and
        // `CONFIG_SECURITY` — are ALREADY `is not set`, so the absence takes
        // nothing derived away. What it compiles out is the setuid/setgid/
        // capset family (`kernel/sys.c`, `kernel/capability.c`), and no
        // nordisk PID 1 calls one: korp-start execs the verified payload as
        // root and there is no second user to drop to.
        // Since lane HOLGER-MEDIA (2026-10-23) the directive lives HERE and not
        // in `kconfig/nuked.config`: a consumer whose own rows require
        // `CONFIG_MULTIUSER=y` (containers: user namespaces and cgroups) lifts
        // this one row by name; for everyone else the merged fragment is the
        // same bytes as before.
        // ── no framebuffer, no DRM, no boot logo (owner 2026-10-30) ─────────
        // The appliance's screen is the VGA TEXT console above; a framebuffer
        // and the logo exist to draw graphics nobody looks at. They were preset
        // vetoes in `kconfig/nuked.config` only (which still says so, so a
        // nuked consumer's merged fragment is byte-identical: the same three
        // lines); since option A they are the contract's rows too, so every
        // consumer states the absence and a feature asking for one is refused
        // by name. A ROW may lift the framebuffer console (`FB`, `FB_EFI`,
        // `FRAMEBUFFER_CONSOLE`, [`ROW_LIFTABLE_SYMBOLS`]; the last two depend
        // on `FB`, whose `=n` is their veto) — the k8s node, whose console
        // dashboard must show under UEFI; `DRM` and `LOGO` are never lifted.
        KconfigReq::n("CONFIG_FB", "the appliance's screen is the VGA text console; a framebuffer is code nobody drives (FB_EFI and FRAMEBUFFER_CONSOLE depend on it, so this row vetoes them too; a row that must show its console under UEFI lifts all three: ROW_LIFTABLE_SYMBOLS, owner 2026-10-30)"),
        KconfigReq::n("CONFIG_DRM", "no GPU stack in an appliance: DRM is the largest attack surface a kernel can carry for a screen nobody needs (never liftable, owner 2026-10-30)"),
        KconfigReq::n("CONFIG_LOGO", "no boot logo over the console (never liftable, owner 2026-10-30)"),
        KconfigReq::n(SINGLE_USER_SYMBOL, "PID 1 is root and execs the payload as root — nothing in korp-start, korp-installer or gunnar-server calls setuid/setgid/capset, and the two subsystems that depend on it (NAMESPACES, SECURITY) are off; a consumer that runs containers requires it =y itself and lifts this row"),
    ]
}

/// ── moved AS IS from gunnar-recipe `clock_requires` (gunnar-live 50c3e69,
/// `crates/gunnar-recipe/src/recipe.rs:590`) so every product's media declare
/// the paravirt wall clock without depending on gunnar (holger first; gunnar
/// adopts later). Only changes: `pub`, the `nornir_build_thing::` paths read `crate::` from
/// inside the crate, and the one intra-doc link that named a gunnar-recipe item
/// is spelled as text.
///
/// **What the appliance's WALL CLOCK needs of the donor kernel** — the
/// paravirtualised clock, on every medium, whatever is toggled.
///
/// # The defect this closes, measured
///
/// 2026-09-20, the live appliance on UpCloud, after a clean re-image:
/// `appliance − front = +7 198 668 ms` — 1 h 59 m 58.7 s, the CEST offset to
/// the second, not drift. Every credential then fell outside
/// `gunnar_grpc::auth::MAX_CLOCK_SKEW_MS` (±300 000 ms), and the console key,
/// the git banner and monetize's tenant poll all went red behind one cause.
///
/// **It is not gunnar reading a clock wrongly.** gunnar reads
/// `SystemTime::now()` and nothing else: there is no RTC read, no `/etc/adjtime`
/// and no timezone anywhere in this repository or in korp-start — a grep for
/// `rtc|hctosys|localtime|adjtime|timezone` over `crates/` and `xtask/` finds no
/// such read. PROVEN under KVM on oden the same day: the guest was handed a CMOS
/// two hours ahead (`-rtc base=2026-09-20T12:00:00` on a host reading 10:00 UTC)
/// and gunnar's first log line read `2026-09-20T12:00:03.485013Z` — the CMOS
/// reproduced to the millisecond, with no offset applied by anything in between.
///
/// **It is WHICH clock the kernel reads.** The donor's built config carries
/// `# CONFIG_HYPERVISOR_GUEST is not set`, so there is no kvmclock and
/// `x86_platform.get_wallclock` falls through to its default,
/// `mach_get_cmos_time` (`arch/x86/kernel/x86_init.c:149`; `arch/x86/kernel/rtc.c`
/// is `obj-y` on x86 whatever `CONFIG_RTC_CLASS` says) — the emulated mc146818
/// CMOS, which QEMU fills from `-rtc base=`. A host started `base=localtime`
/// therefore hands the guest local time, and the kernel — correctly, since the
/// kernel has no notion of a timezone — reads it as UTC: **+2 h in summer, +1 h
/// in winter, every boot.**
///
/// The front, on the same hypervisor and the same zone, is right to 468 ms
/// because ordinary Ubuntu has `CONFIG_KVM_GUEST=y`: `kvm_get_wallclock`
/// (`arch/x86/kernel/kvmclock.c:325`) reads the KVM wall-clock MSR, which the KVM
/// *module* fills from the HOST's `ktime_get_real_ns()` (`arch/x86/kvm/x86.c`,
/// `kvm_get_wall_clock_epoch`) — the host's UTC, out of reach of `-rtc base=`,
/// which shapes only the emulated RTC device. Same hypervisor, different answer,
/// and the whole difference is these four symbols. The estate's older,
/// never-chased note "yvra VMs boot +2 h" is the same number on the same
/// provider: one mechanism, two sightings.
///
/// Declared HERE rather than in a kernel fragment for [`hotplug_requires`]'s
/// reason: a donor that drops the block is then a refusal BY NAME at
/// `--build kernel`, instead of a medium that silently boots two hours fast and
/// refuses every credential it is handed.
///
/// This does not make the signed-peer sync redundant. On a platform with no
/// paravirt clock — metal with a local-time CMOS, Hyper-V, a restored image — the
/// boot clock is still whatever the box booted with, and `gunnar_clock` is what
/// corrects it. This removes the one cause this estate actually has.
pub fn clock_requires() -> Vec<crate::requires::EdgeRequirement> {
    use crate::requires::{EdgeRequirement, Requirement};
    [
        ("CONFIG_HYPERVISOR_GUEST", "the gate symbol for every paravirt guest feature; with it off the x86 wall clock falls through to mach_get_cmos_time and the appliance boots on whatever the emulated CMOS was filled with"),
        ("CONFIG_PARAVIRT", "the paravirt op vectors kvmclock installs itself through: x86_platform.get_wallclock and the clocksource"),
        ("CONFIG_KVM_GUEST", "kvm_get_wallclock: the boot wall clock read from the KVM wall-clock MSR, which the host fills from its own CLOCK_REALTIME - UTC, and out of reach of the hypervisor's -rtc base="),
        ("CONFIG_PARAVIRT_CLOCK", "the pvclock reader itself (pvclock_read_wallclock), and a monotonic clocksource that survives vCPU migration instead of raw TSC"),
    ]
    .into_iter()
    .map(|(symbol, reason)| EdgeRequirement { edge: "kernel".to_string(), what: Requirement::Kconfig(KconfigReq::y(symbol, reason)) })
    .collect()
}

/// ── moved AS IS from gunnar-recipe `hotplug_requires` (gunnar master 4c794781,
/// `crates/gunnar-recipe/src/recipe.rs:763`, landed 35290f29 2026-09-15 beside the
/// runtime probe b247bf64) so every product's installer media demand PCI hot-plug
/// without depending on gunnar (lane G10, 2026-10-21: holger-recipe had none, and a
/// runtime grow on such a kernel answers 511). Only changes: `pub`, the paths read
/// `crate::`.
///
/// **What the runtime data-set growth needs of the donor kernel, on every
/// installer medium** (row-level `[[build.installer.requires]]`, edge `kernel`).
///
/// The appliance is PID 1 and drains and unmounts `/data` itself, so
/// growing the set needs no restart — EXCEPT that UpCloud resizes only DETACHED
/// volumes: a member is detached from the running box, resized and re-attached,
/// and a kernel with `# CONFIG_HOTPLUG_PCI is not set` never sees it leave
/// (`511 HOTPLUG_FAILED`) or come back. MEASURED 2026-09-14/15: tunnr
/// `kernel-pci-hotplug` (87000cc) + growbench R5, unplug → resize → re-plug ×4,
/// 128 → 256 GiB, no reboot (private-gunnar-ops DATA-SET-GROWTH-DESIGN.md §14).
///
/// The donor already grants every line through its last merge layer
/// (`tunnr/variants/kernel/tunnr-hardening.config`); saying it HERE is what makes
/// a donor that drops the block a refusal BY NAME at `--build kernel` instead of
/// a medium that silently grows only at restart. `CONFIG_PCI_MSI` is in the list
/// because it was measured necessary: without it `_OSC` control is never
/// requested and pciehp is compiled in and dead.
///
/// Not on a LIVE medium: growing the data set is the installed disk's verb, and a
/// live medium's `/data` is RAM.
pub fn hotplug_requires() -> Vec<crate::requires::EdgeRequirement> {
    use crate::requires::{EdgeRequirement, Requirement};
    [
        ("CONFIG_HOTPLUG_PCI", "the hot-plug core: a member detached from and re-attached to the RUNNING appliance is otherwise invisible, and UpCloud resizes only detached volumes"),
        ("CONFIG_HOTPLUG_PCI_ACPI", "acpiphp: the firmware-driven eject path (QEMU i440fx, q35 since 6.1) that acknowledges a detach instead of answering HOTPLUG_FAILED"),
        ("CONFIG_PCIEPORTBUS", "binds the PCIe root ports and requests _OSC control, without which native PCIe hot-plug never takes a slot"),
        ("CONFIG_HOTPLUG_PCI_PCIE", "pciehp: the native PCIe hot-plug path, for q35 with ACPI hot-plug off and for metal"),
        ("CONFIG_HOTPLUG_PCI_SHPC", "shpchp: a member behind a conventional pci-bridge slot (the cloud's bus topology is unmeasured)"),
        ("CONFIG_PCI_MSI", "MEASURED necessary: without MSI `_OSC: not requesting OS control` and pciehp is compiled in but dead"),
    ]
    .into_iter()
    .map(|(symbol, reason)| EdgeRequirement { edge: "kernel".to_string(), what: Requirement::Kconfig(KconfigReq::y(symbol, reason)) })
    .collect()
}

// ── an installer medium reads its own CD (Gunnar 09fe2c48, shared) ───────────

/// **The kernel symbols an installer medium needs to read the CD it booted from**
/// (Gunnar `xtask/src/medium.rs` `CD_BLOCK`, tunnr 939dbe3). ALL of them, because the
/// two machines a medium meets differ: PIIX is UpCloud's IDE (i440fx), AHCI is the
/// mock's q35, SCSI is the layer `sr` rides and SR is the cdrom driver on both.
///
/// Not a [`KconfigReq`] list on purpose: `nuked` vetoes ATA and SCSI
/// (`kconfig/nuked.config`), a consumer requirement is never merged over a veto
/// ([`crate::kernel::KernelBuild::apply_requirements`]), and the donor carries the
/// block in its own `kconfig_fragment`, merged after the preset. So the block is
/// judged where the kernel is already built: on its `.config`, before a medium is
/// composed around it ([`refuse_unless_reads_its_cd`]).
pub const CD_BLOCK: &[&str] = &["CONFIG_ATA", "CONFIG_ATA_PIIX", "CONFIG_SATA_AHCI", "CONFIG_SCSI", "CONFIG_BLK_DEV_SR"];

/// The symbols of [`CD_BLOCK`] a built `.config` does not set `=y` (a `=m` is a no:
/// the appliance has no initramfs to load a module from). Empty: the kernel reads
/// its own CD.
pub fn missing_cd_block(config_text: &str) -> Vec<&'static str> {
    CD_BLOCK
        .iter()
        .copied()
        .filter(|want| {
            !config_text
                .lines()
                .filter_map(KconfigValue::of_directive)
                .any(|(sym, v)| sym == *want && v == KconfigValue::Y)
        })
        .collect()
}

/// **A medium that cannot read its own CD is not composed** (Gunnar 09fe2c48,
/// owner's order 2026-09-23: "refuse when cd_driver is false"). The ladder uploads a
/// medium ONCE and installs it from the tray; a kernel without the CD block boots,
/// finds no ISO-9660 volume, prints FAILED and parks. MEASURED 2026-09-23 22:53: a
/// medium whose record said `cd_driver: false` was composed, uploaded, attached and
/// booted, and the ladder waited 1200 s on a box that had failed at second ten.
///
/// `config_text` is the kernel's BUILT `.config`; `None` (a kernel whose config
/// cannot be read) refuses too, as Gunnar's does: an unknown kernel is assumed to
/// have no CD driver. `Ok` carries the line a compose prints.
pub fn refuse_unless_reads_its_cd(config_text: Option<&str>, medium: &str, kernel: &str) -> Result<String> {
    let why = match config_text {
        None => format!("the kernel {kernel} has no .config beside it to read, so its CD block cannot be proven — an unknown kernel is assumed to have no CD driver"),
        Some(text) => {
            let missing = missing_cd_block(text);
            if missing.is_empty() {
                return Ok(format!("{medium}: the kernel {kernel} reads its own CD ({})", CD_BLOCK.iter().map(|s| format!("{s}=y")).collect::<Vec<_>>().join(" ")));
            }
            format!("the kernel {kernel} lacks the CD block: {} not =y in its built .config", missing.join(", "))
        }
    };
    Err(anyhow!(
        "REFUSED [medium-no-cd-driver] {medium} cannot read its own CD: {why}. The ladder uploads a medium \
         once and installs it from the tray, so this medium would boot, find no ISO-9660 volume and park \
         on FAILED. Rebuild the donor kernel with the CD block in its own fragment (tunnr 939dbe3 or \
         later: {}) and compose again.",
        CD_BLOCK.iter().map(|s| format!("{s}=y")).collect::<Vec<_>>().join(" ")
    ))
}

/// **The Kconfig closure of one consumer variant** — the kernel-edge half of
/// [`crate::requires::closure_of_consumer`] (the generic engine: every source,
/// every edge). `Ok(None)` when the variant consumes no kernel.
pub fn requirements_of_variant(
    set: &crate::variant::BuildVariantSet,
    repo: &str,
    variant: &str,
    toggles: &BTreeMap<String, bool>,
) -> Result<Option<KernelRequirements>> {
    let Some(closure) = crate::requires::closure_of_consumer(set, repo, variant, toggles)? else { return Ok(None) };
    Ok(closure.by_edge.values().find_map(|c| c.as_kconfig().cloned()).filter(|k| !k.is_empty()))
}

/// **[`requirements_of_variant`] from a DOOR's line** — the consumer's own
/// `--opt` strings as typed (`dhcp=on`, `kernel.preset=nuked`, …): the
/// root-scope toggles are what the closure reads, the dotted (edge-scoped)
/// pairs belong to the donor's door and are skipped. `repo` labels the
/// requirer (`repo/variant` in `kernel.why.toml`); a door names its checkout
/// the way the planner does (the directory name).
///
/// This is the SAME call the planner makes for the kernel step's `requires`,
/// so the pin a consumer verifies its donor against
/// ([`crate::kernel::resolve_kernel_for`]) is the merge the chained build
/// pinned — one closure, two readers.
pub fn requirements_of_consumer_line(
    set: &crate::variant::BuildVariantSet,
    repo: &str,
    variant: &str,
    opts: &[String],
) -> Result<Option<KernelRequirements>> {
    // The same filter as `requires::toggles_in_scope` at the root scope: a
    // dotted path is another door's, `preset`/`profile` are not toggles.
    let root_scope: Vec<&String> = opts
        .iter()
        .filter(|o| {
            let key = o.split_once('=').map(|(k, _)| k).unwrap_or(o);
            !key.contains(crate::tree::PATH_SEP) && key != "preset" && key != "profile" && key != crate::kernel_trim::coverage::COVERAGE_OPT
        })
        .collect();
    let toggles = crate::options::parse_cli_options(&root_scope)?;
    requirements_of_variant(set, repo, variant, &toggles)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::kernel::{merge_kconfig, BASE_FRAGMENT, NUKED_FRAGMENT, TINYCONFIG_DEPS_FRAGMENT};

    fn dhcp() -> Vec<KconfigReq> {
        vec![KconfigReq::y("CONFIG_INET", "UDP 0.0.0.0:68"), KconfigReq::y("CONFIG_VIRTIO_NET", "the cloud NIC")]
    }

    #[test]
    fn directives_round_trip_and_key_the_same_bytes_as_the_fragments() {
        assert_eq!(KconfigValue::Y.directive("CONFIG_X"), "CONFIG_X=y");
        assert_eq!(KconfigValue::N.directive("CONFIG_X"), "# CONFIG_X is not set");
        assert_eq!(KconfigValue::Str("cp437".into()).directive("CONFIG_F"), "CONFIG_F=\"cp437\"");
        assert_eq!(KconfigValue::of_directive("# CONFIG_X is not set"), Some(("CONFIG_X", KconfigValue::N)));
        assert_eq!(KconfigValue::of_directive("CONFIG_F=\"cp437\""), Some(("CONFIG_F", KconfigValue::Str("cp437".into()))));
        assert_eq!(KconfigValue::of_directive("# a comment"), None);
        // A requirement that repeats a fragment line merges to IDENTICAL bytes → same pin.
        // Since lane HOLGER-MEDIA (2026-10-24) the contract carries ONE line the nuked
        // preset no longer does, `# CONFIG_MULTIUSER is not set` (the one-user default a
        // container-running consumer lifts by name), so the merged fragment of every
        // other consumer is the preset's plus exactly that line, appended: the pin moved
        // ONCE, for that line, and never for a repeated one.
        let nuked = merge_kconfig(&[TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT, NUKED_FRAGMENT]);
        assert!(!nuked.contains("CONFIG_MULTIUSER"), "the preset no longer decides the user model");
        let mut r = KernelRequirements::default();
        r.require_all("gunnar/x", APPLIANCE_BOOT_FEATURE, &appliance_boot_requirements());
        let with = merge_kconfig(&[TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT, NUKED_FRAGMENT, &r.fragment()]);
        assert_eq!(with, format!("{nuked}# CONFIG_MULTIUSER is not set\n"), "the boot contract adds exactly its one-user line to the nuked fragment set");
        // The lifting consumer (its row says =y, the contract's =n stepped aside):
        let mut lifted = KernelRequirements::default();
        lifted.require_all("holger/x", APPLIANCE_BOOT_FEATURE, &appliance_boot_requirements().into_iter().filter(|k| k.symbol != SINGLE_USER_SYMBOL).collect::<Vec<_>>());
        lifted.require("holger/x", "sandbox", &KconfigReq::y(SINGLE_USER_SYMBOL, "containers"));
        let with = merge_kconfig(&[TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT, NUKED_FRAGMENT, &lifted.fragment()]);
        assert_eq!(with, format!("{nuked}CONFIG_MULTIUSER=y\n"));
    }

    #[test]
    fn the_appliance_boot_contract_is_satisfied_by_the_nuked_fragment_and_names_ipv6_off() {
        let nuked = merge_kconfig(&[TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT, NUKED_FRAGMENT]);
        let mut r = KernelRequirements::default();
        r.require_all("gunnar/gunnar-installer-open", APPLIANCE_BOOT_FEATURE, &appliance_boot_requirements());
        assert!(r.contradictions(&nuked, "preset fragment").is_empty(), "{:?}", r.contradictions(&nuked, "preset fragment"));
        assert_eq!(r.rows["CONFIG_IPV6"][0].0, KconfigValue::N);
        // Every PID1_BOOT_CONTRACT symbol is a requirement too — one list feeds the other.
        for s in crate::kernel::PID1_BOOT_CONTRACT {
            assert!(r.rows.contains_key(*s), "{s} in PID1_BOOT_CONTRACT but not a requirement");
        }
    }

    /// **The lane-AE law, stated as a test: every POSITIVE line of the shipped
    /// fragment has an owner, and the ones that do not are named HERE with the
    /// reason nobody can claim them.**
    ///
    /// `preset-only` means UNDECLARED, not unused — but the inverted index
    /// cannot tell those apart until the consumers speak, which is why this
    /// list is written down rather than counted. MEASURED 2026-08-30 before the
    /// lane: 18 of the fragment's `=y` lines had no requirer (ACPI, BLOCK, EFI,
    /// FAT_DEFAULT_IOCHARSET, FAT_FS, INET, MULTIUSER, NET, NETDEVICES,
    /// NET_CORE, NLS, NR_CPUS, PCI, SERIAL_EARLYCON, TUN, UNIX, VIRTIO_MENU,
    /// VIRTIO_NET). Seen red by construction: drop any row this lane added to
    /// [`appliance_boot_requirements`] and its symbol appears in `unowned`.
    #[test]
    fn every_positive_fragment_line_has_an_owner_except_the_ones_named_here() {
        let nuked = merge_kconfig(&[TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT, NUKED_FRAGMENT]);
        let mut r = KernelRequirements::default();
        r.require_all("gunnar/gunnar-installer-open", APPLIANCE_BOOT_FEATURE, &appliance_boot_requirements());
        let unowned: Vec<&str> = nuked
            .lines()
            .filter_map(KconfigValue::of_directive)
            .filter(|(_, v)| *v != KconfigValue::N)
            .map(|(sym, _)| sym)
            .filter(|sym| !r.rows.contains_key(*sym))
            .collect();
        assert_eq!(
            unowned,
            ["CONFIG_UNIX"],
            "a positive fragment line with no requirer is UNDECLARED, not unused — declare it on the \
             feature that needs it, or write down here why nobody can (CONFIG_UNIX is the one such \
             line: no PID-1 path is measured to open an AF_UNIX socket, and `--opt kernel.unix=off` \
             is the door that tests it)"
        );
        // And the removal is visible from the other side: TUN is not a
        // fragment line at all any more, so `wireguard=off` cannot ship it.
        assert!(!nuked.contains("CONFIG_TUN"), "TUN left the donor's shape for gunnar's `wireguard` option");
    }

    /// **`NR_CPUS = 64` is a VALUE, and the surface carries values.**
    ///
    /// Seen red: with `KconfigValue::Str` the directive was `CONFIG_NR_CPUS="64"`
    /// — a string spelling of an int option, which kconfig drops on
    /// `olddefconfig` and the canary then refuses. [`KconfigValue::Int`] is what
    /// makes the row expressible, and the merge keys on the same bytes the
    /// fragment writes, so the pin does not move.
    #[test]
    fn an_int_option_is_expressible_and_a_preset_that_pins_a_smaller_ceiling_is_refused_by_name() {
        assert_eq!(KconfigValue::Int(64).directive("CONFIG_NR_CPUS"), "CONFIG_NR_CPUS=64");
        assert_eq!(KconfigValue::of_directive("CONFIG_NR_CPUS=64"), Some(("CONFIG_NR_CPUS", KconfigValue::Int(64))));
        assert_eq!(KconfigValue::parse("64"), KconfigValue::Int(64));
        // A quoted value stays a string, so the FAT charset keeps its quotes.
        assert_eq!(KconfigValue::of_directive("CONFIG_FAT_DEFAULT_IOCHARSET=\"cp437\""), Some(("CONFIG_FAT_DEFAULT_IOCHARSET", KconfigValue::Str("cp437".into()))));
        assert_eq!(KconfigReq::text("CONFIG_F", "cp437", "x").value.directive("CONFIG_F"), "CONFIG_F=\"cp437\"");
        // The contract carries the row, and it is the int and not a string.
        let contract = appliance_boot_requirements();
        let n = contract.iter().find(|r| r.symbol == "CONFIG_NR_CPUS").expect("the SMP ceiling is declared");
        assert_eq!(n.value, KconfigValue::Int(64));
        // RED: a donor that pins a smaller ceiling is refused BY NAME rather
        // than silently serving 8 of the 64 cores the plan pays for.
        let mut r = KernelRequirements::default();
        r.require_all("gunnar/x", APPLIANCE_BOOT_FEATURE, &contract);
        let c = r.contradictions("CONFIG_NR_CPUS=8\n", "the `tiny` preset fragment");
        assert_eq!(c.len(), 1, "{c:?}");
        let text = c[0].to_string();
        assert!(text.contains("CONFIG_NR_CPUS") && text.contains("64") && text.contains("says `8`"), "{text}");
        // …and the built .config is held to it too.
        assert!(!r.missing_in_config("CONFIG_NR_CPUS=8\n").is_empty());
        assert!(r.missing_in_config("CONFIG_NR_CPUS=64\n").iter().all(|(s, _, _, _)| s != "CONFIG_NR_CPUS"));
    }

    /// Only a ROW lifts the framebuffer console (owner 2026-10-30): a FEATURE
    /// asking for `FB=y` is refused by name against the contract, and `DRM` /
    /// `LOGO` are never in the liftable list.
    #[test]
    fn a_feature_asking_for_a_framebuffer_is_refused_by_name_and_drm_logo_are_never_liftable() {
        for sym in ["CONFIG_FB", "CONFIG_FB_EFI", "CONFIG_FRAMEBUFFER_CONSOLE"] {
            assert!(ROW_LIFTABLE_SYMBOLS.contains(&sym), "{sym} is liftable by a row");
        }
        for sym in ["CONFIG_DRM", "CONFIG_LOGO"] {
            assert!(!ROW_LIFTABLE_SYMBOLS.contains(&sym), "{sym} is never liftable");
        }
        let mut r = KernelRequirements::default();
        r.require_all("gunnar/x", APPLIANCE_BOOT_FEATURE, &appliance_boot_requirements());
        r.require("gunnar/x", "screen-thing", &KconfigReq::y("CONFIG_FB", "wants a framebuffer"));
        let c = r.conflicts();
        assert_eq!(c.len(), 1, "{c:?}");
        let text = c[0].to_string();
        assert!(text.contains("CONFIG_FB") && text.contains("screen-thing") && text.contains("appliance-boot"), "{text}");
    }

    #[test]
    fn a_feature_asking_for_ipv6_is_refused_by_name_against_the_law_and_the_preset() {
        let mut r = KernelRequirements::default();
        r.require_all("gunnar/x", APPLIANCE_BOOT_FEATURE, &appliance_boot_requirements());
        r.require("gunnar/x", "ipv6-thing", &KconfigReq::y("CONFIG_IPV6", "wants v6"));
        let c = r.conflicts();
        assert_eq!(c.len(), 1);
        let text = c[0].to_string();
        assert!(text.contains("CONFIG_IPV6") && text.contains("ipv6-thing") && text.contains("appliance-boot"), "{text}");
        // And against the preset alone (no law row): the nuked fragment says n.
        let mut only = KernelRequirements::default();
        only.require("gunnar/x", "ipv6-thing", &KconfigReq::y("CONFIG_IPV6", "wants v6"));
        let nuked = merge_kconfig(&[TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT, NUKED_FRAGMENT]);
        let c = only.contradictions(&nuked, "preset fragment");
        assert_eq!(c.len(), 1);
        assert!(c[0].to_string().contains("preset fragment says `n`"), "{}", c[0]);
    }

    #[test]
    fn a_solved_derived_row_survives_the_wire_and_an_old_one_still_reads() {
        // The class is a `tag = "class"` enum FLATTENED into the row, so adding
        // fields to one variant is exactly where a serde shape breaks quietly.
        let solved = WhyRow {
            symbol: "CONFIG_INPUT".into(),
            value: KconfigValue::Y,
            class: WhyClass::KconfigDerived {
                head: "CONFIG_VT".into(),
                head_class: "declared".into(),
                chain: "CONFIG_VT [declared] --select--> CONFIG_INPUT".into(),
                heads: vec!["declared:CONFIG_VT".into()],
                unexplained: String::new(),
            },
        };
        let j = serde_json::to_string(&solved).unwrap();
        assert_eq!(serde_json::from_str::<WhyRow>(&j).unwrap(), solved);
        // An index written BEFORE the solver existed has none of the five
        // fields. It must still read, as an UNSOLVED row — not as an error, and
        // not as a row that claims nothing pulled the symbol in.
        let old: WhyRow = serde_json::from_str(
            r#"{"symbol":"CONFIG_INPUT","value":"y","class":"kconfig-derived"}"#,
        )
        .expect("an old kernel.why.toml row still reads");
        assert_eq!(old.class, WhyClass::derived_unsolved());
    }

    #[test]
    fn kernel_why_toml_reads_back_to_the_index_that_wrote_it() {
        let req = |c: &str, f: &str, why: &str| Requirer { consumer: c.into(), feature: f.into(), reason: why.into() };
        let derived = |head: &str, class: &str, chain: &str, heads: &[&str], unexplained: &str| WhyClass::KconfigDerived {
            head: head.into(),
            head_class: class.into(),
            chain: chain.into(),
            heads: heads.iter().map(|h| h.to_string()).collect(),
            unexplained: unexplained.into(),
        };
        let index = WhyIndex {
            rows: vec![
                // Two requirers: ONE `requirers` array (the writer used to emit
                // the key twice — a duplicate key to a TOML parser).
                WhyRow {
                    symbol: "CONFIG_TUN".into(),
                    value: KconfigValue::Y,
                    class: WhyClass::Feature {
                        requirers: vec![
                            req("gunnar/gunnar-installer-open", "wireguard", "the data plane is a TUN device"),
                            req("tunnr/tunnr-kernel", "appliance-boot", "a \"quoted\" why, a back\\slash,\nand a newline\u{1}"),
                        ],
                    },
                },
                WhyRow { symbol: "CONFIG_NLS_CODEPAGE_437".into(), value: KconfigValue::Y, class: WhyClass::PresetOnly },
                WhyRow {
                    symbol: "CONFIG_FAT_DEFAULT_IOCHARSET".into(),
                    value: KconfigValue::Str("cp437".into()),
                    class: WhyClass::PresetOnly,
                },
                // Solved, ONE head: the writer drops `heads`, the reader restores it
                // from `head` — or `sole_declared_head` would go blind off a file.
                WhyRow {
                    symbol: "CONFIG_INPUT".into(),
                    value: KconfigValue::Y,
                    class: derived("CONFIG_VT", "declared", "CONFIG_VT [declared] --select--> CONFIG_INPUT", &["declared:CONFIG_VT"], ""),
                },
                WhyRow {
                    symbol: "CONFIG_XXHASH".into(),
                    value: KconfigValue::Y,
                    class: derived(
                        "CONFIG_BLK_DEV_INITRD",
                        "declared",
                        "CONFIG_BLK_DEV_INITRD --default--> CONFIG_RD_ZSTD --select--> CONFIG_XXHASH",
                        &["declared:CONFIG_BLK_DEV_INITRD", "kconfig-default:CONFIG_XXHASH"],
                        "",
                    ),
                },
                WhyRow {
                    symbol: "CONFIG_CC_HAS_INT128".into(),
                    value: KconfigValue::Y,
                    class: derived("", "", "", &[], "a toolchain probe: the compiler decides it, no declaration can"),
                },
                WhyRow { symbol: "CONFIG_NR_CPUS".into(), value: KconfigValue::Int(64), class: WhyClass::derived_unsolved() },
            ],
            missing: vec!["CONFIG_SERIAL_8250_CONSOLE".into()],
        };
        let text = index.to_toml();
        let back = WhyIndex::from_toml(&text).expect("the writer's own output reads");
        assert_eq!(back, index, "{text}");
        assert_eq!(back.sole_declared_head("CONFIG_VT"), vec!["CONFIG_INPUT"], "off a FILE, the same answer as off the index");
        assert_eq!(back.unexplained_derived(), index.unexplained_derived());

        // Since 2026-10-29 the output is REAL TOML. Before, a symbol with
        // two requirers wrote `requirers = [..]` twice (a duplicate key) and the
        // strings were Rust-escaped; a TOML parser refused the file.
        let parsed: toml::Value = toml::from_str(&text).unwrap_or_else(|e| panic!("kernel.why.toml is not TOML: {e}\n{text}"));
        let tun = &parsed["symbol"].as_array().unwrap()[0];
        assert_eq!(tun["name"].as_str(), Some("CONFIG_TUN"));
        let who = tun["requirers"].as_array().expect("one requirers array");
        assert_eq!(who.len(), 2, "both requirers, under one key");
        assert_eq!(who[1]["reason"].as_str(), Some("a \"quoted\" why, a back\\slash,\nand a newline\u{1}"));
        assert_eq!(parsed["summary"]["missing"].as_array().unwrap().len(), 1);

        // A file in the OLD shape (one `requirers` line per requirer, Rust
        // escapes) still reads, into the same index.
        let old_shape = text
            .replace(
                &format!("requirers = {}", toml_requirers(&[req("gunnar/gunnar-installer-open", "wireguard", "the data plane is a TUN device"), req("tunnr/tunnr-kernel", "appliance-boot", "a \"quoted\" why, a back\\slash,\nand a newline\u{1}")])),
                "requirers = [{ consumer = \"gunnar/gunnar-installer-open\", feature = \"wireguard\", reason = \"the data plane is a TUN device\" }]\nrequirers = [{ consumer = \"tunnr/tunnr-kernel\", feature = \"appliance-boot\", reason = \"a \\\"quoted\\\" why, a back\\\\slash,\\nand a newline\\u{1}\" }]",
            );
        assert_ne!(old_shape, text, "the old-shape fixture replaced the new line");
        assert_eq!(WhyIndex::from_toml(&old_shape).expect("an old-shape file reads"), index);

        // A file written before the solver existed: the derived row reads, UNSOLVED.
        let old = "[summary]\nfeature = 0\npreset_only = 0\nkconfig_derived = 1\n\n[[symbol]]\nname = \"CONFIG_INPUT\"\nvalue = \"y\"\nclass = \"kconfig-derived\"\n";
        let old = WhyIndex::from_toml(old).expect("an old kernel.why.toml still reads");
        assert_eq!(old.rows[0].class, WhyClass::derived_unsolved());

        // Anything the writer never writes is refused by line, not guessed at.
        let bad = WhyIndex::from_toml("[[symbol]]\nname = \"CONFIG_X\"\nvalue = \"y\"\nclass = \"maybe\"\n").unwrap_err();
        assert!(bad.to_string().contains("no writer emits"), "{bad}");
        let bad = WhyIndex::from_toml("[[symbol]]\nname = \"CONFIG_X\"\ncolour = \"red\"\n").unwrap_err();
        assert!(bad.to_string().contains("line 3"), "{bad}");
    }

    #[test]
    fn the_why_index_partitions_the_fragment_and_names_the_nuke_candidates() {
        let mut r = KernelRequirements::default();
        r.require_all("gunnar/gunnar-installer-open", "dhcp", &dhcp());
        let frag = merge_kconfig(&["CONFIG_INET=y\nCONFIG_VIRTIO_NET=y\nCONFIG_TUN=y\n# CONFIG_IPV6 is not set\n", &r.fragment()]);
        let why = WhyIndex::of(&frag, &r);
        why.fragment_is_partitioned(&frag).unwrap();
        assert_eq!(why.counts(), (2, 2, 0));
        assert_eq!(why.preset_only(), ["CONFIG_IPV6", "CONFIG_TUN"]);
        // Join a built .config: derived symbols appear, a broken requirement is `missing`.
        let cfg = "CONFIG_INET=y\nCONFIG_TUN=y\nCONFIG_INPUT=y\n# CONFIG_VIRTIO_NET is not set\n# CONFIG_IPV6 is not set\n";
        let joined = WhyIndex::of(&frag, &r).with_built_config(cfg, &r);
        assert_eq!(joined.counts(), (2, 2, 1));
        assert_eq!(joined.missing.len(), 1);
        assert!(joined.missing[0].starts_with("CONFIG_VIRTIO_NET=y ← gunnar/gunnar-installer-open:dhcp"), "{:?}", joined.missing);
        let e = format!("{:#}", r.check_config(cfg, "k", "here", None).unwrap_err());
        assert!(e.contains("CONFIG_VIRTIO_NET must be `y` for gunnar/gunnar-installer-open:dhcp"), "{e}");
        assert!(e.contains("the built .config has n"), "the refusal says what is there instead: {e}");
        // The repair is the CHAINED build, never the donor's own line: a
        // standalone donor build carries no requirer closure at all (lane AK,
        // from lane AH's B2 `mount … as xfs: ENODEV`).
        assert!(e.contains("dwarves buildthing gunnar --variant gunnar-installer-open --build kernel"), "{e}");
        assert_eq!(r.chained_build_cmds(), ["dwarves buildthing gunnar --variant gunnar-installer-open --build kernel"]);
        assert!(r.check_config("CONFIG_INET=y\nCONFIG_VIRTIO_NET=y\n", "k", "here", None).is_ok());
        let toml = joined.to_toml();
        assert!(toml.contains("[[symbol]]\nname = \"CONFIG_INET\"\nvalue = \"y\"\nclass = \"feature\""), "{toml}");
        assert!(toml.contains("class = \"kconfig-derived\""));
        assert!(toml.contains("missing = ["));
    }

    #[test]
    fn requirements_of_a_variant_follow_the_toggles_and_the_union_is_recursive_data() {
        use crate::installer::{InstallerBuild, KernelDonor};
        use crate::options::OptionSpec;
        let mut set = crate::variant::BuildVariantSet::default();
        set.installers.push(InstallerBuild {
            name: "inst".into(),
            options: vec![
                OptionSpec { name: "dhcp".into(), feature: "dhcp-client".into(), default: true, kernel_requires: dhcp(), ..Default::default() },
                OptionSpec {
                    name: "wireguard".into(),
                    feature: "wg-client".into(),
                    default: true,
                    kernel_requires: vec![KconfigReq::y("CONFIG_TUN", "boringtun")],
                    ..Default::default()
                },
            ],
            kernel_donor: Some(KernelDonor { repo: "tunnr".into(), kernel: "tunnr-kernel".into() }),
            ..Default::default()
        });
        let on = requirements_of_variant(&set, "gunnar", "inst", &BTreeMap::new()).unwrap().unwrap();
        assert!(on.rows.contains_key("CONFIG_TUN") && on.rows.contains_key("CONFIG_INET"));
        let mut t = BTreeMap::new();
        t.insert("wireguard".to_string(), false);
        let off = requirements_of_variant(&set, "gunnar", "inst", &t).unwrap().unwrap();
        assert!(!off.rows.contains_key("CONFIG_TUN"), "wireguard=off asks nothing of TUN");
        assert!(off.rows.contains_key("CONFIG_INET"));
        // Union: two consumers of one kernel, both recorded on the symbol.
        let mut u = off.clone();
        let mut other = KernelRequirements::default();
        other.require("holger/h", "dhcp", &KconfigReq::y("CONFIG_INET", "also"));
        u.union(&other);
        // THREE requirers now, and that is the union working: gunnar's own
        // `dhcp` option, gunnar's appliance-boot contract (PID 1's own netlink
        // + UDP narration — lane AE, 2026-08-30) and holger's `dhcp`.
        assert_eq!(u.rows["CONFIG_INET"].len(), 3);
        assert!(u.summary().contains("gunnar/inst: appliance-boot · dhcp") && u.summary().contains("holger/h: dhcp"), "{}", u.summary());
        // No kernel edge → nothing to require.
        set.installers[0].kernel_donor = None;
        assert!(requirements_of_variant(&set, "gunnar", "inst", &BTreeMap::new()).unwrap().is_none());
        // A toml row round-trips through serde with the string value.
        let r: KconfigReq = toml::from_str("symbol = \"CONFIG_INET\"\nvalue = \"y\"\nreason = \"x\"").unwrap();
        assert_eq!(r.value, KconfigValue::Y);
        assert!(KconfigReq::y("INET", "x").validate("o").is_err());
    }

    /// `clock_requires` (moved from gunnar-recipe, gunnar-live 50c3e69): the four
    /// paravirt wall-clock symbols on edge `kernel`, every one `=y`, and none of
    /// them vetoed by the `nuked` preset the appliance donor `tunnr/tunnr-kernel`
    /// starts from — so a consumer declaring it MERGES instead of being refused.
    #[test]
    fn clock_requires_is_the_paravirt_wall_clock_on_the_kernel_edge_and_nuked_grants_it() {
        use crate::requires::Requirement;
        let rows = clock_requires();
        let syms: Vec<&str> = rows
            .iter()
            .map(|r| {
                assert_eq!(r.edge, "kernel");
                match &r.what {
                    Requirement::Kconfig(k) => {
                        assert_eq!(k.value, KconfigValue::Y, "{}", k.symbol);
                        k.symbol.as_str()
                    }
                    other => panic!("not a Kconfig row: {other:?}"),
                }
            })
            .collect();
        assert_eq!(syms, ["CONFIG_HYPERVISOR_GUEST", "CONFIG_PARAVIRT", "CONFIG_KVM_GUEST", "CONFIG_PARAVIRT_CLOCK"]);
        let nuked = merge_kconfig(&[TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT, NUKED_FRAGMENT]);
        for s in &syms {
            assert!(!nuked.contains(&format!("# {s} is not set")), "nuked vetoes {s}");
        }
    }

    /// ★ Gunnar 35290f29 / b247bf64 (2026-09-15), shared (lane G10): `hotplug_requires`,
    /// moved AS IS from gunnar-recipe — the six PCI hot-plug symbols on edge `kernel`, all
    /// `=y`, and none vetoed by `nuked`, so every installer row that declares it MERGES.
    /// Without them a runtime grow (detach → resize → re-attach) answers 511.
    #[test]
    fn hotplug_requires_is_pci_hotplug_on_the_kernel_edge_and_nuked_grants_it() {
        use crate::requires::Requirement;
        let syms: Vec<String> = hotplug_requires()
            .iter()
            .map(|r| {
                assert_eq!(r.edge, "kernel");
                match &r.what {
                    Requirement::Kconfig(k) => {
                        assert_eq!(k.value, KconfigValue::Y, "{}", k.symbol);
                        k.validate("hotplug").unwrap();
                        k.symbol.clone()
                    }
                    other => panic!("not a Kconfig row: {other:?}"),
                }
            })
            .collect();
        assert_eq!(syms, ["CONFIG_HOTPLUG_PCI", "CONFIG_HOTPLUG_PCI_ACPI", "CONFIG_PCIEPORTBUS", "CONFIG_HOTPLUG_PCI_PCIE", "CONFIG_HOTPLUG_PCI_SHPC", "CONFIG_PCI_MSI"]);
        let nuked = merge_kconfig(&[TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT, NUKED_FRAGMENT]);
        for s in &syms {
            assert!(!nuked.contains(&format!("# {s} is not set")), "nuked vetoes {s}");
        }
    }

    /// ★ Gunnar 09fe2c48 (2026-09-23), shared (lane G10): **a medium that cannot read its
    /// own CD is refused, not recorded.** Judged on the kernel's BUILT `.config` (Gunnar
    /// read the donor's fragment file in the sibling tunnr checkout, which is not always
    /// the kernel that rides the medium). The block cannot be a consumer requirement:
    /// `nuked` vetoes ATA/SCSI and a requirement is never merged over a veto; the donor
    /// carries the block in its own fragment (tunnr 939dbe3 / 3f7c2ff).
    #[test]
    fn a_kernel_that_cannot_read_its_own_cd_is_refused_by_name() {
        let with = "CONFIG_ATA=y\nCONFIG_ATA_SFF=y\nCONFIG_ATA_PIIX=y\nCONFIG_SATA_AHCI=y\nCONFIG_SCSI=y\nCONFIG_BLK_DEV_SR=y\n";
        assert!(missing_cd_block(with).is_empty());
        let ok = refuse_unless_reads_its_cd(Some(with), "holger-installer", "tunnr/tunnr-kernel").expect("the block is there");
        assert!(ok.contains("CONFIG_BLK_DEV_SR=y"), "{ok}");
        let partial = "CONFIG_ATA=y\n# CONFIG_SATA_AHCI is not set\nCONFIG_SCSI=y\nCONFIG_BLK_DEV_SR=m\n";
        assert_eq!(missing_cd_block(partial), vec!["CONFIG_ATA_PIIX", "CONFIG_SATA_AHCI", "CONFIG_BLK_DEV_SR"]);
        let e = refuse_unless_reads_its_cd(Some(partial), "gunnar-installer-metered", "tunnr/tunnr-kernel").unwrap_err().to_string();
        assert!(e.starts_with("REFUSED [medium-no-cd-driver]"), "{e}");
        assert!(e.contains("gunnar-installer-metered") && e.contains("tunnr/tunnr-kernel") && e.contains("CONFIG_SATA_AHCI") && e.contains("939dbe3"), "{e}");
        let e = refuse_unless_reads_its_cd(None, "m", "k").unwrap_err().to_string();
        assert!(e.contains("[medium-no-cd-driver]") && e.contains("no .config"), "an unproven kernel refuses too: {e}");
        // The block is the donor's, never a requirement: nuked vetoes ATA and SCSI.
        let nuked = merge_kconfig(&[TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT, NUKED_FRAGMENT]);
        assert!(nuked.contains("# CONFIG_ATA is not set"));
    }
}