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

//! **The recipe format's compatibility rule** — how one file survives five
//! readers at four pin generations.
//!
//! # The measurement this module exists for (2026-08-30, lane AJ)
//!
//! `.nornir/build-variants.toml` is read by more than one program, at more than
//! one version, by construction: the FORGE (`dwarves` CLI and the ⚒ pane) reads
//! every repo's recipe with ITS pin, and each repo's own `<repo>-build-thing`
//! crate — and that crate's `toml_parity` test — reads the same file with
//! THEIRS. Nothing compared them, and both possible failures had already
//! happened to ONE file, gunnar's:
//!
//! ```text
//! nornir-build-thing 0.1.10 (published) reading gunnar's committed recipe:
//!
//!   REFUSED   unknown field `kernel_requires`, expected one of `name`, `label`,
//!             `default`, `feature`, `params_when_on`, `params_when_off`,
//!             `param_kinds`, `forbid_symbols`, `bakes`
//!             …at [[build.iso.options.kernel_requires]], line 1685 — the WHOLE
//!             recipe unloadable for one additive key (`OptionSpec` is
//!             `deny_unknown_fields`). Lane AE had to invent a side table.
//!
//!   and with that key removed so the load can finish, the SAME reader:
//!
//!   DROPS     build.installer.0.data     build.installer.0.target
//!             build.installer.1.data     build.installer.1.target
//!             …silently — `InstallerBuild` is NOT `deny_unknown_fields`, so two
//!             whole groups (the machine, and what /data is laid with) went four
//!             generations unread while `tests/toml_parity.rs` was GREEN. It was
//!             green because both sides of the comparison were the same loader:
//!             a mirror compared against a reader that discards what it cannot
//!             parse agrees about nothing, loudly.
//! ```
//!
//! Two failure shapes, one file, one reader version. **Neither of them is
//! fixable by choosing between them**: turning `deny_unknown_fields` off
//! everywhere converts the loud one into four more quiet ones, and turning it on
//! everywhere makes every additive key a repo-wide outage. So the rule is three
//! parts, and each part owns exactly one of the three ways a key can go wrong.
//!
//! # THE RULE
//!
//! **R1 — the recipe declares the schema it needs, and the WRITER computes it.**
//! A root-level `[nornir-build-thing] schema = N` table, OUTSIDE `[build]`. Root
//! level is not a taste: [`BuildVariantSet`] is `deny_unknown_fields` and
//! [`BuildConfig`](crate::variant::BuildConfig) is not, so the root is the one
//! place in this format that **every published generation ignores instead of
//! refusing** — measured across 0.1.3 · 0.1.4 · 0.1.7 · 0.1.8 · 0.1.10 · 0.1.11.
//! A key inside `[build]` would have made the version marker itself the next
//! outage. `N` comes from [`required_by`], derived from what the set actually
//! populates — never hand-set, never bumped by an emission that says nothing
//! new. A recipe at the floor stays readable by every reader that ever existed.
//!
//! **R2 — a reader that is too old refuses BY NAME, before serde.**
//! [`check_readable`] compares the declared `N` against [`RECIPE_SCHEMA`] and
//! says which version to upgrade to and which declaration asked for it. Never a
//! serde error naming a field the reader has never heard of; never a line
//! number in a file the operator did not write.
//!
//! **R3 — at a schema it DOES understand, a reader that drops a key fails.**
//! [`dropped_keys`] reports every `build.*` path serde ignored, via
//! `serde_ignored` — one deserialize, not a second parser (LAW 5). Zero is the
//! law, enforced inside
//! [`BuildVariantSet::from_toml_str`](crate::variant::BuildVariantSet::from_toml_str)
//! itself, so the DISCARD cannot happen behind a green test again. This is the
//! half `deny_unknown_fields` was standing in for on the closed types, and the
//! half NOTHING was standing in for on the open ones — `InstallerBuild`,
//! `IsoBuild`, `ContainerBuild` — which is exactly where the four dropped paths
//! above lived.
//!
//! `deny_unknown_fields` **stays** on the types that carry it. Under R1+R2 a
//! reader only ever reaches serde with a file at a schema it understands, and at
//! that point an unknown key is a typo or a hand-edit — the code is the source
//! and the toml is emitted — and both of those must be loud.
//!
//! # What each failure shape now costs
//!
//! | the file says | this reader | what happens |
//! |---|---|---|
//! | schema > mine | too old | R2: named refusal + the version to upgrade to |
//! | schema ≤ mine, key unknown to a CLOSED type | any | serde: named refusal (a typo) |
//! | schema ≤ mine, key unknown to an OPEN type | any | R3: named refusal, listing every dropped path |
//! | schema ≤ mine, nothing unknown | any | loads, and has read every declared byte |
//!
//! None of the four is silence. That is the whole property.
//!
//! # The honest limit
//!
//! A reader that is ALREADY PUBLISHED cannot be taught R2 — nornir-build-thing
//! 0.1.10 will keep answering `unknown field kernel_requires` until nothing runs
//! it. What R1 buys immediately is that the file RECORDS what it needs, and what
//! R3 buys immediately is that the drop is detectable from the new side TODAY
//! (`nornir-build-thing-skew`, which links an old reader and a new one in one
//! process and names the disagreement). Every reader from this version on
//! refuses by name; the ones behind us are caught by the guard instead.
//!
//! MEASURED 2026-09-05, which is what that limit costs when it lands on a
//! person rather than on a guard. `MediumBoot::Gpt` shipped at 03:15 and
//! gunnar's declaration adopted it at 03:16. The forge installed at
//! `~/.local/bin/dwarves` had been built on 2026-09-02 — hours before 0.1.12
//! reached the registry — so it linked a reader with no schema code at all, and
//! `dwarves buildthing gunnar --variant gunnar-appliance-iso` answered
//!
//! ```text
//! unknown variant `gpt`, expected `el-torito` or `hybrid`
//! ```
//!
//! at line 912, column 15. The declaration was carrying `schema = 2` and
//! [`FIRST_READER`] maps that to `0.1.12`, so the named refusal this module
//! exists to produce — "upgrade to >= 0.1.12" — was available and unreachable:
//! the code that would have said it ships IN the version being asked for. The
//! whole gunnar ISO/kernel door was shut, and the operator's only clue was a
//! serde field error about a word they had just correctly written.
//!
//! `nornir-build-thing-skew` does not catch this one either. It links an old
//! reader and a new one in one process, so it sees a drop the moment BOTH are
//! in a build graph — but a stale BINARY on someone's PATH is in no build
//! graph. The residual exposure is exactly that: an installed forge older than
//! the schema it is asked to read. There is no version of this module that can
//! close it, and rebuilding the forge is the only remedy.

