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

//! **A build thing is FUNCTIONALITY, not a table.**
//!
//! The declarations started as data files (`<repo>/.nornir/build-variants.toml`) and
//! the obvious next step was to make them better data — a JSON the app emits. That is
//! wrong, and the reason is visible in this crate's own module list: `analyze`,
//! `assemble`, `container`, `image_build`, `provenance`, `selftest`, `feature_axis`.
//! Those are **verbs**. A pane's INVALID chip is per-app validity *logic*. None of it
//! serializes.
//!
//! So an app's build thing is a [`BuildThing`] implementation — code — living in a
//! crate the app depends on. The app compiles it in, so the app genuinely describes
//! **and validates** itself, and a missing build thing is a compile error rather than
//! a file nobody noticed was absent.
//!
//! # Why the forge loads WASM
//!
//! You cannot load functionality at runtime as data. If dwarves `dep:`ed every
//! buildspec crate it could only ever know the apps that existed at its own compile
//! time; adding an app would mean rebuilding the forge. So the same crate compiles to
//! `wasm32` as well as native — **one implementation, two link targets** — and the
//! forge loads the `.wasm` and *calls* it.
//!
//! That the two targets are one source is load-bearing, not incidental: a native impl
//! and a wasm impl that could drift would rot exactly the way the TOML did. The
//! buildspec crates therefore have no `cfg`-forked logic, and a test builds both.
//!
//! # Why a core module and no WASI
//!
//! The component model (`wasm32-wasip2` + WIT) is the more fashionable answer and was
//! considered. This uses a **core `wasm32-unknown-unknown` module** with the narrow
//! ABI in [`abi`], for one decisive reason: the security requirement is *the sandbox
//! is granted nothing*. A wasip2 component links WASI and you must then carefully
//! **deny** each capability — filesystem, sockets, clock, environment — and a mistake
//! is silent. A `wasm32-unknown-unknown` module has no imports to deny; it cannot
//! *name* a syscall. The guarantee is structural instead of configured.
//!
//! The ABI is hand-rolled but cannot drift: the guest side is a macro
//! ([`export_build_thing!`]) and the host side is [`PluginHandle`], and both come from
//! this file. The upgrade to the component model is a swap of this one module if WIT
//! ever buys something the plugins actually need (rich types across the boundary;
//! today everything crossing it is one JSON string).
//!
//! # Why the forge never executes the artifact
//!
//! The point of all this is that dwarves can pull a built binary from a registry and
//! ask it what it is. It must **not** do that by running it:
//!
//! * **It frequently cannot work.** An aarch64 / musl / wasm artifact will not run on
//!   an x86 forge — and cross-built variants are exactly the ones you most need to
//!   identify remotely. An interrogation method that fails on cross-compilation fails
//!   at its job.
//! * **It is remote code execution from a registry**, on the host that holds the SSH
//!   deploy keys and the publish rights.
//!
//! Instead the artifact carries its build thing's **wasm module** in a linker section
//! ([`SECTION`]), which is read with an object parser and executed in a sandbox with
//! no capabilities. dwarves runs the artifact's *build thing*, never the artifact —
//! so it works for a binary this host could never execute, and a hostile artifact gets
//! a wasm sandbox rather than the forge's process.
//!
//! If the forge must ever genuinely RUN a pulled artifact (a smoke test, or
//! characterising something with no section), that goes in modgunn's OCI or tunnr's
//! KVM. Never bare on the forge host.
//!
//! # The bijection laws are part of this contract
//!
//! The owner, 2026-08-26: *"viktigt att bijektion används för buildthings — det
//! måste vara i traitsen till och med."* A build thing is ONE declaration worn
//! by many surfaces — the `[build]` TOML, the [`BuildVariantSet`] struct, the
//! [`BuildMenu`] a pane renders, the `--opt` flags a CLI takes, the kconfig
//! fragment a kernel decision commits, the receipt a built artifact carries —
//! and each pair must be **mutually recoverable**, because a surface with no
//! way back is a surface that can drift with nothing to say so. That is not a
//! style preference here: every worst bug this design exists to end (the TOML
//! nobody noticed was stale, the twin list nine tabs behind, the "server" that
//! was not the server) is a surface that had silently stopped mapping back to
//! its declaration.
//!
//! The laws are therefore trait-level, not per-impl virtue: **conformance to
//! [`crate::laws`] is part of implementing [`BuildThing`]**. Every
//! implementation's own test suite calls
//! [`laws::assert_bijective`](crate::laws::assert_bijective) on its build
//! thing — dwarves' TOML wrapper, holger's, gunnar's, knut's — and a failure
//! names the law and the inverse that broke. The two deliberate asymmetries
//! (the declaration TEXT has many legal spellings of one struct; a receipt
//! forgets its settings on the way back to its recipe) are stated exactly, as
//! one-way, in [`crate::laws`]' module docs — an honest one-way arrow is part
//! of the contract too, so nobody "fixes" it into a lying bijection.

use serde::{Deserialize, Serialize};

use crate::variant::BuildVariantSet;

/// The linker section an artifact carries its build thing's wasm module in. Read with
/// an object parser; **never** by executing the artifact.
pub const SECTION: &str = ".nordisk.buildthing";

/// The plugin ABI version. Bumped on any change to [`abi`]'s call convention; the host
/// refuses a module that reports a different one rather than reading garbage.
pub const ABI_VERSION: u32 = 1;

// ── what crosses the boundary ────────────────────────────────────────────────

/// Who a build thing belongs to. The first thing a host asks a freshly-loaded module.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
    /// The ABI the module was built against.
    pub abi: u32,
    /// The repo / suite member.
    pub repo: String,
    /// The crate the build thing is implemented in — the source of truth's name.
    pub source_crate: String,
    /// One line about what this app is, for a list.
    pub summary: String,
}

/// A build the caller is asking about: a declared variant by name, or an ad-hoc
/// feature combination the app should judge.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildRequest {
    /// A declared variant name, when the caller is asking about one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub variant: Option<String>,
    /// An ad-hoc feature set, when the caller is asking "could I build THIS?".
    /// Cargo-flavoured; a non-cargo build thing reads
    /// [`selector`](Self::selector) instead.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub features: Vec<String>,
    /// The build thing's OWN selector, opaque to the forge — echoed back from
    /// [`VariantRef::selector`]. Empty for the cargo path, which uses the fields above.
    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
    pub selector: serde_json::Value,
    /// The target triple in play (`None` = host).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target: Option<String>,
}

impl BuildRequest {
    /// Ask about a declared variant.
    pub fn variant(name: impl Into<String>) -> Self {
        BuildRequest {
            variant: Some(name.into()),
            ..Default::default()
        }
    }
    /// Ask about an ad-hoc feature combination.
    pub fn features<I: IntoIterator<Item = S>, S: Into<String>>(f: I) -> Self {
        BuildRequest {
            features: f.into_iter().map(Into::into).collect(),
            ..Default::default()
        }
    }
}

/// **Which subtree of which variant** — the argument of
/// [`BuildThing::options`] over the wasm ABI. `path = ""` is the whole tree.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OptionsQuery {
    /// The declared variant.
    pub variant: String,
    /// A node path inside its tree (`kernel`, `dhcp.static_ip`), or empty.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub path: String,
}

/// An app's judgement. `reasons` is never empty when `!ok` — a refusal that cannot say
/// why is the failure mode this whole session has been about.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Verdict {
    /// Whether the app accepts this.
    pub ok: bool,
    /// Why not (or, when `ok`, any notes worth surfacing). Each entry is one sentence.
    #[serde(default)]
    pub reasons: Vec<String>,
}

impl Verdict {
    /// Accepted, with no notes.
    pub fn ok() -> Self {
        Verdict {
            ok: true,
            reasons: Vec::new(),
        }
    }
    /// Accepted, with something worth saying.
    pub fn ok_with(note: impl Into<String>) -> Self {
        Verdict {
            ok: true,
            reasons: vec![note.into()],
        }
    }
    /// Refused, with the reason.
    pub fn refuse(why: impl Into<String>) -> Self {
        Verdict {
            ok: false,
            reasons: vec![why.into()],
        }
    }
    /// Fold another verdict in: `ok` is the AND, reasons concatenate.
    pub fn and(mut self, other: Verdict) -> Self {
        self.ok &= other.ok;
        self.reasons.extend(other.reasons);
        self
    }
    /// The one-line rendering.
    pub fn line(&self) -> String {
        if self.reasons.is_empty() {
            return if self.ok {
                "ok".into()
            } else {
                "REFUSED (no reason given)".into()
            };
        }
        format!(
            "{} — {}",
            if self.ok { "ok" } else { "REFUSED" },
            self.reasons.join("; ")
        )
    }
}

/// What a host knows about a built artifact when it asks the app to judge it. Passed
/// IN, because the sandbox can read nothing for itself.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactFacts {
    /// The variant it claims to be.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub variant: Option<String>,
    /// File name (not a path — the sandbox has no filesystem and no business with one).
    #[serde(default)]
    pub file_name: String,
    /// Size in bytes.
    #[serde(default)]
    pub bytes: u64,
    /// SHA-256, lowercase hex.
    #[serde(default)]
    pub sha256: String,
    /// Crate names found in the artifact's dependency closure — what the host resolved,
    /// handed over so the app can apply its OWN rules to it (holger's airgap tiers
    /// forbid an outbound HTTP client; only holger knows that).
    #[serde(default)]
    pub linked_crates: Vec<String>,
}

/// **What a variant IS to the product** — the axis a menu groups by.
///
/// A build space is not a flat vector. holger ships three *servers* and two *clients*
/// from one tree, and "which of these five do I want" is unanswerable until you know
/// which of the two questions you are asking. The pane renders one section per role.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    /// A long-running daemon the product IS — holger's `embedded` / `mini` / `full`.
    Server,
    /// Something a human or a script drives AGAINST a server — a UI, a CLI.
    Client,
    /// **UI and server in ONE binary.** You start it and it puts the UI up with the
    /// server running *inside itself*: nothing else to install, nothing to connect
    /// to, no port to configure, no second process to keep alive.
    ///
    /// # Why this is its own role and not a flavour of the other two
    ///
    /// It is **not a [`Client`](Role::Client)**: a client drives a server it does
    /// not contain, so its defining question — *which server am I pointed at* — is
    /// a runtime decision the operator makes. A solo build has no answer to that
    /// question because there is no remote to point at. Filing it under `Client`
    /// puts a self-contained appliance in the same menu section as a thin UI that
    /// is useless without a running daemon, which is precisely the choice the role
    /// axis exists to make legible.
    ///
    /// It is **not a [`Server`](Role::Server)**: a server is headless and is
    /// deployed — systemd unit, a port, a service account, a lifecycle somebody
    /// else owns. A solo build opens a window on a desktop. Filing it under
    /// `Server` means a deploy step can pick it up and try to run a GUI under a
    /// system user with no display.
    ///
    /// The two mistakes are symmetric and both are silent, which is the argument:
    /// the distinction is not stylistic, it changes what a consumer of the menu is
    /// allowed to do with the artifact.
    ///
    /// # What it implies
    ///
    /// * [`linkage`](crate::variant::BuildVariant::shape) is **fat** — a solo build
    ///   that reaches a sibling over the wire is not solo, it is a mixed client.
    /// * It is the artifact you hand somebody who has nothing installed.
    /// * It is normally the SAME source as the `<x>-ui` client, built with the
    ///   server compiled in — a distinct `[[bin]]` name off one `main.rs`, never a
    ///   forked copy of the UI (LAW 5, and the reason
    ///   [`check_bins`](crate::variant::BuildVariantSet::check_bins) exists: two
    ///   shapes of one source MUST NOT emit one file name).
    ///
    /// holger's `solo` (`holger-solo`) is the reference: it is a rename of the old
    /// `full` variant, not a new thing.
    Solo,
    /// A library, an SDK, a shared component: built, published, never run.
    Library,
    /// Tooling that supports the build itself and is not shipped as the product.
    Tool,
}