use std::collections::BTreeSet;

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

use crate::variant::{BuildConfig, BuildVariantSet};

/// **The schema this reader understands.** Bump it in the same commit that adds
/// a key `required_by` can ask for, and add the row to [`FIRST_READER`].
pub const RECIPE_SCHEMA: u32 = 6;

/// **What an ABSENT header means.** Every recipe written before 2026-08-30 —
/// which is every recipe in the constellation but one — carries no header and is
/// schema 1, readable by every generation back to 0.1.3. A file only grows a
/// header when it needs more than this.
pub const RECIPE_SCHEMA_FLOOR: u32 = 1;

/// The root table the header lives in. Hyphenated, because it names the CRATE.
pub const SCHEMA_TABLE: &str = "nornir-build-thing";

/// **The first published `nornir-build-thing` that understands each schema** —
/// what R2's refusal tells the operator to upgrade to.
///
/// Schema 1 is 0.1.3 because that is the oldest copy any lockfile in the
/// constellation still resolves (znippy's, measured 2026-08-30).
///
/// Schema 2 is **0.1.12 and not 0.1.11**, and that gap is a finding, not a
/// rounding: the PUBLISHED 0.1.11 has neither `target`/`data` on
/// [`InstallerBuild`](crate::installer::InstallerBuild) nor `kernel_requires` on
/// [`OptionSpec`](crate::options::OptionSpec) — the vocabulary lanes AE and AF
/// added lives only in edda's tree, under the same version number. Two crates,
/// one version, and `nornir`'s lockfile holds BOTH (one path, one registry) with
/// nothing able to tell them apart. Naming 0.1.11 here would make the refusal
/// message a lie, which is the one thing a refusal that names the fix may never
/// be. Hence this crate is 0.1.12.
/// Schema 3 is 0.1.13: the `package` VOCABULARY —
/// `[[build.package]]` ([`PackageBuild`](crate::package::PackageBuild)) and the
/// `kind = "package"` a variant declares to point at one. It is a schema bump and
/// not a quiet addition for the reason [`BuildVariantSet`] is
/// `deny_unknown_fields`: a 0.1.12 reader handed a recipe with a `[[build.package]]`
/// table refuses the WHOLE FILE with `unknown field \`package\``, at a line number
/// in a file the operator did not write, naming a word they spelled correctly.
/// That is exactly the failure R2 exists to convert into "upgrade
/// nornir-build-thing to >= 0.1.13, because facett's recipe declares
/// [[build.package]]" — and it only works if the bump happens in the same commit
/// as the key. The header is emitted only for a set that actually declares one, so
/// every recipe in the constellation that has no package stays at the floor and
/// keeps loading in every reader back to 0.1.3.
/// Schema 4 is 0.1.14: the `wasm` CELL — `[[build.wasm]]`
/// ([`WasmBuild`](crate::wasm::WasmBuild)) and its typed post-build stages. Same
/// reasoning as schema 3: `BuildVariantSet` is `deny_unknown_fields`, so a 0.1.13
/// reader handed a recipe with a `[[build.wasm]]` table refuses the WHOLE file with
/// `unknown field \`wasm\``, and R2 exists to turn that into "upgrade
/// nornir-build-thing to >= 0.1.14". A recipe whose wasm variants declare no cell —
/// which is every wasm variant written before 2026-09-06 — asks for nothing and
/// stays exactly where it was.
/// Schema 5 is 0.1.24: `sign = { purpose = … }` on a variant
/// ([`SignSpec`](crate::sign::SignSpec)) and `[[build.web_app]]`
/// ([`WebAppBuild`](crate::web_app::WebAppBuild)). Both land in
/// `deny_unknown_fields` structs, so a 0.1.23 reader refuses the whole file on
/// either; R2 turns that into "upgrade to >= 0.1.24".
/// Schema 6 is the release after 0.1.24: `siblings = [...]` on a variant row
/// ([`BuildVariant::siblings`](crate::variant::BuildVariant::siblings)), the
/// row's own sibling checkouts. `BuildVariant` is `deny_unknown_fields`, so a
/// 0.1.24 reader refuses the whole file on it; R2 names the upgrade instead.
pub const FIRST_READER: &[(u32, &str)] =
    &[(1, "0.1.3"), (2, "0.1.12"), (3, "0.1.13"), (4, "0.1.14"), (5, "0.1.24"), (6, "0.1.25")];