impl Default for Role {
    fn default() -> Self {
        Role::Server
    }
}

impl Role {
    /// Stable lowercase label.
    pub fn as_str(self) -> &'static str {
        match self {
            Role::Server => "server",
            Role::Client => "client",
            Role::Solo => "solo",
            Role::Library => "library",
            Role::Tool => "tool",
        }
    }
}

/// **Which build system actually builds a variant.**
///
/// Today the constellation is Rust and every variant is [`Ecosystem::Cargo`]; this
/// field exists because it is one word now and a migration later. It is deliberately
/// the ONLY concession to multi-ecosystem support — there is no factory, no per-
/// ecosystem registry and no second implementation, because there is no second
/// producer and no second consumer. The other concession is that a
/// [`PlanStep`] is a generic `program + args`, not a cargo invocation, and that
/// [`BuildRequest::selector`] is opaque JSON rather than a typed feature list.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Ecosystem {
    /// A Rust crate built by cargo. The only one implemented.
    Cargo,
}

impl Default for Ecosystem {
    fn default() -> Self {
        Ecosystem::Cargo
    }
}

impl Ecosystem {
    /// Stable lowercase label.
    pub fn as_str(self) -> &'static str {
        match self {
            Ecosystem::Cargo => "cargo",
        }
    }
}

/// **How a variant talks to its siblings** — a declared axis, not a consequence of a
/// feature list you have to reverse-engineer.
///
/// Two builds of the same product can be completely different things to deploy: a FAT
/// dwarves compiles nornir + skade IN and opens the warehouse in-process, needing no
/// server at runtime; a THIN one is built without nornir and reaches the same verbs
/// over gRPC, needing a server up. A caller must see that BEFORE building, because the
/// runtime requirements are not comparable.
///
/// This is a projection of [`ServiceShape`](crate::variant::ServiceShape), which the
/// declaration already carries alongside `embeds` / `reaches` — **not a second field to
/// keep in sync**. `embeds` says WHICH siblings; linkage says HOW.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Linkage {
    /// Siblings compiled IN; self-contained at runtime.
    Fat,
    /// No siblings compiled in; reaches them over gRPC. Needs them reachable.
    Thin,
    /// Some embedded, some reached.
    Mixed,
    /// **The axis does not apply** — a library, or a build with no sibling edge at
    /// all. Distinct from an UNDECLARED linkage, which reads as [`Fat`](Self::Fat)
    /// and so quietly claims self-containment nobody checked.
    #[serde(rename = "not-applicable")]
    NotApplicable,
}

impl From<crate::variant::ServiceShape> for Linkage {
    fn from(s: crate::variant::ServiceShape) -> Self {
        match s {
            crate::variant::ServiceShape::FatService => Linkage::Fat,
            crate::variant::ServiceShape::ThinStandalone => Linkage::Thin,
            crate::variant::ServiceShape::Mixed => Linkage::Mixed,
            crate::variant::ServiceShape::NotApplicable => Linkage::NotApplicable,
        }
    }
}

impl Linkage {
    /// Stable lowercase label.
    pub fn as_str(self) -> &'static str {
        match self {
            Linkage::Fat => "fat",
            Linkage::Thin => "thin",
            Linkage::Mixed => "mixed",
            Linkage::NotApplicable => "not-applicable",
        }
    }
    /// The sentence a pane shows beside the variant — what this choice COSTS at
    /// runtime, which is the thing a caller actually needs to know.
    pub fn runtime_line(self, embeds: &[String], reaches: &[String]) -> String {
        match self {
            Linkage::Fat if embeds.is_empty() => {
                "self-contained — nothing else needs to be running".to_string()
            }
            Linkage::Fat => format!(
                "embeds {} — self-contained, nothing else needs to be running",
                embeds.join(" + ")
            ),
            Linkage::Thin if reaches.is_empty() => {
                "standalone — compiles in no siblings".to_string()
            }
            Linkage::Thin => format!(
                "NEEDS {} RUNNING — reached over gRPC, not compiled in",
                reaches.join(" + ").to_uppercase()
            ),
            Linkage::Mixed => format!(
                "embeds {}; needs {} running",
                if embeds.is_empty() {
                    "nothing".into()
                } else {
                    embeds.join(" + ")
                },
                if reaches.is_empty() {
                    "nothing".into()
                } else {
                    reaches.join(" + ")
                }
            ),
            Linkage::NotApplicable => {
                "fat/thin does not apply — this artifact is not deployed on its own".to_string()
            }
        }
    }
}

/// One menu entry, ecosystem-neutral: an id, what it IS, who builds it, and an
/// **opaque selector** the owning build thing understands and the forge does not.
///
/// The forge never reads `selector`. It passes it back when asking for a plan, and the
/// build thing turns it into steps. That is the line that keeps dwarves from needing
/// to know cargo — even though, today, cargo is the only thing on the other side.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VariantRef {
    /// The variant handle.
    pub id: String,
    /// What it is to the product.
    pub role: Role,
    /// Which build system builds it.
    pub ecosystem: Ecosystem,
    /// How it talks to its siblings — fat (compiled in) or thin (over gRPC).
    #[serde(default = "default_linkage")]
    pub linkage: Linkage,
    /// What it needs at runtime, in one sentence.
    #[serde(default)]
    pub runtime: String,
    /// **The normal case for this role.** Exactly one per role. A menu of five with no
    /// indication of which is normal makes every caller guess.
    #[serde(default)]
    pub is_default: bool,
    /// The build thing's own description of this build point. Opaque to the forge.
    #[serde(default)]
    pub selector: serde_json::Value,
}

fn default_linkage() -> Linkage {
    Linkage::Fat
}

/// One `(suite, role)` group — a section of the menu.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoleGroup {
    /// What these variants are.
    pub role: Role,
    /// **Which DELIVERY this section belongs to** — `None` for a repo that ships one
    /// unnamed product (see [`crate::variant::BuildVariant::suites`]).
    ///
    /// A menu section is a `(suite, role)` group, not a role: a repo shipping both a
    /// full product and a carved-out MVP has two server sections, each with its own
    /// normal case. Flattening them to one role would force the two deliveries to
    /// share a default — the declaration bug the suite axis removes. A variant that
    /// ships in two deliveries appears in two sections, which is what it does.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub suite: Option<String>,
    /// One line about this role in THIS product.
    pub summary: String,
    /// The variant names in the group, in menu order.
    pub variants: Vec<String>,
    /// **The normal case.** `None` only when the role has no variants; a role with
    /// variants and no default is a declaration bug, caught by
    /// [`BuildMenu::check_defaults`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_variant: Option<String>,
}

/// **The build menu**: the declaration, grouped by [`Role`]. This is what
/// [`BuildThing::menu`] returns and what a pane renders — never a flat list, because a
/// flat list cannot answer "am I choosing a server or a client".
///
/// (No `PartialEq`: it carries a whole [`BuildVariantSet`], which is compared through
/// its serialized form rather than structurally — see `menu_covers_every_variant`.)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildMenu {
    /// The repo this menu belongs to.
    pub repo: String,
    /// The revision it is pinned to (see [`BuildThing::provenance`]).
    pub rev: String,
    /// The role sections, in menu order.
    pub roles: Vec<RoleGroup>,
    /// Every entry, ecosystem-neutral — what a pane renders and what the forge passes
    /// back when it asks for a plan.
    #[serde(default)]
    pub entries: Vec<VariantRef>,
    /// The cargo declaration behind the menu. `Some` for a cargo build thing (all of
    /// them, today); a build thing for another ecosystem would leave it `None` and the
    /// cargo-specific views — the FEATURE AXIS above all — must then report
    /// NOT APPLICABLE rather than "0 of 0", which would be the vacuous-green bug again
    /// by category error.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cargo_set: Option<BuildVariantSet>,
}

impl BuildMenu {
    /// The variant names in one role.
    pub fn in_role(&self, role: Role) -> Vec<&str> {
        self.roles
            .iter()
            .filter(|g| g.role == role)
            .flat_map(|g| g.variants.iter().map(|s| s.as_str()))
            .collect()
    }
    /// Which role a variant belongs to.
    pub fn role_of(&self, variant: &str) -> Option<Role> {
        self.roles
            .iter()
            .find(|g| g.variants.iter().any(|v| v == variant))
            .map(|g| g.role)
    }
    /// Every variant named by some role — the oracle that no variant is unreachable
    /// from the menu.
    pub fn menu_variants(&self) -> Vec<&str> {
        self.roles
            .iter()
            .flat_map(|g| g.variants.iter().map(|s| s.as_str()))
            .collect()
    }
    /// **Exactly one default per non-empty `(suite, role)` section**, or the reasons
    /// it is not so.
    ///
    /// fat-vs-thin is not a preference: fat is the normal case and thin is the
    /// exception, and a build thing that does not say which is which pushes the guess
    /// onto every caller. Empty result = well-formed.
    ///
    /// This is the MENU-side twin of
    /// [`BuildVariantSet::check_defaults`](crate::variant::BuildVariantSet::check_defaults):
    /// the same rule over the rendered sections rather than over the declaration. The
    /// two cannot contradict, because the sections a default [`BuildThing::roles`]
    /// hands it come from [`BuildVariantSet::groups`](crate::variant::BuildVariantSet::groups)
    /// — the one grouping writer — and because
    /// [`VariantRef::is_default`] is now true when the DECLARATION marks it, not only
    /// when the section elected it. Before that, a declaration marking two defaults in
    /// one role produced one flagged entry, so this checker saw exactly one and went
    /// green on a file the set-level checker rejected.
    ///
    /// A section names its suite in the message only when the menu carries one.
    pub fn check_defaults(&self) -> Vec<String> {
        let mut problems = Vec::new();
        for g in &self.roles {
            if g.variants.is_empty() {
                continue;
            }
            let marked: Vec<&str> = self
                .entries
                .iter()
                .filter(|e| e.is_default && g.variants.iter().any(|v| v == &e.id))
                .map(|e| e.id.as_str())
                .collect();
            let label = match &g.suite {
                Some(s) => format!("suite '{}' role '{}'", s, g.role.as_str()),
                None => format!("role '{}'", g.role.as_str()),
            };
            match marked.len() {
                1 => {
                    if g.default_variant.as_deref() != Some(marked[0]) {
                        problems.push(format!(
                            "{} marks '{}' as default but the group names {:?}",
                            label, marked[0], g.default_variant
                        ));
                    }
                }
                0 => problems.push(format!(
                    "{} has {} variant(s) and NO default — the caller has to guess \
                     which is the normal case",
                    label,
                    g.variants.len()
                )),
                n => problems.push(format!(
                    "{label} marks {n} defaults ({}) — exactly one is the normal case",
                    marked.join(", ")
                )),
            }
        }
        problems
    }

    /// The default variant for a role, if declared.
    pub fn default_for(&self, role: Role) -> Option<&str> {
        self.roles
            .iter()
            .find(|g| g.role == role)?
            .default_variant
            .as_deref()
    }

    /// The cargo declaration, or an empty one. A caller that needs the cargo axes must
    /// go through [`cargo_set`](Self::cargo_set) and handle `None` — this is the
    /// convenience for the cargo-only present.
    pub fn set(&self) -> BuildVariantSet {
        self.cargo_set.clone().unwrap_or_default()
    }
}

// ── the rev pin ──────────────────────────────────────────────────────────────

/// **A build thing's identity: `(repo, rev)`.**
///
/// A build thing is only true for the revision it was built from — features get
/// renamed, variants come and go, an airgap rule changes which crates it forbids. A
/// build thing without a rev is the `build-variants.toml` rot again, wearing a
/// compiled wrapper: something that claims to describe a tree it may no longer match.
///
/// The forge therefore **refuses to rebuild** when the checkout's rev does not match
/// the build thing's ([`RevMatch::refuse_rebuild`]).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpecProvenance {
    /// The repo.
    pub repo: String,
    /// The git revision this build thing describes. Empty means UNKNOWN, which is a
    /// reported state and never treated as "matches anything".
    pub rev: String,
    /// Whether the working tree was dirty when the build thing was built. A dirty
    /// build thing describes something that was never committed anywhere.
    pub dirty: bool,
    /// The buildspec crate.
    pub source_crate: String,
}

impl SpecProvenance {
    /// Compare against the revision a checkout is actually at.
    pub fn check(&self, checkout_rev: &str) -> RevMatch {
        if self.rev.is_empty() || checkout_rev.is_empty() {
            return RevMatch::Unknown {
                spec_rev: self.rev.clone(),
                checkout_rev: checkout_rev.to_string(),
            };
        }
        if self.rev == checkout_rev {
            if self.dirty {
                return RevMatch::MatchesButDirty {
                    rev: self.rev.clone(),
                };
            }
            return RevMatch::Matches {
                rev: self.rev.clone(),
            };
        }
        RevMatch::Mismatch {
            spec_rev: self.rev.clone(),
            checkout_rev: checkout_rev.to_string(),
        }
    }
}

/// The verdict of pinning a build thing against a checkout.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RevMatch {
    /// The build thing describes exactly this tree.
    Matches {
        /// The agreed revision.
        rev: String,
    },
    /// Same commit, but the build thing was built from a dirty tree — so it describes
    /// something that was never committed and cannot be fetched by anyone else.
    MatchesButDirty {
        /// The agreed revision.
        rev: String,
    },
    /// The build thing describes a different revision than the tree in hand.
    Mismatch {
        /// What the build thing says.
        spec_rev: String,
        /// What the checkout is at.
        checkout_rev: String,
    },
    /// One side does not know its revision. **Not** treated as a match.
    Unknown {
        /// What the build thing says (possibly empty).
        spec_rev: String,
        /// What the checkout says (possibly empty).
        checkout_rev: String,
    },
}

impl RevMatch {
    /// **Whether the forge must refuse to rebuild.** Only an exact clean match is
    /// allowed to proceed silently; a dirty match proceeds with a warning; anything
    /// else is refused, because rebuilding tree B with tree A's recipe produces an
    /// artifact that matches neither.
    pub fn refuse_rebuild(&self) -> bool {
        matches!(self, RevMatch::Mismatch { .. } | RevMatch::Unknown { .. })
    }
    /// Stable label.
    pub fn label(&self) -> &'static str {
        match self {
            RevMatch::Matches { .. } => "matches",
            RevMatch::MatchesButDirty { .. } => "matches-dirty",
            RevMatch::Mismatch { .. } => "mismatch",
            RevMatch::Unknown { .. } => "unknown",
        }
    }
    /// The sentence a pane prints, and the refusal reason.
    pub fn line(&self) -> String {
        match self {
            RevMatch::Matches { rev } => {
                format!("build thing is pinned to this tree ({})", short(rev))
            }
            RevMatch::MatchesButDirty { rev } => format!(
                "build thing matches {} but was built from a DIRTY tree — it describes something \
                 never committed",
                short(rev)
            ),
            RevMatch::Mismatch {
                spec_rev,
                checkout_rev,
            } => format!(
                "REFUSING to rebuild: the build thing describes {} and this tree is at {} — \
                 features get renamed and variants come and go, so its recipe is not this tree's",
                short(spec_rev),
                short(checkout_rev)
            ),
            RevMatch::Unknown {
                spec_rev,
                checkout_rev,
            } => format!(
                "REFUSING to rebuild: a revision is UNKNOWN (build thing '{}', checkout '{}') — \
                 an unpinned build thing is the TOML rot in a compiled wrapper",
                short(spec_rev),
                short(checkout_rev)
            ),
        }
    }
}

fn short(rev: &str) -> String {
    if rev.is_empty() {
        return "<unknown>".to_string();
    }
    rev.chars().take(12).collect()
}

// ── plan as data ─────────────────────────────────────────────────────────────

/// One step of a [`BuildPlan`] — a program and its argv. **Data.** The build thing
/// returns it; the forge decides whether, where and how to run it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanStep {
    /// A short label for the step, for a log line and a DAG node.
    pub name: String,
    /// The program (`cargo`, …). Never a shell.
    pub program: String,
    /// The argv, already materialized.
    pub args: Vec<String>,
    /// Extra environment for this step only.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env: Vec<(String, String)>,
}

/// **What the build thing says should happen — and nothing it did.**
///
/// The build thing never performs an action: it returns a plan and the forge executes
/// it. That is what lets a plan be inspected, diffed, dry-run, cached and gated before
/// anything touches the disk — and it is *why the sandbox needs no capabilities at
/// all*. A build thing that could run a command would need a process capability; one
/// that returns a list of commands needs nothing.
///
/// When a build genuinely must react to output, the forge runs step *n*, hands the
/// result back, and asks for step *n+1* — a step loop, still with no raw capability.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildPlan {
    /// The variant this plan builds.
    pub variant: String,
    /// The steps, in order.
    pub steps: Vec<PlanStep>,
    /// Environment applied to every step.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env: Vec<(String, String)>,
    /// The artifacts the plan is expected to produce, relative to the repo root. The
    /// forge checks these EXIST afterwards — which is how "cargo exited 0 and produced
    /// nothing" stops counting as success.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub expected_artifacts: Vec<String>,
    /// Why this plan is what it is, for a human reading a dry run.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rationale: Option<String>,
}

impl BuildPlan {
    /// The plan as shell-ish lines for a dry run. Display only — the forge executes
    /// the argv, never this string.
    pub fn preview(&self) -> String {
        let mut s = format!("plan for '{}':\n", self.variant);
        for (i, st) in self.steps.iter().enumerate() {
            s.push_str(&format!(
                "  {}. {} — {} {}\n",
                i + 1,
                st.name,
                st.program,
                st.args.join(" ")
            ));
        }
        for a in &self.expected_artifacts {
            s.push_str(&format!("  expect: {a}\n"));
        }
        s
    }
}

/// Everything needed to fetch the source and rebuild this artifact — the payload that
/// makes `pull → identify → rebuild → compare digests` possible.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RebuildRecipe {
    /// The repo to fetch.
    pub repo: String,
    /// Its git remote, when the app knows it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub remote: Option<String>,
    /// The revision the artifact was built from, when the app was told at build time.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rev: Option<String>,
    /// The declared variant to rebuild.
    pub variant: String,
    /// The exact cargo argv, materialized by the app.
    pub cargo_args: Vec<String>,
    /// The toolchain the artifact was built with, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub toolchain: Option<String>,
}

// ── the trait ────────────────────────────────────────────────────────────────

/// **An application's build thing.** Implemented once, in a crate the app depends on;
/// compiled into the app natively AND to `wasm32` as the plugin the forge loads.
///
/// The methods are verbs deliberately: [`validate`](BuildThing::validate) and
/// [`judge_artifact`](BuildThing::judge_artifact) are where an app's own rules live,
/// and they are the reason this is code rather than a JSON file. `holger` knows that
/// its airgap tiers must link no HTTP client; `korp` knows `spark41` needs `iceberg`.
/// The generic validator in [`BuildVariantSet`] cannot know either.
///
/// Implementations must be **pure**: no filesystem, no network, no clock. The wasm
/// sandbox grants none of those (it cannot even name them), so an impl that reaches
/// for one compiles native and traps in the plugin — the drift this design exists to
/// prevent. Everything an impl needs is passed in.
pub trait BuildThing {
    /// Who this belongs to.
    fn manifest(&self) -> Manifest;

    /// The declared build space — every variant with its axes.
    fn variants(&self) -> BuildVariantSet;

    /// **Which variants are which** — the role axis.
    ///
    /// The default READS the declaration: a `[[build.variant]]` may carry
    /// `role = "server" | "client" | "solo" | "library" | "tool"` and `default = true`, and
    /// those group the menu and name each group's normal case. Only when NO variant
    /// declares a role does it fall back to one `Server` group — honest for a
    /// single-role product, wrong for anything that also ships a client, which is
    /// why the declaration is worth making. A build thing with a richer notion of
    /// its own roles still overrides.
    fn roles(&self) -> Vec<RoleGroup> {
        let set = self.variants();
        if set.variants.iter().all(|v| v.role.is_none()) && !set.declares_suites() {
            let names: Vec<String> = set.names().iter().map(|s| s.to_string()).collect();
            return vec![RoleGroup {
                role: Role::Server,
                suite: None,
                summary: "every declared variant (this build thing declares no role axis)"
                    .to_string(),
                default_variant: names.first().cloned(),
                variants: names,
            }];
        }
        // One section per `(suite, role)` group, in declaration order. The grouping
        // comes from `BuildVariantSet::groups` — the SAME writer `check_defaults`
        // uses — so the menu and the guard cannot disagree about what a section is.
        set.groups()
            .into_iter()
            .map(|(suite, _role_label, members)| {
                let role = members.first().and_then(|v| v.role).unwrap_or_default();
                let suite = (suite != crate::variant::UNSUITED).then(|| suite.to_string());
                RoleGroup {
                    summary: match &suite {
                        Some(s) => format!(
                            "{} declared {} variant(s) in delivery '{}'",
                            members.len(),
                            role.as_str(),
                            s
                        ),
                        None => format!("{} declared {} variant(s)", members.len(), role.as_str()),
                    },
                    role,
                    suite,
                    // `None` when the repo marked none — `check_defaults` then says so
                    // by name instead of the menu quietly electing the first entry.
                    default_variant: members
                        .iter()
                        .find(|v| v.is_default)
                        .map(|v| v.name.clone()),
                    variants: members.iter().map(|v| v.name.clone()).collect(),
                }
            })
            .collect()
    }