/// The version string R2 points at for `schema`, or the newest known.
pub fn first_reader_of(schema: u32) -> &'static str {
    FIRST_READER
        .iter()
        .find(|(n, _)| *n == schema)
        .map(|(_, v)| *v)
        .unwrap_or("a newer nornir-build-thing than any this reader knows")
}

/// **The root header.** Its own struct so it can be a real field of
/// [`BuildConfig`](crate::variant::BuildConfig) rather than a key R3 would then
/// report as dropped — the version marker may not be the first thing the drop
/// detector trips over.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SchemaHeader {
    /// The schema level this recipe needs a reader to understand.
    pub schema: u32,
}

/// The root header as it is written into an emission, comment and all.
pub fn header_toml(schema: u32) -> String {
    format!(
        "# The schema this recipe NEEDS a reader to understand. Computed by the\n\
         # emitter from what the recipe declares (nornir_build_thing::schema::\n\
         # required_by) — never hand-set. A reader that understands less refuses\n\
         # BY NAME and says which version to upgrade to; it does not drop rows.\n\
         [{SCHEMA_TABLE}]\n\
         schema = {schema}\n"
    )
}

/// **Read the declared schema without deserializing the recipe** — so an
/// unreadable file is refused by R2's message and never by serde's.
///
/// A partial parse into `toml::Value` on purpose: the whole point is to answer
/// "can I read this" for a file whose `[build]` subtree this reader may not
/// understand, and a struct-shaped parse could not do that.
pub fn declared_schema(text: &str) -> Result<u32> {
    let doc: toml::Value = toml::from_str(text)
        .map_err(|e| anyhow::anyhow!("{e}"))
        .map_err(|e| anyhow::anyhow!("the recipe is not valid TOML at all: {e}"))?;
    let Some(table) = doc.get(SCHEMA_TABLE) else {
        return Ok(RECIPE_SCHEMA_FLOOR);
    };
    let Some(n) = table.get("schema").and_then(toml::Value::as_integer) else {
        bail!(
            "[{SCHEMA_TABLE}] is present but declares no integer `schema` — a header that \
             cannot be read is worse than none, because a reader that skips it believes \
             the file is schema {RECIPE_SCHEMA_FLOOR}"
        );
    };
    if n < 1 {
        bail!("[{SCHEMA_TABLE}] schema = {n}: schemas start at {RECIPE_SCHEMA_FLOOR}");
    }
    Ok(n as u32)
}

/// **THE STALE READER, as a value.** R2's finding before it is a sentence: the
/// recipe's level, this reader's level, this reader's version, and the version to
/// upgrade to.
///
/// It exists as a TYPE and not only as an `Err` because of what the four seats of
/// 2026-09-05/06 measured. Every one of them met this exact condition, and every
/// one of them met it as a string already wrapped in somebody else's context —
/// `facett: build-variants.toml could not be parsed: …`, which names a PARSE
/// failure for a file that parses perfectly. A caller that wants to say "your forge
/// is older than this recipe; rebuild it" cannot get there by matching on prose, so
/// it never said it: the operator read "could not be parsed" and went to look at
/// the recipe. A refusal that sends its reader to the wrong file is barely better
/// than silence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StaleReader {
    /// The schema the RECIPE declares.
    pub declared: u32,
    /// [`RECIPE_SCHEMA`] — the newest schema this reader understands.
    pub reader_schema: u32,
    /// `CARGO_PKG_VERSION` of this reader.
    pub reader_version: &'static str,
    /// The first published `nornir-build-thing` that understands `declared`.
    pub upgrade_to: &'static str,
}

impl StaleReader {
    /// The refusal, in the one wording every door states it in.
    ///
    /// It names BOTH versions and the FIX, and it says out loud that the recipe is
    /// not the problem — because the message it replaces sent four people to read a
    /// file that was correct.
    #[must_use]
    pub fn sentence(&self) -> String {
        format!(
            "this recipe is schema {}; this nornir-build-thing ({}) understands up to \
             schema {} — upgrade nornir-build-thing to >= {}. \
             (The recipe is not malformed and nothing in it is a typo: it declares \
             vocabulary that arrived after this reader's pin. See edda \
             .nornir/build-design.md, 'the compatibility rule'.)",
            self.declared, self.reader_version, self.reader_schema, self.upgrade_to,
        )
    }
}

/// **Is THIS reader older than the recipe in `text`?**
///
/// `None` for every other outcome, including a header that cannot be read at all —
/// that is [`declared_schema`]'s refusal, a different problem with a different fix.
/// This answers exactly one question, so a caller can route on it without parsing
/// prose.
#[must_use]
pub fn stale_reader(text: &str) -> Option<StaleReader> {
    let declared = declared_schema(text).ok()?;
    (declared > RECIPE_SCHEMA).then(|| StaleReader {
        declared,
        reader_schema: RECIPE_SCHEMA,
        reader_version: env!("CARGO_PKG_VERSION"),
        upgrade_to: first_reader_of(declared),
    })
}

/// [`stale_reader`] asked of a FILE, without loading it — the cheap gate a door can
/// run BEFORE it commits to anything, which is the half R2 was missing: the
/// sentence existed, and it only ever arrived from inside a parse the caller had
/// already labelled something else.
///
/// A path that cannot be read is `None`: "the file is not there" is the caller's
/// own refusal to make, and answering it here would be this function pretending to
/// know two things.
#[must_use]
pub fn stale_reader_at(path: &std::path::Path) -> Option<StaleReader> {
    stale_reader(&std::fs::read_to_string(path).ok()?)
}

/// **R2.** `Ok(n)` when this reader can read the file; a refusal that names the
/// fix when it cannot.
pub fn check_readable(text: &str) -> Result<u32> {
    let n = declared_schema(text)?;
    if let Some(stale) = stale_reader(text) {
        bail!("{}", stale.sentence());
    }
    Ok(n)
}