    /// **The menu** a pane renders: the declaration grouped by role and pinned to a
    /// revision. Not usually overridden — it composes [`variants`](BuildThing::variants),
    /// [`roles`](BuildThing::roles) and [`provenance`](BuildThing::provenance).
    fn menu(&self) -> BuildMenu {
        let set = self.variants();
        let roles = self.roles();
        let entries = set
            .variants
            .iter()
            .map(|v| {
                let group = roles
                    .iter()
                    .find(|g| g.variants.iter().any(|n| n == &v.name));
                let linkage = Linkage::from(v.shape);
                let embeds: Vec<String> = v.embeds.iter().cloned().collect();
                let reaches: Vec<String> = v.reaches.iter().cloned().collect();
                VariantRef {
                    id: v.name.clone(),
                    role: group.map(|g| g.role).unwrap_or_default(),
                    ecosystem: Ecosystem::Cargo,
                    linkage,
                    runtime: linkage.runtime_line(&embeds, &reaches),
                    // The DECLARATION marking it, or the section electing it. Reading
                    // only the section made a two-default declaration render as one
                    // flagged entry, so `BuildMenu::check_defaults` went green on a
                    // file `BuildVariantSet::check_defaults` rejects — two checkers
                    // for one rule, contradicting. The `||` keeps an overriding
                    // `roles()` (which names its defaults in the group, not in the
                    // declaration) working, and stops the menu from hiding a
                    // double-default.
                    is_default: v.is_default
                        || group
                            .and_then(|g| g.default_variant.as_deref())
                            .map(|d| d == v.name)
                            .unwrap_or(false),
                    selector: serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
                }
            })
            .collect();
        BuildMenu {
            repo: self.manifest().repo,
            rev: self.provenance().rev,
            roles,
            entries,
            cargo_set: Some(set),
        }
    }

    /// **The rev this build thing is true for.** The default asks the host via
    /// [`host::git_rev`], so a build thing compiled into an app reports the app's own
    /// tree — and a plugin loaded from an artifact reports what it was built from.
    fn provenance(&self) -> SpecProvenance {
        let (rev, dirty) = host::git_rev();
        SpecProvenance {
            repo: self.manifest().repo,
            rev,
            dirty,
            source_crate: self.manifest().source_crate,
        }
    }

    /// **What should happen to build `variant` — as DATA.** The build thing performs
    /// nothing; the forge executes the returned steps. The default materializes the
    /// declared cargo argv, which is right for a plain cargo variant; a product with a
    /// codegen step, a wasm post-process or a packaging step overrides.
    fn plan(&self, variant: &str) -> Option<BuildPlan> {
        let set = self.variants();
        let v = set.get(variant)?;
        let mut env = Vec::new();
        if let Some(cfg) = &v.cfg {
            env.push(("RUSTFLAGS".to_string(), format!("--cfg {cfg}")));
        }
        Some(BuildPlan {
            variant: variant.to_string(),
            steps: vec![PlanStep {
                name: format!("build-{variant}"),
                program: "cargo".to_string(),
                args: v.cargo_args(),
                env: Vec::new(),
            }],
            env,
            // Through `artifact_rel_path`, not a second copy of the convention:
            // a plan that expects a path the build does not write is a plan that
            // reports a missing artifact for a reason nobody can find.
            expected_artifacts: v
                .artifact_rel_path()
                .map(|p| vec![p.to_string_lossy().into_owned()])
                .unwrap_or_default(),
            rationale: Some(format!(
                "the declared variant '{variant}' materializes to exactly one cargo invocation"
            )),
        })
    }

    /// **The app's own judgement** on a requested build. The default applies the
    /// generic set rules; override to add the rules only this app knows.
    fn validate(&self, req: &BuildRequest) -> Verdict {
        let set = self.variants();
        if let Some(name) = &req.variant {
            return match set.get(name) {
                None => Verdict::refuse(format!(
                    "no variant '{name}'; declared: {}",
                    set.names().join(", ")
                )),
                Some(v) => match set.validate(v) {
                    Ok(()) => Verdict::ok(),
                    Err(e) => Verdict::refuse(e.to_string()),
                },
            };
        }
        let probe = crate::variant::BuildVariant {
            name: "<ad-hoc>".to_string(),
            features: req.features.clone(),
            target: req.target.clone(),
            ..Default::default()
        };
        match set.validate(&probe) {
            Ok(()) => Verdict::ok(),
            Err(e) => Verdict::refuse(e.to_string()),
        }
    }

    /// **The app's judgement on a BUILT artifact** — the check only this app can make.
    /// Default: accept, saying so honestly. An app with a real invariant (holger's
    /// zero-egress airgap tiers) overrides.
    fn judge_artifact(&self, facts: &ArtifactFacts) -> Verdict {
        let _ = facts;
        Verdict::ok_with("this build thing declares no artifact-level invariant")
    }

    /// How to rebuild a declared variant from source — the payload behind
    /// `pull → identify → rebuild → compare`.
    fn rebuild_recipe(&self, variant: &str) -> Option<RebuildRecipe> {
        let set = self.variants();
        let v = set.get(variant)?;
        Some(RebuildRecipe {
            repo: self.manifest().repo,
            remote: None,
            rev: None,
            variant: variant.to_string(),
            cargo_args: v.cargo_args(),
            toolchain: None,
        })
    }

    /// **What building `variant` IS, as a graph.** variant → plan steps → expected
    /// artifacts, plus the `embeds` / `reaches` edges that make fat-vs-thin visible.
    ///
    /// The default composes [`plan`](BuildThing::plan) and the declaration, which is
    /// right for every cargo build thing; a product with a genuinely different shape
    /// (a multi-stage image chain) overrides.
    fn graph(&self, variant: &str) -> BuildGraph {
        let set = self.variants();
        let mut g = BuildGraph {
            repo: self.manifest().repo,
            root: variant.to_string(),
            ..Default::default()
        };
        let Some(v) = set.get(variant) else { return g };

        let linkage = Linkage::from(v.shape);
        let embeds: Vec<String> = v.embeds.iter().cloned().collect();
        let reaches: Vec<String> = v.reaches.iter().cloned().collect();
        let root_id = format!("variant:{variant}");
        g.nodes.push(GraphNode {
            id: root_id.clone(),
            label: variant.to_string(),
            kind: NodeKind::Variant,
            detail: Some(linkage.runtime_line(&embeds, &reaches)),
            selects: Some(variant.to_string()),
        });

        // The plan DAG: variant → step → step, and each step → what it produces.
        if let Some(plan) = self.plan(variant) {
            let mut prev = root_id.clone();
            for (i, st) in plan.steps.iter().enumerate() {
                let id = format!("step:{variant}:{i}");
                g.nodes.push(GraphNode {
                    id: id.clone(),
                    label: st.name.clone(),
                    kind: NodeKind::Step,
                    detail: Some(format!("{} {}", st.program, st.args.join(" "))),
                    selects: None,
                });
                g.edges.push(GraphEdge {
                    from: prev.clone(),
                    to: id.clone(),
                    kind: EdgeKind::Plan,
                    label: None,
                });
                prev = id;
            }
            // Artifacts hang off the LAST step — the one whose completion produces them.
            for a in &plan.expected_artifacts {
                let id = format!("artifact:{a}");
                g.nodes.push(GraphNode {
                    id: id.clone(),
                    label: a.rsplit('/').next().unwrap_or(a).to_string(),
                    kind: NodeKind::Artifact,
                    detail: Some(a.clone()),
                    selects: None,
                });
                g.edges.push(GraphEdge {
                    from: prev.clone(),
                    to: id,
                    kind: EdgeKind::Produces,
                    label: None,
                });
            }
        }

        // The composition edges — the whole point of drawing this at all.
        for e in &embeds {
            let id = format!("embeds:{e}");
            g.nodes.push(GraphNode {
                id: id.clone(),
                label: e.clone(),
                kind: NodeKind::Embedded,
                // A CLAIM, and labelled as one. On 2026-07-22 `dwarves`, `dwarves-server`
                // and `dwarves-thin` all declared `embeds = ["nornir","holger","skade"]`
                // and carried ZERO nornir symbols — only `dwarves-full` genuinely embeds.
                // Rendering a declaration as fact would have drawn three lies, so the node
                // says DECLARED and a host that has measured the artifact overwrites it.
                detail: Some(format!(
                    "{e} is DECLARED compiled-in — unverified until the built artifact's \
                 symbols say so"
                )),
                selects: None,
            });
            g.edges.push(GraphEdge {
                from: root_id.clone(),
                to: id,
                kind: EdgeKind::Embeds,
                label: Some("compiled in".to_string()),
            });
        }
        for r in &reaches {
            let id = format!("reaches:{r}");
            g.nodes.push(GraphNode {
                id: id.clone(),
                label: r.clone(),
                kind: NodeKind::Reached,
                detail: Some(format!("{r} must be RUNNING — reached over gRPC")),
                selects: None,
            });
            g.edges.push(GraphEdge {
                from: root_id.clone(),
                to: id,
                kind: EdgeKind::Reaches,
                label: Some("over gRPC".to_string()),
            });
        }
        g
    }

    /// **The settings tree of `variant`, from `path` down** — feature ▸ setting
    /// ▸ val, paths as ids ([`crate::tree`]). `None` when the variant is not
    /// declared or the path is not in its tree. The default DERIVES the tree
    /// from [`variants`](BuildThing::variants) (options → features with their
    /// conditional params as children; `kernel_donor` → the `kernel` edge with
    /// the kernel model's rows); an impl whose answer is dynamic — a kernel
    /// tree read from fetched source — overrides for that subtree and asks the
    /// host through [`host::fetch`].
    fn options(&self, variant: &str, path: &str) -> Option<crate::tree::SettingsTree> {
        crate::tree::SettingsTree::of_set(&self.variants(), variant)?.subtree(path)
    }

    /// **The judge** — may this feature go on/off, and what follows? The
    /// default is [`SettingsTree::try_toggle`](crate::tree::SettingsTree::try_toggle):
    /// a plain toggle for a plain feature, and the kernel model's own cascade
    /// and contract refusal for a `kernel.*` symbol — which is exactly what a
    /// kernel donor's build thing must answer for its symbols.
    fn try_toggle(&self, req: &crate::tree::ToggleRequest) -> crate::tree::ToggleDecision {
        match self.options(&req.variant, "") {
            Some(tree) => tree.try_toggle(req),
            None => crate::tree::ToggleDecision {
                path: req.path.clone(),
                on: req.on,
                verdict: Verdict::refuse(format!(
                    "no variant '{}'; declared: {}",
                    req.variant,
                    self.variants().names().join(", ")
                )),
                cascade: Vec::new(),
                refuses: Vec::new(),
                activates: Vec::new(),
            },
        }
    }

    /// **The verbs a typ bears** — RUN / BOOT / UNPACK. The default is the
    /// table in [`crate::verbs`]; an impl refines (a kernel build thing that
    /// also ships an UNPACK-able archive).
    fn actions(&self, kind: crate::variant::ArtifactKind) -> Vec<crate::verbs::Action> {
        crate::verbs::actions_for(kind)
    }

    /// The human `--describe-build` report.
    fn describe(&self) -> String {
        let m = self.manifest();
        crate::spec::describe(&m.repo, &m.source_crate, &self.variants())
    }
}

// ── the imports: THE PERMISSION LIST ─────────────────────────────────────────

/// **What a build thing may ask the host for — and therefore everything it can do.**
///
/// This is not a sandbox bolted onto an interface; it *is* the interface. There is no
/// filesystem, no socket, no clock, no process spawn, and no way to name one: a build
/// thing cannot shell out or open a connection, it can only ask dwarves a question,
/// and dwarves decides what to answer.
///
/// Three facts, chosen because a build thing genuinely cannot compute them and cannot
/// be trusted to fetch them:
///
/// | import | why it is safe |
/// |---|---|
/// | [`git_rev`] | the host reads the revision; the plugin cannot run `git` |
/// | [`read_manifest`] | the host resolves and reads ONE manifest, refusing traversal; the plugin has no path handle |
/// | [`log`] | a line of text out, nothing in |
///
/// Natively these are real implementations; on `wasm32` they are `extern` declarations
/// the host binds — and a module importing anything *outside* this namespace is
/// refused at load, by name.
pub mod host {
    /// The import namespace. The host binds exactly these and refuses a module that
    /// imports from any other module name.
    pub const NAMESPACE: &str = "nordisk";

    /// Import name: the revision + dirty flag of the tree the host is looking at.
    pub const IMPORT_GIT_REV: &str = "git_rev";
    /// Import name: read one manifest by repo-relative path.
    pub const IMPORT_READ_MANIFEST: &str = "read_manifest";
    /// Import name: emit a log line.
    pub const IMPORT_LOG: &str = "log";
    /// Import name: fetch the bytes behind a coordinate (`repo/variant`) — the ONE
    /// narrow way a build thing reaches an artifact or a source it needs to
    /// answer `options("kernel")`. The host may refuse; a refusal is a named
    /// error, never a silent empty.
    pub const IMPORT_FETCH: &str = "fetch";

    /// Every import the host will bind. **This slice is the permission list**: the
    /// loader allows exactly these and nothing else.
    pub const IMPORTS: &[&str] = &[IMPORT_GIT_REV, IMPORT_READ_MANIFEST, IMPORT_LOG, IMPORT_FETCH];

    /// The first byte of a `fetch` reply: fulfilled in a CONTAINER (the
    /// declared toolchain image) — the reference fulfilment; the bytes follow.
    pub const FETCH_OK: u8 = 0;
    /// The first byte of a `fetch` reply: a UTF-8 refusal reason follows.
    pub const FETCH_REFUSED: u8 = 1;
    /// The first byte of a `fetch` reply: fulfilled on the HOST by a NAMED
    /// exception (a fixture, a host path the operator chose) — the bytes
    /// follow. Distinct from [`FETCH_OK`] so a host fulfilment can never pass
    /// as the container one: the module, the log and the receipt all see it.
    pub const FETCH_OK_HOST_EXCEPTION: u8 = 2;

    /// Where a fulfilled `fetch` came from.
    #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
    #[serde(rename_all = "kebab-case")]
    pub enum FetchOrigin {
        /// The declared toolchain container (`nordisk-cargo-toolchain` via
        /// draupnir / skidbladnir) — the default and the reference.
        Container,
        /// The host, by a named exception — never the default.
        HostException,
    }

    /// A fulfilled fetch: the bytes and where they came from.
    #[derive(Debug, Clone, PartialEq, Eq)]
    pub struct Fetched {
        /// The bytes.
        pub bytes: Vec<u8>,
        /// Container (reference) or host (named exception).
        pub origin: FetchOrigin,
    }

    /// **The host declined a fetch** — and said why.
    #[derive(Debug, Clone, PartialEq, Eq)]
    pub struct FetchRefused {
        /// The coordinate asked for.
        pub coordinate: String,
        /// The host's reason.
        pub reason: String,
    }

    impl std::fmt::Display for FetchRefused {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "fetch('{}') refused by the host: {}", self.coordinate, self.reason)
        }
    }

    impl std::error::Error for FetchRefused {}

    /// **Fetch the bytes behind `coordinate`** (`repo/variant`, or whatever the
    /// host agreed to serve under that name).
    ///
    /// **The contract.** The reference fulfilment is a CONTAINER: the host
    /// resolves the coordinate inside the declared toolchain image
    /// (`nordisk-cargo-toolchain`, run through draupnir / skidbladnir — see
    /// `plugin_host::ContainerFetcher`), never host cargo, host git or a host
    /// path. A host-side fulfilment exists only as a NAMED exception
    /// (`plugin_host::HostPathFetcher`, or a test fixture) and is marked on the
    /// wire ([`FETCH_OK_HOST_EXCEPTION`]) so the module gets
    /// [`FetchOrigin::HostException`] and the host log carries the exception's
    /// reason — it cannot be fulfilled silently on the host. A host that bound
    /// no fetcher REFUSES with a reason. Native: answered from
    /// [`NativeFacts::fetchables`] (a fixture — a host exception by definition).
    /// Either way an absent or declined coordinate is `Err(FetchRefused)` with
    /// the reason — a build thing that needs the kernel source to answer must
    /// say it could not, not answer from nothing.
    pub fn fetch(coordinate: &str) -> Result<Fetched, FetchRefused> {
        let decode = |payload: Vec<u8>| -> Result<Fetched, FetchRefused> {
            match payload.split_first() {
                Some((&FETCH_OK, rest)) => Ok(Fetched { bytes: rest.to_vec(), origin: FetchOrigin::Container }),
                Some((&FETCH_OK_HOST_EXCEPTION, rest)) => {
                    Ok(Fetched { bytes: rest.to_vec(), origin: FetchOrigin::HostException })
                }
                Some((&FETCH_REFUSED, rest)) => Err(FetchRefused {
                    coordinate: coordinate.to_string(),
                    reason: String::from_utf8_lossy(rest).into_owned(),
                }),
                _ => Err(FetchRefused {
                    coordinate: coordinate.to_string(),
                    reason: "the host answered nothing (no fetch capability bound, or it could \
                             not allocate the reply)"
                        .to_string(),
                }),
            }
        };
        #[cfg(target_arch = "wasm32")]
        {
            let bytes = coordinate.as_bytes();
            let packed = unsafe { wasm_imports::fetch(bytes.as_ptr() as i32, bytes.len() as i32) };
            let (ptr, len) = crate::plugin::abi::unpack(packed);
            decode(read_host_bytes(ptr, len))
        }
        #[cfg(not(target_arch = "wasm32"))]
        {
            match native_facts().fetchables.get(coordinate) {
                Some(bytes) => {
                    decode(std::iter::once(FETCH_OK_HOST_EXCEPTION).chain(bytes.iter().copied()).collect())
                }
                None => Err(FetchRefused {
                    coordinate: coordinate.to_string(),
                    reason: "the host holds nothing under that coordinate".to_string(),
                }),
            }
        }
    }

    /// The revision the host is looking at, and whether its tree is dirty.
    ///
    /// Native: shells nothing — the caller supplies it through
    /// [`set_native_facts`]. wasm: the host answers.
    pub fn git_rev() -> (String, bool) {
        #[cfg(target_arch = "wasm32")]
        {
            let packed = unsafe { wasm_imports::git_rev() };
            let (ptr, len) = crate::plugin::abi::unpack(packed);
            let s = read_host_string(ptr, len);
            match s.split_once('\u{1}') {
                Some((rev, d)) => (rev.to_string(), d == "1"),
                None => (s, false),
            }
        }
        #[cfg(not(target_arch = "wasm32"))]
        {
            native_facts().rev_and_dirty
        }
    }

    /// Read one manifest, by path relative to the repo root. Returns an empty string
    /// when the host declines (missing, or an attempted escape).
    pub fn read_manifest(path: &str) -> String {
        #[cfg(target_arch = "wasm32")]
        {
            let bytes = path.as_bytes();
            let packed =
                unsafe { wasm_imports::read_manifest(bytes.as_ptr() as i32, bytes.len() as i32) };
            let (ptr, len) = crate::plugin::abi::unpack(packed);
            read_host_string(ptr, len)
        }
        #[cfg(not(target_arch = "wasm32"))]
        {
            native_facts()
                .manifests
                .get(path)
                .cloned()
                .unwrap_or_default()
        }
    }

    /// Emit a line for the host's log.
    pub fn log(msg: &str) {
        #[cfg(target_arch = "wasm32")]
        {
            let b = msg.as_bytes();
            unsafe { wasm_imports::log(b.as_ptr() as i32, b.len() as i32) }
        }
        #[cfg(not(target_arch = "wasm32"))]
        {
            let _ = msg;
        }
    }

    #[cfg(target_arch = "wasm32")]
    fn read_host_string(ptr: u32, len: u32) -> String {
        String::from_utf8_lossy(&read_host_bytes(ptr, len)).into_owned()
    }

    #[cfg(target_arch = "wasm32")]
    fn read_host_bytes(ptr: u32, len: u32) -> Vec<u8> {
        if ptr == 0 || len == 0 {
            return Vec::new();
        }
        unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize).to_vec() }
    }

    #[cfg(target_arch = "wasm32")]
    mod wasm_imports {
        #[link(wasm_import_module = "nordisk")]
        unsafe extern "C" {
            pub fn git_rev() -> i64;
            pub fn read_manifest(ptr: i32, len: i32) -> i64;
            pub fn log(ptr: i32, len: i32);
            pub fn fetch(ptr: i32, len: i32) -> i64;
        }
    }

    /// The facts a NATIVE build thing sees. On wasm these come from the host over the
    /// imports; natively the embedding app supplies them once at startup, so the same
    /// impl runs both ways with no `cfg` fork in the build thing itself.
    #[cfg(not(target_arch = "wasm32"))]
    #[derive(Debug, Clone, Default)]
    pub struct NativeFacts {
        /// `(rev, dirty)`.
        pub rev_and_dirty: (String, bool),
        /// Manifests by repo-relative path.
        pub manifests: std::collections::BTreeMap<String, String>,
        /// Bytes the host will serve to [`fetch`], by coordinate — a FIXTURE,
        /// answered as [`FetchOrigin::HostException`]. Absent ⇒ refused.
        pub fetchables: std::collections::BTreeMap<String, Vec<u8>>,
    }

    #[cfg(not(target_arch = "wasm32"))]
    static NATIVE: std::sync::OnceLock<std::sync::RwLock<NativeFacts>> = std::sync::OnceLock::new();

    #[cfg(not(target_arch = "wasm32"))]
    fn cell() -> &'static std::sync::RwLock<NativeFacts> {
        NATIVE.get_or_init(|| std::sync::RwLock::new(NativeFacts::default()))
    }

    /// Supply the facts a native build thing will see (the app does this at startup;
    /// a test does it to pin a revision).
    #[cfg(not(target_arch = "wasm32"))]
    pub fn set_native_facts(facts: NativeFacts) {
        if let Ok(mut g) = cell().write() {
            *g = facts;
        }
    }

    /// The facts currently in force natively.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn native_facts() -> NativeFacts {
        cell().read().map(|g| g.clone()).unwrap_or_default()
    }
}