/// **R1 — the schema this SET needs, and the declarations that ask for it.**
///
/// Derived from content, so an emission that says nothing new stays at the floor
/// and every reader back to 0.1.3 keeps loading it. Each arm names the exact
/// declaration, because that string is what R2's operator reads when a build box
/// refuses a recipe it used to accept.
pub fn required_by(set: &BuildVariantSet) -> (u32, Vec<String>) {
    let mut why: Vec<String> = Vec::new();
    // The HIGHEST schema anything in this set asks for. A running max and not a
    // constant, because with three levels "some declaration is past the floor"
    // stopped being the same question as "which level": a set carrying both an
    // installer `target` (schema 2) and a package cell (schema 3) must emit 3, and
    // the version that answered a flat 2 for anything non-floor would have written
    // a header that reads back as a promise the file cannot keep.
    let mut need = RECIPE_SCHEMA_FLOOR;

    for i in &set.installers {
        if !crate::target::Target::is_default(&i.target) {
            why.push(format!("[build.installer.target] on '{}'", i.name));
        }
        if !crate::data::Data::is_default(&i.data) {
            why.push(format!("[build.installer.data] on '{}'", i.name));
        }
        if !i.medium_boot.is_default() {
            why.push(format!("`medium_boot = \"{}\"` on '{}'", i.medium_boot.as_str(), i.name));
        }
        for o in &i.options {
            if !o.kernel_requires.is_empty() {
                why.push(format!(
                    "`kernel_requires` on option '{}' of installer '{}'",
                    o.name, i.name
                ));
            }
            if !o.requires.is_empty() {
                why.push(format!(
                    "`requires` on option '{}' of installer '{}'",
                    o.name, i.name
                ));
            }
        }
        if !i.requires.is_empty() {
            why.push(format!("[[build.installer.requires]] on '{}'", i.name));
        }
    }
    for iso in &set.isos {
        for o in &iso.options {
            if !o.kernel_requires.is_empty() {
                why.push(format!(
                    "`kernel_requires` on option '{}' of iso '{}'",
                    o.name, iso.name
                ));
            }
            if !o.requires.is_empty() {
                why.push(format!("`requires` on option '{}' of iso '{}'", o.name, iso.name));
            }
        }
    }

    if !why.is_empty() {
        need = need.max(2);
    }

    // ── schema 6 — a row's own sibling checkouts ─────────────────────────────
    for v in &set.variants {
        if !v.siblings.is_empty() {
            why.push(format!("`siblings = [{}]` on '{}'", v.siblings.join(", "), v.name));
            need = need.max(6);
        }
    }

    // ── schema 5 — signed variants and browser web apps ──────────────────────
    for v in &set.variants {
        if let Some(s) = &v.sign {
            why.push(format!("`sign = {{ purpose = \"{}\" }}` on '{}'", s.purpose.as_str(), v.name));
            need = need.max(5);
        }
    }
    for w in &set.web_apps {
        why.push(format!("[[build.web_app]] '{}'", w.name));
        need = need.max(5);
    }

    // ── schema 4 — the wasm cell ─────────────────────────────────────────────
    // Named per CELL, like the package rows below: an operator reading a refusal
    // off a build box needs to know WHICH declaration asked for a newer reader.
    for w in &set.wasms {
        why.push(format!("[[build.wasm]] '{}'", w.name));
        need = need.max(4);
    }

    // ── schema 3 — the package vocabulary ────────────────────────────────────
    // Named per CELL, not once for the set: R2's operator reads this string off a
    // build box that just refused a recipe, and "a package cell" would leave them
    // to find which one.
    for p in &set.packages {
        why.push(format!("[[build.package]] '{}'", p.name));
        need = need.max(3);
    }
    // A `kind = "package"` variant is the same vocabulary reached by the other
    // spelling, and it asks for the same reader even in the (invalid, but
    // emittable) case where the cell is absent — `validate_kind` refuses that, and
    // an emission is not the place to discover it.
    if set
        .variants
        .iter()
        .any(|v| v.kind == crate::variant::ArtifactKind::Package)
        && set.packages.is_empty()
    {
        why.push("`kind = \"package\"` on a variant".to_string());
        need = need.max(3);
    }

    if why.is_empty() {
        (RECIPE_SCHEMA_FLOOR, why)
    } else {
        (need, why)
    }
}