// ── the wasm ABI ─────────────────────────────────────────────────────────────

/// **The plugin call convention.** Both halves live here so they cannot drift: the
/// guest exports are generated by [`export_build_thing!`], and the host's caller is
/// [`crate::plugin_host::PluginHandle`].
///
/// A core `wasm32-unknown-unknown` module exporting:
///
/// | export | signature | meaning |
/// |---|---|---|
/// | `memory` | — | linear memory (the only shared surface) |
/// | `bt_abi_version` | `() -> i32` | [`ABI_VERSION`]; a mismatch is refused, not guessed at |
/// | `bt_alloc` | `(i32) -> i32` | reserve `len` bytes for the host to write an argument into |
/// | `bt_dealloc` | `(i32, i32)` | release a buffer |
/// | `bt_call` | `(op, ptr, len) -> i64` | the one entry point |
///
/// `bt_call` returns the reply as a packed `(ptr << 32) | len`; the reply is always
/// UTF-8 JSON, and the host frees it with `bt_dealloc`. One entry point rather than
/// one export per verb keeps the surface that must be kept in sync to a single
/// signature.
pub mod abi {
    /// `bt_call` op: [`super::Manifest`].
    pub const OP_MANIFEST: u32 = 1;
    /// `bt_call` op: the [`super::BuildVariantSet`] declaration.
    pub const OP_VARIANTS: u32 = 2;
    /// `bt_call` op: [`super::BuildRequest`] in, [`super::Verdict`] out.
    pub const OP_VALIDATE: u32 = 3;
    /// `bt_call` op: [`super::ArtifactFacts`] in, [`super::Verdict`] out.
    pub const OP_JUDGE_ARTIFACT: u32 = 4;
    /// `bt_call` op: a variant name (bare JSON string) in, `Option<RebuildRecipe>` out.
    pub const OP_REBUILD_RECIPE: u32 = 5;
    /// `bt_call` op: the human describe text, as a JSON string.
    pub const OP_DESCRIBE: u32 = 6;
    /// `bt_call` op: the role-grouped [`super::BuildMenu`].
    pub const OP_MENU: u32 = 7;
    /// `bt_call` op: [`super::SpecProvenance`] — the `(repo, rev)` pin.
    pub const OP_PROVENANCE: u32 = 8;
    /// `bt_call` op: a variant name (bare JSON string) in, `Option<BuildPlan>` out.
    pub const OP_PLAN: u32 = 9;
    /// `bt_call` op: a variant name (bare JSON string) in, [`super::BuildGraph`] out.
    pub const OP_GRAPH: u32 = 10;
    /// `bt_call` op: [`super::OptionsQuery`] in, `Option<SettingsTree>` out.
    pub const OP_OPTIONS: u32 = 11;
    /// `bt_call` op: [`ToggleRequest`](crate::tree::ToggleRequest) in,
    /// [`ToggleDecision`](crate::tree::ToggleDecision) out.
    pub const OP_TRY_TOGGLE: u32 = 12;
    /// `bt_call` op: an [`ArtifactKind`](crate::variant::ArtifactKind) (JSON string)
    /// in, `Vec<Action>` out.
    pub const OP_ACTIONS: u32 = 13;

    /// Export name: the ABI probe.
    pub const EXPORT_ABI_VERSION: &str = "bt_abi_version";
    /// Export name: allocate.
    pub const EXPORT_ALLOC: &str = "bt_alloc";
    /// Export name: deallocate.
    pub const EXPORT_DEALLOC: &str = "bt_dealloc";
    /// Export name: the single entry point.
    pub const EXPORT_CALL: &str = "bt_call";
    /// Export name: linear memory.
    pub const EXPORT_MEMORY: &str = "memory";

    /// Pack a reply buffer into `bt_call`'s return value.
    pub fn pack(ptr: u32, len: u32) -> i64 {
        (((ptr as u64) << 32) | (len as u64)) as i64
    }
    /// Unpack `bt_call`'s return value into `(ptr, len)`.
    pub fn unpack(v: i64) -> (u32, u32) {
        let v = v as u64;
        ((v >> 32) as u32, (v & 0xffff_ffff) as u32)
    }
}

/// **Generate the wasm exports for a [`BuildThing`].** One line in a buildspec crate:
///
/// ```ignore
/// nornir_build_thing::export_build_thing!(HolgerBuildThing);
/// ```
///
/// Emits `bt_abi_version` / `bt_alloc` / `bt_dealloc` / `bt_call`, each `#[no_mangle]`
/// and `extern "C"`, under `#[cfg(target_arch = "wasm32")]` — so the same crate is a
/// plain library natively and a plugin when built for wasm, from one source.
///
/// The type must implement [`BuildThing`] + [`Default`].
#[macro_export]
macro_rules! export_build_thing {
    ($ty:ty) => {
        #[cfg(target_arch = "wasm32")]
        mod __build_thing_abi {
            use super::*;

            #[unsafe(no_mangle)]
            pub extern "C" fn bt_abi_version() -> i32 {
                $crate::plugin::ABI_VERSION as i32
            }

            #[unsafe(no_mangle)]
            pub extern "C" fn bt_alloc(len: i32) -> i32 {
                let mut buf: Vec<u8> = Vec::with_capacity(len.max(0) as usize);
                let ptr = buf.as_mut_ptr();
                core::mem::forget(buf);
                ptr as i32
            }

            #[unsafe(no_mangle)]
            pub unsafe extern "C" fn bt_dealloc(ptr: i32, len: i32) {
                if ptr != 0 {
                    drop(Vec::from_raw_parts(ptr as *mut u8, 0, len.max(0) as usize));
                }
            }

            #[unsafe(no_mangle)]
            pub unsafe extern "C" fn bt_call(op: i32, ptr: i32, len: i32) -> i64 {
                let arg: &str = if ptr == 0 || len <= 0 {
                    ""
                } else {
                    let bytes = core::slice::from_raw_parts(ptr as *const u8, len as usize);
                    core::str::from_utf8(bytes).unwrap_or("")
                };
                let bt = <$ty>::default();
                let json = $crate::plugin::dispatch(&bt, op as u32, arg);
                let bytes = json.into_bytes();
                let out_len = bytes.len() as u32;
                let mut boxed = bytes.into_boxed_slice();
                let out_ptr = boxed.as_mut_ptr() as u32;
                core::mem::forget(boxed);
                $crate::plugin::abi::pack(out_ptr, out_len)
            }
        }
    };
}

/// **The guest-side dispatcher** — the body behind `bt_call`, shared by every plugin
/// so no buildspec crate hand-writes op decoding. Public because the
/// [`export_build_thing!`] macro expands to a call to it; not usually called directly.
///
/// Every reply is JSON. A malformed argument is answered with a refusing [`Verdict`],
/// never a trap: a plugin that panics tells the host nothing.
pub fn dispatch<T: BuildThing + ?Sized>(bt: &T, op: u32, arg: &str) -> String {
    fn enc<S: Serialize>(v: &S) -> String {
        serde_json::to_string(v).unwrap_or_else(|e| {
            serde_json::to_string(&Verdict::refuse(format!(
                "plugin could not encode a reply: {e}"
            )))
            .unwrap_or_else(|_| "{\"ok\":false,\"reasons\":[\"encode failed\"]}".to_string())
        })
    }
    match op {
        abi::OP_MANIFEST => enc(&bt.manifest()),
        abi::OP_VARIANTS => enc(&bt.variants()),
        abi::OP_VALIDATE => match serde_json::from_str::<BuildRequest>(arg) {
            Ok(req) => enc(&bt.validate(&req)),
            Err(e) => enc(&Verdict::refuse(format!("unreadable BuildRequest: {e}"))),
        },
        abi::OP_JUDGE_ARTIFACT => match serde_json::from_str::<ArtifactFacts>(arg) {
            Ok(f) => enc(&bt.judge_artifact(&f)),
            Err(e) => enc(&Verdict::refuse(format!("unreadable ArtifactFacts: {e}"))),
        },
        abi::OP_REBUILD_RECIPE => match serde_json::from_str::<String>(arg) {
            Ok(name) => enc(&bt.rebuild_recipe(&name)),
            Err(e) => enc(&Verdict::refuse(format!("unreadable variant name: {e}"))),
        },
        abi::OP_DESCRIBE => enc(&bt.describe()),
        abi::OP_MENU => enc(&bt.menu()),
        abi::OP_PROVENANCE => enc(&bt.provenance()),
        abi::OP_PLAN => match serde_json::from_str::<String>(arg) {
            Ok(name) => enc(&bt.plan(&name)),
            Err(e) => enc(&Verdict::refuse(format!("unreadable variant name: {e}"))),
        },
        abi::OP_GRAPH => match serde_json::from_str::<String>(arg) {
            Ok(name) => enc(&bt.graph(&name)),
            Err(e) => enc(&Verdict::refuse(format!("unreadable variant name: {e}"))),
        },
        abi::OP_OPTIONS => match serde_json::from_str::<OptionsQuery>(arg) {
            Ok(q) => enc(&bt.options(&q.variant, &q.path)),
            Err(e) => enc(&Verdict::refuse(format!("unreadable OptionsQuery: {e}"))),
        },
        abi::OP_TRY_TOGGLE => match serde_json::from_str::<crate::tree::ToggleRequest>(arg) {
            Ok(req) => enc(&bt.try_toggle(&req)),
            Err(e) => enc(&Verdict::refuse(format!("unreadable ToggleRequest: {e}"))),
        },
        abi::OP_ACTIONS => match serde_json::from_str::<crate::variant::ArtifactKind>(arg) {
            Ok(kind) => enc(&bt.actions(kind)),
            Err(e) => enc(&Verdict::refuse(format!("unreadable ArtifactKind: {e}"))),
        },
        other => enc(&Verdict::refuse(format!("unknown build-thing op {other}"))),
    }
}

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

    #[derive(Default)]
    struct Toy;

    impl BuildThing for Toy {
        fn manifest(&self) -> Manifest {
            Manifest {
                abi: ABI_VERSION,
                repo: "toy".into(),
                source_crate: "nornir-buildspec-toy".into(),
                summary: "a toy".into(),
            }
        }
        fn variants(&self) -> BuildVariantSet {
            BuildVariantSet::from_toml_str(
                r#"
[build]
offers = ["a", "b"]
exclusive = [["a", "b"]]
[[build.variant]]
name = "only-a"
features = ["a"]
"#,
            )
            .unwrap()
        }
        /// The app's OWN rule — the thing that does not serialize.
        fn judge_artifact(&self, facts: &ArtifactFacts) -> Verdict {
            if facts.linked_crates.iter().any(|c| c == "reqwest") {
                return Verdict::refuse("a toy artifact must not link reqwest");
            }
            Verdict::ok()
        }
    }

    #[test]
    fn the_default_validate_applies_the_declared_rules() {
        let t = Toy;
        assert!(t.validate(&BuildRequest::variant("only-a")).ok);
        let v = t.validate(&BuildRequest::variant("ghost"));
        assert!(!v.ok);
        assert!(v.reasons[0].contains("no variant 'ghost'"), "{v:?}");
        // An ad-hoc combo is judged too — the "could I build THIS?" question.
        let v = t.validate(&BuildRequest::features(["a", "b"]));
        assert!(!v.ok, "a and b are mutually exclusive");
        assert!(v.reasons[0].contains("mutually exclusive"), "{v:?}");
        let v = t.validate(&BuildRequest::features(["nope"]));
        assert!(!v.ok && v.reasons[0].contains("not offered"), "{v:?}");
    }

    /// **The reason this is code.** `judge_artifact` is a rule only the app knows;
    /// there is no field in any JSON schema that expresses it.
    #[test]
    fn an_apps_own_artifact_rule_runs() {
        let t = Toy;
        let clean = ArtifactFacts {
            linked_crates: vec!["serde".into(), "hyper".into()],
            ..Default::default()
        };
        assert!(t.judge_artifact(&clean).ok);
        let egressing = ArtifactFacts {
            linked_crates: vec!["reqwest".into()],
            ..Default::default()
        };
        let v = t.judge_artifact(&egressing);
        assert!(!v.ok);
        assert!(v.line().contains("must not link reqwest"), "{}", v.line());
    }

    /// The dispatcher is the guest half of the ABI; exercising it natively is
    /// exercising exactly the code the wasm export runs.
    #[test]
    fn dispatch_answers_every_op_in_json() {
        let t = Toy;
        let m: Manifest = serde_json::from_str(&dispatch(&t, abi::OP_MANIFEST, "")).unwrap();
        assert_eq!(m.repo, "toy");
        assert_eq!(m.abi, ABI_VERSION);

        let set: BuildVariantSet =
            serde_json::from_str(&dispatch(&t, abi::OP_VARIANTS, "")).unwrap();
        assert_eq!(set.names(), vec!["only-a"]);

        let req = serde_json::to_string(&BuildRequest::variant("only-a")).unwrap();
        let v: Verdict = serde_json::from_str(&dispatch(&t, abi::OP_VALIDATE, &req)).unwrap();
        assert!(v.ok);

        let facts = serde_json::to_string(&ArtifactFacts {
            linked_crates: vec!["reqwest".into()],
            ..Default::default()
        })
        .unwrap();
        let v: Verdict =
            serde_json::from_str(&dispatch(&t, abi::OP_JUDGE_ARTIFACT, &facts)).unwrap();
        assert!(!v.ok);

        let name = serde_json::to_string("only-a").unwrap();
        let r: Option<RebuildRecipe> =
            serde_json::from_str(&dispatch(&t, abi::OP_REBUILD_RECIPE, &name)).unwrap();
        assert_eq!(r.unwrap().cargo_args, vec!["build", "--features", "a"]);

        let d: String = serde_json::from_str(&dispatch(&t, abi::OP_DESCRIBE, "")).unwrap();
        assert!(d.contains("nornir-buildspec-toy"));
    }

    /// The three tree ops answer over the same dispatcher — the tree, the
    /// judge and the verbs — and a refusal of `fetch` is a NAMED error natively.
    #[test]
    fn the_tree_ops_dispatch_and_fetch_refuses_by_name() {
        let t = Toy;
        let q = serde_json::to_string(&OptionsQuery { variant: "only-a".into(), path: String::new() }).unwrap();
        let tree: Option<crate::tree::SettingsTree> =
            serde_json::from_str(&dispatch(&t, abi::OP_OPTIONS, &q)).unwrap();
        let tree = tree.expect("a declared variant has a tree");
        assert_eq!(tree.variant, "only-a");
        assert!(tree.paths().is_empty(), "a variant with no `open` axis has nothing to switch: {:?}", tree.paths());
        let q = serde_json::to_string(&OptionsQuery { variant: "ghost".into(), path: String::new() }).unwrap();
        let none: Option<crate::tree::SettingsTree> =
            serde_json::from_str(&dispatch(&t, abi::OP_OPTIONS, &q)).unwrap();
        assert!(none.is_none());

        let req = serde_json::to_string(&crate::tree::ToggleRequest::new("only-a", "a", false)).unwrap();
        let d: crate::tree::ToggleDecision =
            serde_json::from_str(&dispatch(&t, abi::OP_TRY_TOGGLE, &req)).unwrap();
        assert!(!d.verdict.ok && d.verdict.line().contains("not a setting"), "an always-on feature is not a toggle: {d:?}");
        let req = serde_json::to_string(&crate::tree::ToggleRequest::new("ghost", "a", false)).unwrap();
        let d: crate::tree::ToggleDecision =
            serde_json::from_str(&dispatch(&t, abi::OP_TRY_TOGGLE, &req)).unwrap();
        assert!(!d.verdict.ok && d.verdict.line().contains("no variant 'ghost'"), "{d:?}");

        let kind = serde_json::to_string(&crate::variant::ArtifactKind::Kernel).unwrap();
        let a: Vec<crate::verbs::Action> = serde_json::from_str(&dispatch(&t, abi::OP_ACTIONS, &kind)).unwrap();
        assert_eq!(a, vec![crate::verbs::Action::Boot]);

        let e = host::fetch("tunnr/tunnr-kernel").unwrap_err();
        assert_eq!(e.coordinate, "tunnr/tunnr-kernel");
        assert!(e.to_string().contains("refused by the host"), "{e}");
        host::set_native_facts(host::NativeFacts {
            fetchables: [("tunnr/tunnr-kernel".to_string(), b"bzImage".to_vec())].into_iter().collect(),
            ..Default::default()
        });
        let got = host::fetch("tunnr/tunnr-kernel").unwrap();
        assert_eq!(got.bytes, b"bzImage");
        assert_eq!(got.origin, host::FetchOrigin::HostException, "a fixture is a host exception, never the container");
        host::set_native_facts(host::NativeFacts::default());
    }

    /// A plugin must never trap on bad input — a module that panics tells the host
    /// nothing, and the host cannot tell "hostile" from "buggy" from "old".
    #[test]
    fn a_malformed_argument_is_refused_not_trapped() {
        let t = Toy;
        let v: Verdict = serde_json::from_str(&dispatch(&t, abi::OP_VALIDATE, "not json")).unwrap();
        assert!(!v.ok);
        assert!(v.reasons[0].contains("unreadable BuildRequest"), "{v:?}");

        let v: Verdict = serde_json::from_str(&dispatch(&t, 999, "")).unwrap();
        assert!(!v.ok);
        assert!(v.reasons[0].contains("unknown build-thing op 999"), "{v:?}");
    }

    #[test]
    fn the_abi_pack_round_trips() {
        for (p, l) in [
            (0u32, 0u32),
            (1, 1),
            (0xdead_beef, 0x1234),
            (u32::MAX, u32::MAX),
        ] {
            assert_eq!(abi::unpack(abi::pack(p, l)), (p, l));
        }
    }

    #[test]
    fn a_refusal_can_never_be_silent() {
        let v = Verdict {
            ok: false,
            reasons: Vec::new(),
        };
        assert!(
            v.line().contains("no reason given"),
            "a refusal with no reason must SAY it has none: {}",
            v.line()
        );
        assert_eq!(
            Verdict::ok().and(Verdict::refuse("x")).ok,
            false,
            "and() is the AND"
        );
    }

    // ───────────────── the SUITE axis, as the menu renders it ───────────────────

    /// A build thing whose declaration ships TWO deliveries out of one crate graph —
    /// the holger shape, minus holger.
    struct TwoDeliveries(&'static str);

    impl BuildThing for TwoDeliveries {
        fn manifest(&self) -> Manifest {
            Manifest {
                abi: ABI_VERSION,
                repo: "two".into(),
                source_crate: "nornir-buildspec-two".into(),
                summary: "two deliveries".into(),
            }
        }
        fn variants(&self) -> BuildVariantSet {
            BuildVariantSet::from_toml_str(self.0).unwrap()
        }
    }

    /// **A menu section is a `(suite, role)` group, not a role.** Two deliveries each
    /// get their own server section with their own normal case, and the shared atom
    /// appears in both client sections — because it ships in both.
    #[test]
    fn the_menu_gives_each_delivery_its_own_section() {
        let m = TwoDeliveries(
            r#"
[build]
base_suite = "full"

[[build.variant]]
name = "holger-full"
role = "server"
suites = ["full"]
default = true

[[build.variant]]
name = "holger-mvp"
role = "server"
suites = ["mvp"]
default = true

[[build.variant]]
name = "agent-online"
role = "client"
suites = ["full", "mvp"]
default = true
"#,
        )
        .menu();

        let sections: Vec<(Option<&str>, &str, Vec<&str>, Option<&str>)> = m
            .roles
            .iter()
            .map(|g| {
                (
                    g.suite.as_deref(),
                    g.role.as_str(),
                    g.variants.iter().map(|s| s.as_str()).collect(),
                    g.default_variant.as_deref(),
                )
            })
            .collect();
        assert_eq!(
            sections,
            vec![
                (
                    Some("full"),
                    "server",
                    vec!["holger-full"],
                    Some("holger-full")
                ),
                (
                    Some("mvp"),
                    "server",
                    vec!["holger-mvp"],
                    Some("holger-mvp")
                ),
                (
                    Some("full"),
                    "client",
                    vec!["agent-online"],
                    Some("agent-online")
                ),
                (
                    Some("mvp"),
                    "client",
                    vec!["agent-online"],
                    Some("agent-online")
                ),
            ],
            "four sections; the shared atom is in two of them"
        );
        assert!(m.check_defaults().is_empty(), "{:?}", m.check_defaults());
        // One ENTRY per declared variant — the shared atom is listed twice in the
        // menu but is one build point, not two.
        assert_eq!(m.entries.len(), 3);
    }

    /// RED-WHEN-BROKEN: a delivery with variants and NO default is named BY SUITE in
    /// the menu-level message, not just by role — otherwise the reader is told
    /// "role 'server' has no default" about a repo whose other server section is fine.
    #[test]
    fn the_menu_names_the_defaultless_delivery() {
        let m = TwoDeliveries(
            r#"
[build]
base_suite = "full"

[[build.variant]]
name = "holger-full"
role = "server"
suites = ["full"]
default = true

[[build.variant]]
name = "holger-mvp"
role = "server"
suites = ["mvp"]
"#,
        )
        .menu();
        let problems = m.check_defaults();
        assert_eq!(problems.len(), 1, "{problems:?}");
        assert!(problems[0].contains("suite 'mvp'"), "{problems:?}");
        assert!(problems[0].contains("NO default"), "{problems:?}");
        assert!(!problems[0].contains("suite 'full'"), "{problems:?}");
    }

    /// ★ The two `check_defaults` implementations must not CONTRADICT. A declaration
    /// marking two defaults in one role is rejected by
    /// [`BuildVariantSet::check_defaults`]; the menu used to render only the section's
    /// elected default as `is_default`, so the menu-level checker counted ONE and went
    /// green on the very same file.
    #[test]
    fn a_double_default_cannot_be_green_in_the_menu_and_red_in_the_set() {
        let t = TwoDeliveries(
            r#"
[build]
[[build.variant]]
name = "a"
role = "server"
default = true

[[build.variant]]
name = "b"
role = "server"
default = true
"#,
        );
        let set = t.variants();
        let m = t.menu();

        // The set-level rule says two.
        let set_problems = set.check_defaults();
        assert_eq!(set_problems.len(), 1, "{set_problems:?}");
        assert!(
            set_problems[0].contains("marks 2 defaults"),
            "{set_problems:?}"
        );

        // RED-WHEN-BROKEN: the section elects exactly ONE — reading only that is what
        // made the menu-level checker blind.
        assert_eq!(m.roles[0].default_variant.as_deref(), Some("a"));
        assert_eq!(
            m.roles[0]
                .variants
                .iter()
                .filter(|v| m.roles[0].default_variant.as_deref() == Some(v.as_str()))
                .count(),
            1,
            "the section names one — the old `is_default` derivation stopped here"
        );

        // The entries now carry what the DECLARATION said, so the menu agrees.
        assert_eq!(m.entries.iter().filter(|e| e.is_default).count(), 2);
        let menu_problems = m.check_defaults();
        assert_eq!(menu_problems.len(), 1, "{menu_problems:?}");
        assert!(
            menu_problems[0].contains("marks 2 defaults"),
            "{menu_problems:?}"
        );
    }

    /// The suite-LESS menu is unchanged: no section names a suite, and a build thing
    /// with no role axis still gets its single all-variants section.
    #[test]
    fn a_suiteless_menu_names_no_delivery() {
        let m = Toy.menu();
        assert!(m.roles.iter().all(|g| g.suite.is_none()), "{:?}", m.roles);
        assert_eq!(m.roles.len(), 1);
        assert_eq!(m.roles[0].variants, vec!["only-a"]);
        assert_eq!(m.roles[0].default_variant.as_deref(), Some("only-a"));
        assert!(m.check_defaults().is_empty(), "{:?}", m.check_defaults());
        // The message shape for a suite-less repo must not grow a `suite '…'` prefix.
        let bare = TwoDeliveries(
            "[build]\n[[build.variant]]\nname = \"a\"\nrole = \"server\"\n\
             [[build.variant]]\nname = \"b\"\nrole = \"server\"\n",
        )
        .menu();
        let problems = bare.check_defaults();
        assert_eq!(problems.len(), 1, "{problems:?}");
        assert!(problems[0].starts_with("role 'server'"), "{problems:?}");
        assert!(!problems[0].contains("suite"), "{problems:?}");
    }

    // ── the solo role ────────────────────────────────────────────────────────

    /// `role = "solo"` parses, round-trips and labels itself.
    #[test]
    fn solo_is_a_declarable_role() {
        assert_eq!(Role::Solo.as_str(), "solo");
        let r: Role = serde_json::from_str("\"solo\"").expect("parse");
        assert_eq!(r, Role::Solo);
        assert_eq!(serde_json::to_string(&Role::Solo).expect("ser"), "\"solo\"");
        // It is NOT quietly aliased onto an existing role — the whole point.
        assert_ne!(Role::Solo, Role::Server);
        assert_ne!(Role::Solo, Role::Client);
    }

    /// ★ **The reason `Solo` had to be a role and not a note in a comment.**
    ///
    /// `check_defaults` demands exactly one default PER GROUP. Before `Solo`
    /// existed, a repo's UI client and its self-contained solo build had to share
    /// the `client` group — so declaring both as normal was a hard error, and the
    /// repo had to pick one and leave the other with no declared normal case. RED
    /// first with the two crammed into `client`, GREEN once solo is its own group.
    #[test]
    fn solo_needs_its_own_group_to_hold_its_own_default() {
        // RED: two defaults in one `client` group — the shape a repo was forced into.
        let crammed = TwoDeliveries(
            "[build]\n\
             [[build.variant]]\nname = \"korp-ui\"\nrole = \"client\"\ndefault = true\n\
             [[build.variant]]\nname = \"korp-solo\"\nrole = \"client\"\ndefault = true\n",
        )
        .menu();
        let problems = crammed.check_defaults();
        assert_eq!(problems.len(), 1, "{problems:?}");
        assert!(problems[0].contains("marks 2 defaults"), "{problems:?}");
        assert!(problems[0].contains("korp-ui, korp-solo"), "{problems:?}");

        // GREEN: solo is its own group, and each names its own normal case.
        let split = TwoDeliveries(
            "[build]\n\
             [[build.variant]]\nname = \"korp-ui\"\nrole = \"client\"\ndefault = true\n\
             [[build.variant]]\nname = \"korp-solo\"\nrole = \"solo\"\ndefault = true\n",
        )
        .menu();
        assert!(
            split.check_defaults().is_empty(),
            "{:?}",
            split.check_defaults()
        );
        assert_eq!(split.roles.len(), 2, "{:?}", split.roles);
        assert_eq!(split.in_role(Role::Solo), vec!["korp-solo"]);
        assert_eq!(split.in_role(Role::Client), vec!["korp-ui"]);
        assert_eq!(split.role_of("korp-solo"), Some(Role::Solo));
    }
}