/// **R3 — every `build.*` key path this reader IGNORED.**
///
/// Scoped to the `build` subtree deliberately. [`BuildConfig`] parses the same
/// `[build]` schema whether it stands alone or is embedded in a `nornir.toml`
/// full of other root tables (`[repo]`, `[docs]`, …), so the ROOT is open on
/// purpose and its extra keys are not this format's business. The recipe is the
/// `[build]` subtree, and inside it there is no such thing as a key that is
/// somebody else's.
///
/// One deserialize through `serde_ignored`, not a second parser walking
/// `toml::Value` beside the real one — that second walk is precisely the twin
/// that let `allow_unbuilt_bins` live five months without a schema
/// (`variant.rs`, `buildgate::read_allowlist`).
pub fn dropped_keys(text: &str) -> Result<Vec<String>> {
    let mut seen: BTreeSet<String> = BTreeSet::new();
    // toml >= 0.9: building the deserializer parses, so it can fail here.
    let de = toml::Deserializer::parse(text).map_err(|e| anyhow::anyhow!("{e}"))?;
    let _cfg: BuildConfig = serde_ignored::deserialize(de, |path| {
        seen.insert(path.to_string());
    })
    .map_err(|e| anyhow::anyhow!("{e}"))?;
    Ok(seen.into_iter().filter(|p| is_recipe_path(p)).collect())
}

/// Is this ignored path inside the recipe (the `[build]` subtree)?
fn is_recipe_path(path: &str) -> bool {
    path == "build" || path.starts_with("build.")
}

/// The refusal R3 raises. Split out so the loader states it once and the guard
/// can render the same sentence without loading anything.
pub fn refuse_dropped(path_hint: &str, dropped: &[String]) -> Result<()> {
    if dropped.is_empty() {
        return Ok(());
    }
    bail!(
        "this reader (nornir-build-thing {}, schema {RECIPE_SCHEMA}) would SILENTLY DROP {} \
         declaration{} from {path_hint}:\n  {}\n\
         A recipe key this reader cannot parse is not a shrug — it is a group of the build \
         that no artifact will carry, and a `toml_parity` mirror written against the dropped \
         value agrees with the drop. Either the writer is newer than this reader (raise the \
         `nornir-build-thing` pin, and the recipe should have declared a [{SCHEMA_TABLE}] \
         schema saying so), or the key is a hand-edit of an EMITTED file, which the code-is-\
         the-source law forbids: change the recipe in Rust and re-emit.",
        env!("CARGO_PKG_VERSION"),
        dropped.len(),
        if dropped.len() == 1 { "" } else { "s" },
        dropped.join("\n  "),
    )
}

/// **What a reader can say about a recipe without agreeing to load it.**
///
/// The guard's unit of comparison: two readers produce two of these over the
/// same bytes, and the disagreement is a field-by-field diff rather than "one of
/// them panicked".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecipeAudit {
    /// The schema the file declares (the floor when it carries no header).
    pub declared: Option<u32>,
    /// [`RECIPE_SCHEMA`] of the reader that produced this audit.
    pub reader_schema: u32,
    /// `CARGO_PKG_VERSION` of the reader that produced this audit.
    pub reader_version: &'static str,
    /// Every `build.*` path this reader ignored — R3's answer. Empty is the law.
    pub dropped: Vec<String>,
    /// The refusal, when this reader cannot load the file at all.
    pub refused: Option<String>,
}

impl RecipeAudit {
    /// This reader's honest reading of `text`.
    pub fn of(text: &str) -> Self {
        let declared = declared_schema(text).ok();
        let mut audit = RecipeAudit {
            declared,
            reader_schema: RECIPE_SCHEMA,
            reader_version: env!("CARGO_PKG_VERSION"),
            dropped: Vec::new(),
            refused: None,
        };
        if let Err(e) = check_readable(text) {
            audit.refused = Some(format!("{e:#}"));
            return audit;
        }
        match dropped_keys(text) {
            Ok(d) => audit.dropped = d,
            Err(e) => audit.refused = Some(format!("{e:#}")),
        }
        audit
    }

    /// Did this reader read every declared byte of the recipe?
    pub fn is_faithful(&self) -> bool {
        self.refused.is_none() && self.dropped.is_empty()
    }
}

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

    /// The smallest recipe that is a recipe.
    const FLOOR: &str = r#"
[build]
offers = ["gui"]

[[build.variant]]
name = "app"
features = ["gui"]
"#;

    #[test]
    fn a_recipe_with_no_header_is_the_floor_and_every_reader_may_read_it() {
        assert_eq!(declared_schema(FLOOR).unwrap(), RECIPE_SCHEMA_FLOOR);
        assert_eq!(check_readable(FLOOR).unwrap(), RECIPE_SCHEMA_FLOOR);
        assert!(dropped_keys(FLOOR).unwrap().is_empty());
    }

    /// R2, and the half of it that matters: the message names the FIX.
    #[test]
    fn a_recipe_from_the_future_is_refused_by_name_and_told_what_to_upgrade_to() {
        let future = format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA + 7));
        let err = check_readable(&future).expect_err("a schema this reader cannot read");
        let msg = format!("{err:#}");
        assert!(
            msg.contains(&format!("schema {}", RECIPE_SCHEMA + 7)),
            "the refusal must name the schema the RECIPE declares: {msg}"
        );
        assert!(
            msg.contains("upgrade nornir-build-thing"),
            "the refusal must name the fix, not the field: {msg}"
        );
        assert!(
            !msg.contains("unknown field"),
            "R2 exists precisely so this is NOT a serde field error: {msg}"
        );
    }

    /// **R2 as a VALUE.** The four seats of 2026-09-05/06 all met this condition
    /// and none of them could route on it: the finding only existed as prose,
    /// already wrapped in a caller's "could not be parsed". A door that wants to
    /// say "rebuild the forge" needs the two versions, not a string to grep.
    #[test]
    fn the_stale_reader_is_a_value_a_door_can_route_on() {
        let future = format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA + 1));
        let stale = stale_reader(&future).expect("this reader is older than that recipe");
        assert_eq!(stale.declared, RECIPE_SCHEMA + 1);
        assert_eq!(stale.reader_schema, RECIPE_SCHEMA);
        assert_eq!(stale.reader_version, env!("CARGO_PKG_VERSION"));
        // Both versions and the fix, in the sentence itself.
        let msg = stale.sentence();
        assert!(msg.contains(env!("CARGO_PKG_VERSION")), "{msg}");
        assert!(msg.contains("upgrade nornir-build-thing"), "{msg}");
        // R2's refusal IS this sentence — one wording, not two that can drift.
        let err = format!("{:#}", check_readable(&future).expect_err("refused"));
        assert_eq!(err, msg);

        // Everything else is None, including a header that cannot be read: that is
        // a different refusal with a different fix.
        assert!(stale_reader(FLOOR).is_none());
        assert!(stale_reader(&format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA))).is_none());
        assert!(stale_reader(&format!("[{SCHEMA_TABLE}]\nschema = \"two\"\n{FLOOR}")).is_none());
    }

    /// The FILE half — the gate a door runs before it commits to anything.
    #[test]
    fn a_door_can_ask_a_path_whether_this_reader_is_too_old_for_it() {
        let dir = std::env::temp_dir().join(format!("nbt-stale-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("build-variants.toml");
        std::fs::write(&path, format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA + 3))).unwrap();
        let stale = stale_reader_at(&path).expect("the file is from the future");
        assert_eq!(stale.declared, RECIPE_SCHEMA + 3);
        std::fs::write(&path, FLOOR).unwrap();
        assert!(stale_reader_at(&path).is_none(), "a floor recipe is readable by everything");
        assert!(
            stale_reader_at(&dir.join("nope.toml")).is_none(),
            "an absent file is the CALLER's refusal to make, not this one's"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// A header at or below the reader's level is not in the way.
    #[test]
    fn a_recipe_at_this_readers_schema_loads_and_the_header_is_not_a_dropped_key() {
        let here = format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA));
        assert_eq!(check_readable(&here).unwrap(), RECIPE_SCHEMA);
        assert!(
            dropped_keys(&here).unwrap().is_empty(),
            "the version marker may not be the first thing the drop detector trips over"
        );
        let set = BuildVariantSet::from_toml_str(&here).expect("loads");
        assert_eq!(set.variants.len(), 1);
    }

    /// **R3, seen RED by construction.**
    ///
    /// `InstallerBuild` is not `deny_unknown_fields` — that is not an oversight,
    /// it is what let `[build.installer.fixtures]`, `.target` and `.data` be
    /// added at all — so serde takes this file happily and throws the group
    /// away. Before this module, THAT was the whole story and the loader
    /// returned `Ok`. Delete the `refuse_dropped` call in `from_toml_str` and
    /// this test goes green while the declaration is gone.
    #[test]
    fn a_group_an_open_type_would_swallow_is_named_and_refused() {
        let text = r#"
[build]

[[build.installer]]
name = "appliance"
variant = "app"

[build.installer.made_up_group]
fs = "xfs"
"#;
        let dropped = dropped_keys(text).expect("serde takes the file");
        assert_eq!(
            dropped,
            vec!["build.installer.0.made_up_group".to_string()],
            "the drop must be reported BY PATH — 'something was dropped' is the \
             silence this module exists to end"
        );
        let err = BuildVariantSet::from_toml_str(text)
            .expect_err("the loader must refuse a recipe it cannot read in full");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("build.installer.0.made_up_group"),
            "the refusal must carry the path: {msg}"
        );
        assert!(
            msg.contains("SILENTLY DROP"),
            "and must say what it is refusing to do: {msg}"
        );
    }

    /// The measured 2026-08-30 shape, in miniature: the two groups nornir-build-
    /// thing 0.1.10 swallowed out of gunnar's recipe were spelled EXACTLY like
    /// this, and `tests/toml_parity.rs` was green over them.
    #[test]
    fn the_2026_08_30_swallowed_groups_are_what_this_test_is_shaped_like() {
        for group in ["target", "data"] {
            let text = format!(
                "[build]\n\n[[build.installer]]\nname = \"gunnar-installer\"\nvariant = \"app\"\n\n\
                 [build.installer.{group}_typo]\nfs = \"xfs\"\n"
            );
            let dropped = dropped_keys(&text).unwrap();
            assert_eq!(dropped, vec![format!("build.installer.0.{group}_typo")]);
        }
    }

    /// R1: content decides, not the writer's mood.
    #[test]
    fn the_schema_a_set_needs_is_derived_from_what_it_declares() {
        let plain = BuildVariantSet::from_toml_str(FLOOR).unwrap();
        let (n, why) = required_by(&plain);
        assert_eq!(n, RECIPE_SCHEMA_FLOOR);
        assert!(why.is_empty(), "nothing in a plain cargo recipe asks for more: {why:?}");

        let declaring = BuildVariantSet::from_toml_str(
            r#"
[build]

[[build.installer]]
name = "appliance"
variant = "app"

[build.installer.data]
fs = "xfs"
"#,
        )
        .unwrap();
        let (n, why) = required_by(&declaring);
        assert_eq!(n, 2, "a declared [build.installer.data] needs schema 2");
        assert_eq!(why, vec!["[build.installer.data] on 'appliance'".to_string()]);
        assert_eq!(
            first_reader_of(n),
            "0.1.12",
            "and 0.1.12 is the first PUBLISHED reader with the field — 0.1.11 is \
             published WITHOUT it, under the same version number as edda's tree"
        );
    }

    /// A malformed header is a refusal, not a shrug back to the floor: a reader
    /// that skipped it would believe the file is schema 1 and drop the very rows
    /// the header was written to protect.
    #[test]
    fn a_header_that_cannot_be_read_is_refused_rather_than_defaulted() {
        let text = format!("[{SCHEMA_TABLE}]\nschema = \"two\"\n{FLOOR}");
        let err = declared_schema(&text).expect_err("a non-integer schema");
        assert!(format!("{err:#}").contains("no integer `schema`"), "{err:#}");
    }

    /// The audit is what the cross-version guard compares.
    #[test]
    fn an_audit_says_who_read_it_and_whether_it_read_all_of_it() {
        let a = RecipeAudit::of(FLOOR);
        assert!(a.is_faithful());
        assert_eq!(a.reader_schema, RECIPE_SCHEMA);
        assert_eq!(a.reader_version, env!("CARGO_PKG_VERSION"));

        let future = format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA + 1));
        let b = RecipeAudit::of(&future);
        assert!(!b.is_faithful());
        assert_eq!(b.declared, Some(RECIPE_SCHEMA + 1));
        assert!(b.refused.is_some());
    }
}