// ── the graph: what a build IS, as a picture's worth of data ─────────────────

/// What a [`GraphNode`] stands for. A picture answers "what does this variant
/// compile in" far better than a table, and the node kind is what lets a renderer
/// colour that answer categorically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NodeKind {
    /// The variant being built — the root of the picture.
    Variant,
    /// One step of its plan (a program + argv).
    Step,
    /// A file the plan is expected to produce.
    Artifact,
    /// A sibling **compiled IN** (a fat build's `embeds`).
    Embedded,
    /// A sibling **reached over gRPC** (a thin build's `reaches`) — needs it running.
    Reached,
    /// A source file (or cargo target) carrying a `cfg` gate — see
    /// [`crate::gating`]. Its `detail` names the gate and whether THIS variant
    /// compiles it in, gates it out, or leaves it undecided.
    SourceFile,
}

impl NodeKind {
    /// Stable label.
    pub fn as_str(self) -> &'static str {
        match self {
            NodeKind::Variant => "variant",
            NodeKind::Step => "step",
            NodeKind::Artifact => "artifact",
            NodeKind::Embedded => "embedded",
            NodeKind::Reached => "reached",
            NodeKind::SourceFile => "source-file",
        }
    }
}

/// What an edge means. A renderer draws `Reaches` differently (dashed — it is a
/// runtime dependency, not a compile-time one), which is the whole fat/thin story
/// in one visual distinction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EdgeKind {
    /// variant → step, or step → step: the plan's order.
    Plan,
    /// step → artifact: what the step produces.
    Produces,
    /// variant → sibling **compiled in**. Solid: it is in the binary.
    Embeds,
    /// variant → sibling **reached over gRPC**. Dashed: it must be RUNNING.
    Reaches,
    /// variant → a source file its feature set gates. The layer that answers "what
    /// code is actually IN this build" — see [`crate::gating`].
    Gates,
}

impl EdgeKind {
    /// Stable label.
    pub fn as_str(self) -> &'static str {
        match self {
            EdgeKind::Plan => "plan",
            EdgeKind::Produces => "produces",
            EdgeKind::Embeds => "embeds",
            EdgeKind::Reaches => "reaches",
            EdgeKind::Gates => "gates",
        }
    }
    /// Whether a renderer should dash it — a runtime edge, not a compile-time one.
    pub fn dashed(self) -> bool {
        matches!(self, EdgeKind::Reaches)
    }
}

/// One node of a [`BuildGraph`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphNode {
    /// Stable id, unique within the graph — what a click returns.
    pub id: String,
    /// What to draw on it.
    pub label: String,
    /// What it stands for.
    pub kind: NodeKind,
    /// A second line: the argv for a step, the runtime cost for a variant, the
    /// expected path for an artifact. Optional; a renderer may show it on hover.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
    /// For a [`NodeKind::Variant`] node: the variant id to select when it is clicked.
    /// This is why the graph is data — a picture you can build from.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub selects: Option<String>,
}

/// One edge of a [`BuildGraph`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphEdge {
    /// Source node id.
    pub from: String,
    /// Target node id.
    pub to: String,
    /// What it means.
    pub kind: EdgeKind,
    /// An optional mid-edge label.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
}

/// **What a build IS, as a graph.** Returned by [`BuildThing::graph`].
///
/// # Why data and not SVG
///
/// SVG was the other option and is the wrong one. facett renders egui natively, so
/// an SVG would mean shipping a parser and a rasteriser, and it would throw away
/// hit-testing, hover, selection, zoom and live status — everything that makes the
/// picture worth having. **A node the user can click to build is worth more than a
/// picture of one.** The plugin describes; `facett-graphpan` lays out and paints.
///
/// It is plain serde with no egui type anywhere near it, because it crosses the wasm
/// boundary — the plugin *cannot* draw, and should not be able to.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildGraph {
    /// The repo this graph belongs to.
    pub repo: String,
    /// The variant at its root (empty for a whole-menu overview).
    pub root: String,
    /// The nodes.
    pub nodes: Vec<GraphNode>,
    /// The edges.
    pub edges: Vec<GraphEdge>,
}

impl BuildGraph {
    /// Node ids, in order.
    pub fn node_ids(&self) -> Vec<&str> {
        self.nodes.iter().map(|n| n.id.as_str()).collect()
    }
    /// Edges of one kind.
    pub fn edges_of(&self, kind: EdgeKind) -> Vec<&GraphEdge> {
        self.edges.iter().filter(|e| e.kind == kind).collect()
    }
    /// The siblings this build **compiles in** — the fat story, in one call.
    pub fn embedded(&self) -> Vec<&str> {
        self.nodes
            .iter()
            .filter(|n| n.kind == NodeKind::Embedded)
            .map(|n| n.label.as_str())
            .collect()
    }
    /// The siblings this build **needs running** — the thin story.
    pub fn reached(&self) -> Vec<&str> {
        self.nodes
            .iter()
            .filter(|n| n.kind == NodeKind::Reached)
            .map(|n| n.label.as_str())
            .collect()
    }
    /// Every edge points at a declared node — the oracle that a renderer can lay this
    /// out without inventing a node to hang a dangling edge from.
    pub fn dangling_edges(&self) -> Vec<String> {
        let ids: std::collections::BTreeSet<&str> = self.node_ids().into_iter().collect();
        self.edges
            .iter()
            .filter(|e| !ids.contains(e.from.as_str()) || !ids.contains(e.to.as_str()))
            .map(|e| format!("{} -{}-> {}", e.from, e.kind.as_str(), e.to))
            .collect()
    }
}