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

//! The unified **`assemble()` seam** (design §f7) — the ONE polymorphic entry
//! point that turns a declared [`BuildVariant`] into a single content-addressed
//! **receipt** ([`Assembled`]).
//!
//! f1 declares *what can be built*; f5 adds *what kind of thing* it is; f3 records
//! *what happened*; build-info streams *the record about it*. **f7 composes them**:
//! [`assemble`] reads a chosen variant, dispatches on its
//! [`ArtifactKind`](crate::variant::ArtifactKind), drives the matching *existing*
//! builder, and hands back ONE value binding the three halves — the produced thing
//! ([`ArtifactOutput`]), the proof ([`AssembledProof`]), and the build-info
//! lineage ([`crate::build_info::BuildInfoRow`]s carrying each artifact's
//! `content_id`).
//!
//! # Invents nothing (REUSE-FIRST)
//!
//! | f7 needs | reuses |
//! |---|---|
//! | dispatch discriminant | [`ArtifactKind`](crate::variant::ArtifactKind) + [`output_of`](crate::output::output_of) |
//! | the actual build | [`build_variant_with_info`](crate::provenance::build_variant_with_info) / [`build_container`](crate::image_build::build_container) / [`build_vm`](crate::image_build::build_vm) |
//! | proof | [`Attestation`](crate::provenance::Attestation) / [`TargetAttestation`](crate::image_build::TargetAttestation) |
//! | lineage | [`manifest_rows`](crate::build_info::manifest_rows) / [`target_manifest_rows`](crate::build_info::target_manifest_rows) / [`build_feed_rows`](crate::build_info::build_feed_rows) |
//!
//! # The composite payoff (the fat/thin axis, made executable)
//!
//! A fat variant that `embeds` a sibling (or a container that chains a cargo
//! `from_variant` layer) declares a sub-build DAG. [`assemble`] **topo-assembles**
//! the parts (the embedded lib first), threads each sub-artifact's `content_id`
//! into the parent's `dep` rows — so *"which artifact fed which build"* is a join,
//! not a guess — and rejects a cycle with a clear error.

use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::sync::Mutex;
use std::time::Instant;

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

use crate::analyze::ArtifactAnalyzer;
use crate::build_info::{
    build_feed_rows, manifest_rows, now_micros, target_manifest_rows, BuildInfoKind, BuildInfoRow,
    BuildInfoSink, NullBuildInfoSink,
};
use crate::container::ContainerBuild;
use crate::image_build::{
    build_container as image_build_container, build_iso as image_build_iso,
    build_vm as image_build_vm, ImageBuilder, IsoImageBuilder, TargetAttestation,
    TargetBuildOutcome, VmImageBuilder,
};
use crate::iso::IsoBuild;
use crate::output::{output_of, ArtifactOutput};
use crate::provenance::{
    build_variant_with_info, Attestation, BuildOutcome, BuildProvenance, Outcome, Toolchain,
};
use crate::variant::{ArtifactKind, BuildVariant, BuildVariantSet};
use crate::vm::VmBuild;

/// The **proof** half of an [`Assembled`] receipt — the smallest union that keeps
/// BOTH attestation flavours whole. A cargo build (Exe / Lib / Wasm / Service)
/// yields a [`crate::provenance::Attestation`]; an image target (Container / Vm)
/// yields a [`crate::image_build::TargetAttestation`]. Neither is lossily flattened
/// into the other — each seals over its own produced-artifact digest.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AssembledProof {
    /// A cargo-build attestation (Exe / Lib / Wasm / Service).
    Build(Attestation),
    /// An image/VM target attestation (Container / Vm).
    Target(TargetAttestation),
}

impl AssembledProof {
    /// Lowercase-hex form of the canonical digest, whichever flavour.
    pub fn digest_hex(&self) -> String {
        match self {
            AssembledProof::Build(a) => a.digest_hex(),
            AssembledProof::Target(a) => a.digest_hex(),
        }
    }

    /// True once a real cryptographic seal has been applied.
    pub fn is_sealed(&self) -> bool {
        match self {
            AssembledProof::Build(a) => a.is_sealed(),
            AssembledProof::Target(a) => a.is_sealed(),
        }
    }

    /// Recompute the digest from the record and confirm it still matches (the
    /// tamper check — a mutated artifact hash breaks this).
    pub fn digest_matches_record(&self) -> Result<bool> {
        match self {
            AssembledProof::Build(a) => a.digest_matches_record(),
            AssembledProof::Target(a) => a.digest_matches_record(),
        }
    }

    /// The provenance record id — the `build_id` its lineage rows are keyed by.
    pub fn build_id(&self) -> &str {
        match self {
            AssembledProof::Build(a) => &a.record.id,
            AssembledProof::Target(a) => &a.record.id,
        }
    }

    /// The [`ArtifactKind`] this proof attests to.
    pub fn kind(&self) -> ArtifactKind {
        match self {
            AssembledProof::Build(a) => a.record.variant.kind,
            AssembledProof::Target(a) => a.record.kind,
        }
    }

    /// **Did the build this proof attests to actually succeed?**
    ///
    /// Read off the sealed record's own `outcome`, whichever arm — not off a flag a
    /// caller carried alongside it and not off whether [`assemble`] returned `Ok`.
    /// `Ok` from [`assemble`] means *the build reached a terminal outcome and here is
    /// its record*: a red cargo build has always returned `Ok` this way, and a red
    /// container / ISO / VM build now does too, which is what lets a failure be
    /// filed. A caller that renders or files a receipt must therefore ask THIS rather
    /// than assume, and there is exactly one thing to ask so the two arms cannot
    /// diverge again.
    pub fn outcome(&self) -> Outcome {
        match self {
            AssembledProof::Build(a) => a.record.outcome,
            AssembledProof::Target(a) => a.record.outcome,
        }
    }

    /// **The one record a finished build FILES, whichever flavour it is.**
    ///
    /// The warehouse holds a single build-record ledger (`build_provenance`) fed by a
    /// single submit, so a producer needs one shape for both arms of this enum. A
    /// cargo build hands over its record untouched; an image / ISO / VM build is
    /// projected through
    /// [`TargetProvenance::to_build_provenance`](crate::image_build::TargetProvenance::to_build_provenance).
    ///
    /// This exists so a caller cannot get it half-right: before it, the only way to
    /// file a target build was to reach into the `Build` arm and drop the `Target`
    /// one, which is precisely what left ⚒ Build Thing's records pane empty while
    /// containers and ISOs built perfectly well.
    ///
    /// `variant` is the DECLARED cell — the target record carries the descriptor's
    /// name, not the build point's — and is ignored for the cargo arm, whose record
    /// already holds its own.
    pub fn as_build_provenance(&self, variant: &BuildVariant) -> BuildProvenance {
        match self {
            AssembledProof::Build(a) => a.record.clone(),
            AssembledProof::Target(a) => a.record.to_build_provenance(variant),
        }
    }
}

/// **What the dispatched builder actually reported**, carried out of [`dispatch`] so
/// the receipt can be ASKED whether it produced anything.
///
/// It exists because [`crate::image_build::capture_target_build`] deliberately RECORDS
/// a builder failure instead of propagating it — a red target build has to return `Ok`
/// so its provenance can still be filed. That contract is right, and until 2026-08-05
/// it had no second half: the three target arms of [`dispatch`] read
/// `TargetBuildOutcome::success` only to fill a feed row and dropped
/// `TargetBuildOutcome::error` on the floor. The reason a build failed was constructed,
/// formatted and then thrown away inside this crate, so no caller could print it and
/// none did.
///
/// MEASURED, gunnar's `gunnar-appliance-iso`, 2026-08-05: two runs, 38.5 s and 0.9 s,
/// two different digests, `BUILT` and exit 0 both times, and no `.iso` written by
/// either. The composer had failed on its first file and said so into this value.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProducedVerdict {
    /// The builder's OWN error text when it failed — `None` on a green build.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    /// WHERE the produced thing was expected: a mastered ISO's output path, an image
    /// reference, the binaries a cargo cell declares. Named by the arm that dispatched
    /// the build, because that is the only code that knows — and printed by
    /// [`Assembled::produced`] when nothing came out, so "produced nothing" is a
    /// sentence with a path in it rather than a count.
    #[serde(default)]
    pub expected: String,
}

/// The **content-addressed receipt** [`assemble`] returns: ONE value binding the
/// produced thing, the proof, and the build-info lineage. "What I built" and
/// "proof + lineage of what I built" become a single receipt.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Assembled {
    /// WHAT — the produced artifact's output descriptor (kind-tagged).
    pub output: ArtifactOutput,
    /// PROOF — the (unsealed) attestation, folded to a canonical digest.
    pub attestation: AssembledProof,
    /// The build-info record ABOUT it — the `provenance` / `dep` / `artifact` +
    /// live-feed rows, keyed by `build_id`, each artifact row carrying its
    /// `content_id` (so lineage graphs across composite builds).
    pub lineage: Vec<BuildInfoRow>,
    /// WHETHER it produced anything, and what it was supposed to produce. Read it
    /// through [`Assembled::produced`], never field by field.
    #[serde(default)]
    pub verdict: ProducedVerdict,
}

impl Assembled {
    /// The [`ArtifactKind`] of the produced output.
    pub fn kind(&self) -> ArtifactKind {
        self.output.kind()
    }

    /// The canonical digest of the proof, lowercase-hex.
    pub fn digest_hex(&self) -> String {
        self.attestation.digest_hex()
    }

    /// **Did this build succeed?** Taken from the sealed record
    /// ([`AssembledProof::outcome`]), which is the only thing that knows.
    ///
    /// A receipt exists for a red build as well as a green one — that is the point of
    /// it — so a UI that reports `success: true` because it holds a receipt is
    /// reporting that it holds a receipt.
    pub fn success(&self) -> bool {
        self.attestation.outcome() == Outcome::Success
    }

    /// The `content_id`s (sha256 CAS ids) of every produced `artifact` row in the
    /// lineage — the keys a parent build threads into its `dep` rows.
    pub fn artifact_content_ids(&self) -> Vec<String> {
        self.lineage
            .iter()
            .filter(|r| r.kind == BuildInfoKind::Artifact.as_str())
            .map(|r| r.content_id.clone())
            .filter(|c| !c.is_empty())
            .collect()
    }

    /// The PATHS of every produced `artifact` row — the same rows
    /// [`artifact_content_ids`](Self::artifact_content_ids) counts, read by their key
    /// instead of their value, so a consumer can `stat` what the build says it wrote
    /// rather than believe the count. Relative to the repo root when the file lives
    /// under it (see [`crate::provenance::Artifact::path`]).
    pub fn artifact_paths(&self) -> Vec<String> {
        self.lineage
            .iter()
            .filter(|r| r.kind == BuildInfoKind::Artifact.as_str())
            .map(|r| r.name.clone())
            .filter(|p| !p.is_empty())
            .collect()
    }

    /// **Did this receipt produce a thing?** — the ONE writer for that question, and
    /// the answer every consumer of [`assemble`] must key on instead of on holding a
    /// receipt at all.
    ///
    /// Two reds, and they are different diagnoses:
    ///
    /// * the builder FAILED, and its own words are in
    ///   [`ProducedVerdict::error`] — the value that used to be discarded inside this
    ///   crate, which is why `dwarves buildthing gunnar --atom gunnar-appliance-iso`
    ///   printed `BUILT … — 0 artifact(s)` and exited 0 over a composer that had
    ///   bailed on its first step;
    /// * the builder reported SUCCESS and produced NOTHING. The artifact list is the
    ///   only evidence a build ever offers, so an empty one is a red — named with
    ///   [`ProducedVerdict::expected`], because "0 artifacts" is a number and
    ///   "no `<repo>/.iso-out/gunnar-appliance-iso.iso`" is a place to look.
    ///
    /// A `lib` / `wasm` cell produces a real rlib cargo never reports as an
    /// executable ([`ArtifactKind::produces_no_executable`]), and a `container`
    /// image lives in the engine's store with no path to stat
    /// ([`ArtifactKind::product_is_not_a_file`]) — for those the empty list is what a
    /// perfectly good build looks like and only the first red applies. Asking the
    /// KIND is what tells "nothing came out" apart from "nothing was ever going to".
    /// An `iso` is neither: the media IS the artifact, and it is a file.
    pub fn produced(&self) -> Result<()> {
        if let Some(e) = &self.verdict.error {
            bail!(
                "{} '{}' FAILED and produced nothing: {e}",
                self.kind().as_str(),
                self.output.name(),
            );
        }
        if !self.success() {
            bail!(
                "{} '{}' did not succeed (outcome {:?}) — no artifact was produced",
                self.kind().as_str(),
                self.output.name(),
                self.attestation.outcome(),
            );
        }
        // The two kinds for which an empty artifact list is what a GOOD build looks
        // like, asked of the discriminant rather than listed here (L5): a `lib` /
        // `wasm` cell produces a real rlib cargo never reports as an executable, and
        // a `container` image lives in the engine's store with no path to stat. An
        // `iso` is neither — the media IS the artifact, and it is a file.
        if self.artifact_content_ids().is_empty()
            && !self.kind().produces_no_executable()
            && !self.kind().product_is_not_a_file()
        {
            bail!(
                "{} '{}' reported SUCCESS and produced NO artifact — expected {}. \
                 A build that wrote nothing is not a build that succeeded.",
                self.kind().as_str(),
                self.output.name(),
                if self.verdict.expected.is_empty() {
                    "an artifact this build point declares"
                } else {
                    self.verdict.expected.as_str()
                },
            );
        }
        Ok(())
    }
}

/// The **runtime gate** result — [`precheck`]'s answer. Where
/// [`BuildVariantSet::validate`] proves *config*-validity, this proves *reality*:
/// toolchain present, `--target` installed, engine socket up. Feeds f4's `valid`
/// flag; NEVER hard-fails — it RETURNS the reasons.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Precheck {
    /// Everything the build needs is present.
    Ready,
    /// One or more runtime prerequisites are missing (human-readable reasons).
    Missing(Vec<String>),
}

impl Precheck {
    /// True when nothing is missing.
    pub fn is_ready(&self) -> bool {
        matches!(self, Precheck::Ready)
    }

    /// The missing-prerequisite reasons (empty when [`Ready`](Self::Ready)).
    pub fn reasons(&self) -> &[String] {
        match self {
            Precheck::Ready => &[],
            Precheck::Missing(r) => r,
        }
    }
}

/// The **builder-injection seam** — the verb-side of f7. `assemble` reaches the
/// three real builders through this thin trait so tests inject fakes (no real
/// cargo / docker / kvm). Every method has a real default; a test overrides only
/// what it exercises.
///
/// * cargo builds default to [`build_variant_with_info`];
/// * container / VM builds default to a clear "no builder injected" error — the
///   concrete [`Builders`] wires the real [`ImageBuilder`] / [`VmImageBuilder`];
/// * the availability probes drive [`precheck`].
pub trait Assemble {
    /// Build a cargo variant (Exe / Lib / Wasm / Service). Default = the real
    /// [`build_variant_with_info`]; the injected `sink` receives its build-info.
    fn build_cargo(
        &self,
        set: &BuildVariantSet,
        name: &str,
        repo_root: &Path,
        sink: &dyn BuildInfoSink,
    ) -> Result<BuildOutcome> {
        build_variant_with_info(set, name, repo_root, sink)
    }

    /// Build a container image. Default errors (no builder injected); [`Builders`]
    /// drives the real [`build_container`](crate::image_build::build_container).
    fn build_container(
        &self,
        _image: &ContainerBuild,
        _repo: &str,
        _repo_root: &Path,
    ) -> Result<TargetBuildOutcome> {
        bail!("assemble: kind 'container' needs an injected ImageBuilder (use `Builders::with_image`)")
    }

    /// Build a VM appliance. Default errors (no builder injected); [`Builders`]
    /// drives the real [`build_vm`](crate::image_build::build_vm).
    fn build_vm(&self, _vm: &VmBuild, _repo: &str, _repo_root: &Path) -> Result<TargetBuildOutcome> {
        bail!("assemble: kind 'vm' needs an injected VmImageBuilder (use `Builders::with_vm`)")
    }

    /// Build a bootable ISO. Default errors (no builder injected); [`Builders`]
    /// drives the real [`build_iso`](crate::image_build::build_iso).
    fn build_iso(&self, _iso: &IsoBuild, _repo: &str, _repo_root: &Path) -> Result<TargetBuildOutcome> {
        bail!("assemble: kind 'iso' needs an injected IsoImageBuilder (use `Builders::with_iso`)")
    }

    /// **Compose an installer ISO.** Default errors (no composer injected), and the
    /// refusal keeps naming the door — this crate still links no composer, and must
    /// not: composing needs a kernel and an app payload, which are the composer's
    /// inputs to resolve and not facts an assembly descriptor carries.
    ///
    /// What changed on 2026-08-16 is that the refusal is now a MISSING INJECTION
    /// rather than a closed kind. `dispatch` used to `bail!` on
    /// [`ArtifactKind::Installer`] before any builder could be consulted, so a
    /// consumer that DOES link a composer (dwarves, through
    /// `korp_installer::isocompose::ForgeComposer`) had no way to hand it in — the
    /// declared cell was listable, plannable and unbuildable, and the ⚒ pane's BYGG
    /// fell all the way through to the cargo door and reported
    /// `no build variant 'gunnar-installer'`.
    fn build_installer(
        &self,
        _cell: &crate::installer::InstallerBuild,
        _repo: &str,
        _repo_root: &Path,
    ) -> Result<TargetBuildOutcome> {
        bail!(
            "assemble: kind 'installer' is COMPOSED, and no composer is injected (use \
             `Builders::with_installer`). This crate links none on purpose — master it \
             through the product's compose verb (`dwarves buildthing <product> --installer`) \
             or inject one."
        )
    }

    /// **Compile a Linux kernel.** Default errors (no builder injected);
    /// [`Builders`] drives the injected
    /// [`KernelBuilder`](crate::kernel::KernelBuilder) through the existing
    /// [`build_kernel`](crate::image_build::build_kernel) free fn. This crate
    /// links no container engine by default — the production builder
    /// ([`ContainerKernelBuilder`](crate::kernel::ContainerKernelBuilder) over
    /// draupnir's `backend-oci`, feature `kernel-build`) is the consumer's to
    /// hand in.
    fn build_kernel(
        &self,
        _kernel: &crate::kernel::KernelBuild,
        _repo: &str,
        _repo_root: &Path,
    ) -> Result<TargetBuildOutcome> {
        bail!(
            "assemble: kind 'kernel' needs an injected KernelBuilder (use \
             `Builders::with_kernel` — the production one is \
             ContainerKernelBuilder over draupnir's container backend, feature \
             `kernel-build`)"
        )
    }

    /// **Assemble a distribution package.** Default errors (no builder injected).
    ///
    /// The same shape as `build_installer` and for the same reason: assembling a
    /// wheel means running the format's own tool inside a pinned container, and
    /// this dep-light crate links no packaging toolchain and must not — a leaf's
    /// xtask consuming these types would then be pulling a container engine to
    /// read a declaration. What it DOES own is the declaration, the member set,
    /// the `SHA256SUMS` writer and the produced-set verification
    /// ([`crate::package`]), so every door that injects a builder leaves the same
    /// release behind rather than each writing its own.
    fn build_package(
        &self,
        _pkg: &crate::package::PackageBuild,
        _repo: &str,
        _repo_root: &Path,
    ) -> Result<TargetBuildOutcome> {
        bail!(
            "assemble: kind 'package' needs an injected PackageBuilder (use \
             `Builders::with_package`). This crate links no packaging toolchain on \
             purpose — a wheel is built by `python -m build` inside a pinned image, \
             which is a capability, not a declaration. Build it through the \
             product's own publish verb, or inject a builder."
        )
    }

    /// Post-produce hook (task #65 slice 4b): analyse an artifact the dispatch just
    /// wrote, keyed by its `content_id`, returning warehouse-ready [`BuildInfoRow`]s
    /// (size / hardening / SBOM facts). Default = **no-op** so the leaf pays
    /// nothing; [`Builders`] delegates to the injected
    /// [`ArtifactAnalyzer`](crate::analyze::ArtifactAnalyzer) — the exact mirror of
    /// the [`ImageBuilder`] injection.
    fn analyze_artifact(
        &self,
        _artifact_path: &Path,
        _content_id: &str,
        _build_id: &str,
    ) -> Vec<BuildInfoRow> {
        Vec::new()
    }

    /// Runtime probe: is the cargo/rustc toolchain present? Default probes
    /// `rustc -vV` / `cargo -vV`.
    fn cargo_available(&self) -> bool {
        toolchain_present()
    }

    /// Runtime probe: is `triple` an installed rust target? Default probes
    /// `rustup target list --installed`; if rustup is absent it degrades to `true`
    /// (best-effort — never a false "missing").
    fn target_installed(&self, triple: &str) -> bool {
        rustup_target_installed(triple)
    }

    /// Runtime probe: is the container engine (podman/Docker socket) reachable?
    /// Base default `false` (no builder injected).
    fn container_available(&self) -> bool {
        false
    }

    /// Runtime probe: is the VM builder (KVM / prebuilt artifacts) reachable?
    /// Base default `false` (no builder injected).
    fn vm_available(&self) -> bool {
        false
    }

    /// Runtime probe: is the ISO builder (xorriso / prebuilt artifacts) reachable?
    /// Base default `false` (no builder injected).
    fn iso_available(&self) -> bool {
        false
    }

    /// Runtime probe: is an installer composer reachable? Base default `false`.
    fn installer_available(&self) -> bool {
        false
    }

    /// Runtime probe: is a kernel builder (container toolchain) reachable?
    /// Base default `false` (no builder injected).
    fn kernel_available(&self) -> bool {
        false
    }

    /// Whether a packaging toolchain is injected — the precheck's question, the
    /// peer of [`kernel_available`](Self::kernel_available).
    fn package_available(&self) -> bool {
        false
    }
}

/// The production [`Assemble`]: wires the real container / VM builder seams. Cargo
/// builds use the trait's real default; container / VM builds drive the injected
/// [`ImageBuilder`] / [`VmImageBuilder`] through the existing
/// [`build_container`](crate::image_build::build_container) /
/// [`build_vm`](crate::image_build::build_vm) free fns.
#[derive(Default)]
pub struct Builders<'a> {
    /// The injected container image builder (e.g. `BollardImageBuilder`).
    pub image: Option<&'a dyn ImageBuilder>,
    /// The injected VM image builder (e.g. `PrebuiltVmBuilder`).
    pub vm: Option<&'a dyn VmImageBuilder>,
    /// The injected distribution-package builder — the seam
    /// [`crate::package::PackageBuilder`] names. `None` on a forge that links no
    /// packaging toolchain, which is the default and the honest state.
    pub package: Option<&'a dyn crate::package::PackageBuilder>,
    /// The injected ISO image builder (e.g. `PrebuiltIsoBuilder`).
    pub iso: Option<&'a dyn IsoImageBuilder>,
    /// The injected INSTALLER composer, as an [`IsoImageBuilder`] over the rendered
    /// [`InstallerBuild::installer_iso`](crate::installer::InstallerBuild::installer_iso).
    ///
    /// The same trait as `iso`, deliberately and in its own slot. The same trait
    /// because what a composer hands back is what an ISO builder hands back — the
    /// mastered medium's [`Artifact`](crate::image_build::Artifact) hashes — and
    /// [`build_iso_as`](crate::image_build::build_iso_as) already exists to record
    /// that as `TargetKind::Installer` rather than `Iso`, which is the distinction an
    /// auditor needs (a live ISO *is* the appliance; an installer *produces* one). Its
    /// own slot because the two are not interchangeable: a repo may declare both, and
    /// a plain ISO builder handed an installer would master a medium that boots
    /// nothing.
    pub installer: Option<&'a dyn IsoImageBuilder>,
    /// The injected KERNEL builder (e.g.
    /// [`ContainerKernelBuilder`](crate::kernel::ContainerKernelBuilder) over
    /// draupnir's container backend).
    pub kernel: Option<&'a dyn crate::kernel::KernelBuilder>,
    /// The injected post-produce artifact analyzer (nornir's `binscan`). `None`
    /// = the no-op default (the leaf runs no analysis).
    pub analyzer: Option<&'a dyn ArtifactAnalyzer>,
}

impl<'a> Builders<'a> {
    /// An empty injection (cargo-only).
    pub fn new() -> Self {
        Builders {
            image: None,
            vm: None,
            iso: None,
            installer: None,
            kernel: None,
            package: None,
            analyzer: None,
        }
    }

    /// Inject the container image builder seam.
    pub fn with_image(mut self, image: &'a dyn ImageBuilder) -> Self {
        self.image = Some(image);
        self
    }

    /// Inject the VM image builder seam.
    pub fn with_vm(mut self, vm: &'a dyn VmImageBuilder) -> Self {
        self.vm = Some(vm);
        self
    }

    /// Inject the ISO image builder seam.
    pub fn with_iso(mut self, iso: &'a dyn IsoImageBuilder) -> Self {
        self.iso = Some(iso);
        self
    }

    /// Inject the INSTALLER composer seam. Separate from
    /// [`with_iso`](Self::with_iso) on purpose — see the field.
    pub fn with_installer(mut self, installer: &'a dyn IsoImageBuilder) -> Self {
        self.installer = Some(installer);
        self
    }

    /// Inject the KERNEL builder seam.
    pub fn with_kernel(mut self, kernel: &'a dyn crate::kernel::KernelBuilder) -> Self {
        self.kernel = Some(kernel);
        self
    }

    /// Inject the PACKAGE builder seam.
    pub fn with_package(mut self, package: &'a dyn crate::package::PackageBuilder) -> Self {
        self.package = Some(package);
        self
    }

    /// Inject the post-produce artifact analyzer seam (mirrors
    /// [`with_image`](Self::with_image)).
    pub fn with_analyzer(mut self, analyzer: &'a dyn ArtifactAnalyzer) -> Self {
        self.analyzer = Some(analyzer);
        self
    }
}

impl<'a> Assemble for Builders<'a> {
    fn build_container(
        &self,
        image: &ContainerBuild,
        repo: &str,
        repo_root: &Path,
    ) -> Result<TargetBuildOutcome> {
        let builder = self
            .image
            .context("assemble: no ImageBuilder injected into Builders")?;
        image_build_container(builder, image, repo, repo_root)
    }

    fn build_vm(&self, vm: &VmBuild, repo: &str, repo_root: &Path) -> Result<TargetBuildOutcome> {
        let builder = self
            .vm
            .context("assemble: no VmImageBuilder injected into Builders")?;
        image_build_vm(builder, vm, repo, repo_root)
    }

    fn build_iso(&self, iso: &IsoBuild, repo: &str, repo_root: &Path) -> Result<TargetBuildOutcome> {
        let builder = self
            .iso
            .context("assemble: no IsoImageBuilder injected into Builders")?;
        image_build_iso(builder, iso, repo, repo_root)
    }

    /// The composer drives the SAME capture the ISO leg does, through
    /// [`build_iso_as`](crate::image_build::build_iso_as) with
    /// [`TargetKind::Installer`] — so the provenance says which of the two media this
    /// is, and there is no second attestation path to keep in step.
    fn build_installer(
        &self,
        cell: &crate::installer::InstallerBuild,
        repo: &str,
        repo_root: &Path,
    ) -> Result<TargetBuildOutcome> {
        let builder = self
            .installer
            .context("assemble: no installer composer injected into Builders")?;
        crate::image_build::build_iso_as(
            builder,
            &cell.installer_iso(),
            crate::image_build::TargetKind::Installer,
            repo,
            repo_root,
        )
    }

    fn build_kernel(
        &self,
        kernel: &crate::kernel::KernelBuild,
        repo: &str,
        repo_root: &Path,
    ) -> Result<TargetBuildOutcome> {
        let builder = self
            .kernel
            .context("assemble: no KernelBuilder injected into Builders")?;
        crate::image_build::build_kernel(builder, kernel, repo, repo_root)
    }

    fn build_package(
        &self,
        pkg: &crate::package::PackageBuild,
        repo: &str,
        repo_root: &Path,
    ) -> Result<TargetBuildOutcome> {
        let builder = self
            .package
            .context("assemble: no PackageBuilder injected into Builders")?;
        crate::image_build::build_package(builder, pkg, repo, repo_root)
    }

    fn container_available(&self) -> bool {
        self.image.is_some()
    }

    fn iso_available(&self) -> bool {
        self.iso.is_some()
    }

    fn installer_available(&self) -> bool {
        self.installer.is_some()
    }

    fn kernel_available(&self) -> bool {
        self.kernel.is_some()
    }

    fn package_available(&self) -> bool {
        self.package.is_some()
    }

    fn vm_available(&self) -> bool {
        self.vm.is_some()
    }

    fn analyze_artifact(
        &self,
        artifact_path: &Path,
        content_id: &str,
        build_id: &str,
    ) -> Vec<BuildInfoRow> {
        match self.analyzer {
            Some(a) => a.analyze(artifact_path, content_id, build_id),
            None => Vec::new(),
        }
    }
}

/// The **content-addressed idempotence cache** seam: `assemble` skips the rebuild
/// when a receipt for the same variant digest already exists. The dep-light
/// [`InMemAssembleCache`] is the in-process default; a persistent impl (an on-disk
/// attestation ledger) is the production form.
pub trait AssembleCache {
    /// Fetch a cached receipt by variant digest.
    fn get(&self, key: &str) -> Option<Assembled>;
    /// Store a receipt under its variant digest.
    fn put(&self, key: &str, assembled: &Assembled);
}

/// An in-process [`AssembleCache`] — a keyed map of receipts, for a single box.
#[derive(Default)]
pub struct InMemAssembleCache {
    map: Mutex<HashMap<String, Assembled>>,
}

impl InMemAssembleCache {
    /// A fresh, empty cache.
    pub fn new() -> Self {
        Self::default()
    }
}

impl AssembleCache for InMemAssembleCache {
    fn get(&self, key: &str) -> Option<Assembled> {
        self.map.lock().expect("assemble cache mutex").get(key).cloned()
    }

    fn put(&self, key: &str, assembled: &Assembled) {
        self.map
            .lock()
            .expect("assemble cache mutex")
            .insert(key.to_string(), assembled.clone());
    }
}

/// The **context** [`assemble`] / [`precheck`] thread through: the repo identity +
/// roots, the build-info sink, the injected builders, and the idempotence knobs.
pub struct AssembleCtx<'a> {
    /// The repo name recorded in provenance (defaults to the repo-root dir name).
    pub repo: String,
    /// The build context root (where cargo runs / the image build context lives).
    pub repo_root: &'a Path,
    /// Where a built binary / service lands (`output_of`'s `exec_dir`, e.g.
    /// `/usr/local/bin`).
    pub exec_dir: &'a Path,
    /// The build-info sink every assemble emits its lineage to.
    pub info: &'a dyn BuildInfoSink,
    /// The injected builder seams (cargo / container / VM).
    pub builders: &'a dyn Assemble,
    /// When `true` (and [`cache`](Self::cache) is set), skip a rebuild whose
    /// content-addressed receipt already exists.
    pub idempotent: bool,
    /// The idempotence cache (see [`idempotent`](Self::idempotent)).
    pub cache: Option<&'a dyn AssembleCache>,
}

impl<'a> AssembleCtx<'a> {
    /// A context with idempotence off. `repo` defaults to the repo-root dir name.
    pub fn new(
        repo_root: &'a Path,
        exec_dir: &'a Path,
        info: &'a dyn BuildInfoSink,
        builders: &'a dyn Assemble,
    ) -> Self {
        let repo = repo_root
            .file_name()
            .map(|s| s.to_string_lossy().into_owned())
            .unwrap_or_else(|| repo_root.display().to_string());
        AssembleCtx {
            repo,
            repo_root,
            exec_dir,
            info,
            builders,
            idempotent: false,
            cache: None,
        }
    }

    /// Turn on content-addressed idempotence, backed by `cache`.
    pub fn idempotent(mut self, cache: &'a dyn AssembleCache) -> Self {
        self.idempotent = true;
        self.cache = Some(cache);
        self
    }
}

/// **The f7 seam.** Read `variant`, dispatch on its
/// [`kind`](BuildVariant::kind), drive the matching *existing* builder, emit the
/// build-info lineage to `ctx.info`, and return ONE [`Assembled`] receipt.
///
/// Composite: if `variant` embeds siblings (or a container chains `from_variant`
/// layers) the sub-builds are topo-assembled first (embedded lib BEFORE the
/// parent) and each sub-artifact's `content_id` is threaded into the parent's
/// `dep` rows. A cycle in the embed graph is a clear error.
pub fn assemble(set: &BuildVariantSet, variant: &BuildVariant, ctx: &AssembleCtx) -> Result<Assembled> {
    let mut stack: Vec<String> = Vec::new();
    assemble_rec(set, variant, ctx, &mut stack)
}

fn assemble_rec(
    set: &BuildVariantSet,
    variant: &BuildVariant,
    ctx: &AssembleCtx,
    stack: &mut Vec<String>,
) -> Result<Assembled> {
    // Cycle detection over the embed/from_variant DAG (a tiny local serial DFS, no
    // extra deps): a variant already on the DFS stack is a cycle.
    if stack.iter().any(|n| n == &variant.name) {
        stack.push(variant.name.clone());
        bail!(
            "assemble: dependency cycle detected: {}",
            stack.join(" -> ")
        );
    }
    stack.push(variant.name.clone());

    // Config gate (f1) before any build — kind × target × service × delegation.
    set.validate(variant).with_context(|| {
        format!("assemble: variant '{}' is not a valid build point", variant.name)
    })?;

    // Topo-assemble the sub-builds first (embedded lib BEFORE the parent), and
    // collect each produced artifact's content_id to thread into the parent.
    let subs = sub_build_names(set, variant)?;
    let mut sub_deps: Vec<(String, String)> = Vec::new();
    for sub in &subs {
        let sub_variant = resolve_variant(set, sub)?.clone();
        let assembled = assemble_rec(set, &sub_variant, ctx, stack)?;
        for cid in assembled.artifact_content_ids() {
            sub_deps.push((sub.clone(), cid));
        }
    }
    stack.pop();

    // Idempotence: skip the rebuild when this variant's content-addressed receipt
    // already exists — still emit a `step` row noting the cache hit.
    let key = variant_digest(variant);
    if ctx.idempotent {
        if let Some(cache) = ctx.cache {
            if let Some(mut hit) = cache.get(&key) {
                let ts = now_micros();
                let row = BuildInfoRow::new(hit.attestation.build_id(), ts, BuildInfoKind::Step)
                    .step("assemble")
                    .level("cache-hit")
                    .kv("assemble", variant.name.clone());
                let _ = ctx.info.emit(std::slice::from_ref(&row));
                hit.lineage.push(row);
                return Ok(hit);
            }
        }
    }

    // Dispatch to the matching existing builder + lower its record into lineage.
    let (output, proof, build_id, mut lineage, verdict) = dispatch(set, variant, ctx)?;

    // Thread each sub-artifact's content_id into the parent's `dep` rows, so the
    // lineage graphs: this build consumed exactly these content-addressed inputs.
    if !sub_deps.is_empty() {
        let ts = now_micros();
        let mut dep_rows = Vec::with_capacity(sub_deps.len());
        for (name, cid) in sub_deps {
            dep_rows.push(
                BuildInfoRow::new(&build_id, ts, BuildInfoKind::Dep)
                    .kv("embeds", name)
                    .content_id(&cid),
            );
        }
        let _ = ctx.info.emit(&dep_rows);
        lineage.extend(dep_rows);
    }

    let assembled = Assembled { output, attestation: proof, lineage, verdict };
    // A FAILED build is never cached. Idempotence answers "this exact variant was
    // already produced, do not produce it again"; a failure produced nothing, and
    // serving it back would turn one bad build into a permanent one — every retry a
    // cache hit, with a receipt, having run no builder. This became reachable the
    // moment a red target build started returning `Ok` so its record could be filed,
    // which is exactly the kind of second-order cost a contract change buys.
    //
    // `produced()` and not `success()`: a build that reported success and wrote NO
    // artifact is exactly as uncacheable, and caching it would serve the empty
    // answer back forever.
    if ctx.idempotent && assembled.produced().is_ok() {
        if let Some(cache) = ctx.cache {
            cache.put(&key, &assembled);
        }
    }
    Ok(assembled)
}

/// Dispatch one variant to its builder and return
/// `(output, proof, build_id, base_lineage, verdict)`. The base lineage (feed +
/// manifest rows) is emitted to `ctx.info` here; the caller appends the composite dep
/// rows.
///
/// The fifth element is the half that used to be dropped: the builder's own error
/// text and the path the artifact was expected at. See [`ProducedVerdict`].
fn dispatch(
    set: &BuildVariantSet,
    variant: &BuildVariant,
    ctx: &AssembleCtx,
) -> Result<(
    ArtifactOutput,
    AssembledProof,
    String,
    Vec<BuildInfoRow>,
    ProducedVerdict,
)> {
    let output = output_of(set, variant, ctx.exec_dir)?;
    let ts = now_micros();

    match variant.kind {
        // An installer is a legal variant kind (see the crate-root vocabulary) and it
        // is COMPOSED, not assembled: the boot chain is built, the kernel pinned and
        // the payloads staged by korp-installer's isocompose, which this dep-light
        // crate does not link and must not.
        //
        // It used to `bail!` right here, and that put the refusal one step too early:
        // a consumer that DOES link a composer could not hand it in, so the door the
        // message names was reachable only from a CLI. The refusal now lives on
        // `Assemble::build_installer`'s default — same words, one injection later —
        // and a `Builders::with_installer` consumer composes for real.
        ArtifactKind::Installer => {
            let cell = set.require_installer(&variant.name)?;
            let outcome = ctx.builders.build_installer(cell, &ctx.repo, ctx.repo_root)?;
            let rec = &outcome.attestation.record;
            let mut rows = target_feed_rows(
                &rec.id,
                ts,
                &rec.target,
                outcome.success,
                outcome.artifacts.len(),
                rec.host.cores,
                "installer-compose",
            );
            rows.extend(target_manifest_rows(rec, ts));
            let _ = ctx.info.emit(&rows);
            let build_id = rec.id.clone();
            // The SAME path function the ISO leg names, applied to the rendered
            // descriptor — so a red compose names the path a green one writes.
            let verdict = ProducedVerdict {
                error: outcome.error.clone(),
                expected: crate::image_build::iso_output_path(
                    ctx.repo_root,
                    &cell.installer_iso(),
                )
                .display()
                .to_string(),
            };
            Ok((
                output,
                AssembledProof::Target(outcome.attestation),
                build_id,
                rows,
                verdict,
            ))
        }
        ArtifactKind::Exe | ArtifactKind::Lib | ArtifactKind::Wasm | ArtifactKind::Service => {
            // The build info is regenerated from the returned record (a Null sink
            // to the inner build), so a fake `build_cargo` need only return an
            // outcome — the lineage is fully controlled here.
            let started = Instant::now();
            let outcome =
                ctx.builders
                    .build_cargo(set, &variant.name, ctx.repo_root, &NullBuildInfoSink)?;
            let elapsed = started.elapsed().as_micros() as i64;
            let rec = &outcome.attestation.record;
            let mut rows = build_feed_rows(
                &rec.id,
                ts,
                &variant.name,
                outcome.success,
                outcome.artifacts.len(),
                rec.host.cores,
                elapsed,
            );
            rows.extend(manifest_rows(rec, ts));
            let _ = ctx.info.emit(&rows);
            let build_id = rec.id.clone();

            // Post-produce hook (task #65 slice 4b): analyse each produced artifact
            // right after it is written, keyed by its `content_id` (= the artifact
            // row's sha256). Default no-op; nornir injects the real binary scan.
            // Only cargo kinds are analysed here — container/VM layers are a
            // deferred path (design §4). The paths are resolved against `repo_root`
            // (provenance records them relative when under it).
            let mut analysis_rows: Vec<BuildInfoRow> = Vec::new();
            for a in &rec.artifacts {
                let ap = Path::new(&a.path);
                let resolved = if ap.is_absolute() {
                    ap.to_path_buf()
                } else {
                    ctx.repo_root.join(ap)
                };
                let extra = ctx
                    .builders
                    .analyze_artifact(&resolved, &a.sha256, &build_id);
                analysis_rows.extend(extra);
            }
            if !analysis_rows.is_empty() {
                let _ = ctx.info.emit(&analysis_rows);
                rows.extend(analysis_rows);
            }

            let verdict = ProducedVerdict {
                error: (!outcome.success).then(|| {
                    format!(
                        "cargo build of variant '{}' exited non-zero",
                        variant.name
                    )
                }),
                expected: expected_binaries(&output),
            };
            Ok((
                output,
                AssembledProof::Build(outcome.attestation),
                build_id,
                rows,
                verdict,
            ))
        }
        ArtifactKind::Container => {
            let cref = variant
                .container_ref
                .as_deref()
                .context("assemble: container kind without container_ref")?;
            let mut image = set.require_container(cref)?.clone();
            // **A pinned image is checked before its tags move** (2026-09-22): the kernel rows
            // that name this image as their toolchain are the pin, and the build refuses an
            // image that is not it rather than moving `:latest` onto it.
            if let Some(pin) = crate::kernel::toolchain_pin_for(set, &image.name)? {
                image.expected_sha256 = pin;
            }
            let outcome = ctx.builders.build_container(&image, &ctx.repo, ctx.repo_root)?;
            let rec = &outcome.attestation.record;
            let mut rows = target_feed_rows(
                &rec.id,
                ts,
                &rec.target,
                outcome.success,
                outcome.artifacts.len(),
                rec.host.cores,
                "image-build",
            );
            rows.extend(target_manifest_rows(rec, ts));
            let _ = ctx.info.emit(&rows);
            let build_id = rec.id.clone();
            let verdict = ProducedVerdict {
                error: outcome.error.clone(),
                expected: format!(
                    "an image in the engine's store tagged {}",
                    image
                        .primary_tag()
                        .unwrap_or(image.name.as_str())
                ),
            };
            Ok((
                output,
                AssembledProof::Target(outcome.attestation),
                build_id,
                rows,
                verdict,
            ))
        }
        ArtifactKind::Vm => {
            let vref = variant
                .vm_ref
                .as_deref()
                .context("assemble: vm kind without vm_ref")?;
            let vm = set.require_vm(vref)?;
            let outcome = ctx.builders.build_vm(vm, &ctx.repo, ctx.repo_root)?;
            let rec = &outcome.attestation.record;
            let mut rows = target_feed_rows(
                &rec.id,
                ts,
                &rec.target,
                outcome.success,
                outcome.artifacts.len(),
                rec.host.cores,
                "vm-build",
            );
            rows.extend(target_manifest_rows(rec, ts));
            let _ = ctx.info.emit(&rows);
            let build_id = rec.id.clone();
            let verdict = ProducedVerdict {
                error: outcome.error.clone(),
                expected: format!("the boot image {} declares ({})", vm.name, vm.kernel),
            };
            Ok((
                output,
                AssembledProof::Target(outcome.attestation),
                build_id,
                rows,
                verdict,
            ))
        }
        // A `kind = "kernel"` variant carries its handle in its own name (the Iso
        // rule); dispatch it through the kernel builder seam over the declared
        // `[[build.kernel]]` of the same name.
        ArtifactKind::Kernel => {
            let cell = set.require_kernel(&variant.name)?;
            let outcome = ctx.builders.build_kernel(cell, &ctx.repo, ctx.repo_root)?;
            let rec = &outcome.attestation.record;
            let mut rows = target_feed_rows(
                &rec.id,
                ts,
                &rec.target,
                outcome.success,
                outcome.artifacts.len(),
                rec.host.cores,
                "kernel-build",
            );
            rows.extend(target_manifest_rows(rec, ts));
            let _ = ctx.info.emit(&rows);
            let build_id = rec.id.clone();
            // The SAME path function the kernel builder collects to
            // (`kernel_output_path`), so a red build names the path a green one
            // writes.
            let verdict = ProducedVerdict {
                error: outcome.error.clone(),
                expected: crate::image_build::kernel_output_path(ctx.repo_root, cell)
                    .display()
                    .to_string(),
            };
            Ok((
                output,
                AssembledProof::Target(outcome.attestation),
                build_id,
                rows,
                verdict,
            ))
        }
        // A `kind = "package"` variant carries the cell handle in its own name (the
        // Iso/Installer/Kernel rule, no delegating `package_ref`); dispatch it
        // through the package builder seam over the declared `[[build.package]]` of
        // the same name.
        ArtifactKind::Package => {
            let cell = set.require_package(&variant.name)?;
            let outcome = ctx.builders.build_package(cell, &ctx.repo, ctx.repo_root)?;
            let rec = &outcome.attestation.record;
            let mut rows = target_feed_rows(
                &rec.id,
                ts,
                &rec.target,
                outcome.success,
                outcome.artifacts.len(),
                rec.host.cores,
                "package-build",
            );
            rows.extend(target_manifest_rows(rec, ts));
            let _ = ctx.info.emit(&rows);
            let build_id = rec.id.clone();
            // The DIRECTORY and the member set, not one path: a package that
            // produced three of its four members failed, and a red build that named
            // only the wheel would send the reader looking at the wrong file.
            let verdict = ProducedVerdict {
                error: outcome.error.clone(),
                expected: format!(
                    "{} in {}",
                    cell.required_members()
                        .iter()
                        .map(|m| m.as_str())
                        .collect::<Vec<_>>()
                        .join(" + "),
                    cell.out_dir(ctx.repo_root).display()
                ),
            };
            Ok((
                output,
                AssembledProof::Target(outcome.attestation),
                build_id,
                rows,
                verdict,
            ))
        }
        // A `kind = "iso"` cargo variant carries the ISO handle in its own name (no
        // delegating `iso_ref`); dispatch it through the ISO builder seam over the
        // declared `[[build.iso]]` of the same name.
        ArtifactKind::Iso => {
            let iso = set.require_iso(&variant.name)?;
            let outcome = ctx.builders.build_iso(iso, &ctx.repo, ctx.repo_root)?;
            let rec = &outcome.attestation.record;
            let mut rows = target_feed_rows(
                &rec.id,
                ts,
                &rec.target,
                outcome.success,
                outcome.artifacts.len(),
                rec.host.cores,
                "iso-build",
            );
            rows.extend(target_manifest_rows(rec, ts));
            let _ = ctx.info.emit(&rows);
            let build_id = rec.id.clone();
            // The conventional output path is the SAME function the ISO builders
            // master to (`iso_output_path`), not a second spelling of the rule — so
            // the path a red build names is the path a green build writes.
            let verdict = ProducedVerdict {
                error: outcome.error.clone(),
                expected: crate::image_build::iso_output_path(ctx.repo_root, iso)
                    .display()
                    .to_string(),
            };
            Ok((
                output,
                AssembledProof::Target(outcome.attestation),
                build_id,
                rows,
                verdict,
            ))
        }
    }
}

/// The install paths a cargo cell's [`ArtifactOutput`] intends — the `expected` half
/// of a [`ProducedVerdict`] for the four cargo kinds.
fn expected_binaries(output: &ArtifactOutput) -> String {
    match output {
        ArtifactOutput::Binary(b) => b.path.clone(),
        ArtifactOutput::Service(s) => s.exec.clone(),
        ArtifactOutput::Pages(p) => format!("a {} bundle for {}", p.target, p.module),
        other => other.name().to_string(),
    }
}

/// The **runtime gate** (§f7.1): does the host have what this variant needs to
/// build? RETURNS the reasons — never hard-fails — so f4's `valid` flag reflects
/// reality, not just config.
pub fn precheck(set: &BuildVariantSet, variant: &BuildVariant, ctx: &AssembleCtx) -> Precheck {
    let mut missing: Vec<String> = Vec::new();
    match variant.kind {
        // See assemble(): an installer is COMPOSED, and the composer is injected. The
        // gate is therefore whether one was — the same shape as every other kind's,
        // rather than a flat "this kind cannot be built here".
        ArtifactKind::Installer => {
            if !ctx.builders.installer_available() {
                missing.push(
                    "kind 'installer' is composed and no composer is injected — use the \
                     product's compose verb, or `Builders::with_installer`"
                        .to_string(),
                );
            }
        }
        ArtifactKind::Exe | ArtifactKind::Lib | ArtifactKind::Wasm | ArtifactKind::Service => {
            if !ctx.builders.cargo_available() {
                missing.push("cargo/rustc toolchain not found on PATH".to_string());
            }
            if let Some(t) = variant.target.as_deref() {
                if !ctx.builders.target_installed(t) {
                    missing.push(format!(
                        "rust target '{t}' is not installed (`rustup target add {t}`)"
                    ));
                }
            }
        }
        ArtifactKind::Container => {
            if !ctx.builders.container_available() {
                missing.push(
                    "container engine (podman/Docker socket) is not available".to_string(),
                );
            }
            // A `from_variant` layer needs the cargo toolchain to produce its payload.
            if let Some(cref) = variant.container_ref.as_deref() {
                if let Ok(c) = set.require_container(cref) {
                    if !c.variant_deps().is_empty() && !ctx.builders.cargo_available() {
                        missing.push(
                            "cargo/rustc toolchain not found (needed for from_variant layers)"
                                .to_string(),
                        );
                    }
                }
            }
        }
        ArtifactKind::Vm => {
            if !ctx.builders.vm_available() {
                missing
                    .push("vm builder (KVM / prebuilt artifacts) is not available".to_string());
            }
        }
        ArtifactKind::Iso => {
            if !ctx.builders.iso_available() {
                missing.push(
                    "iso builder (xorriso / prebuilt artifacts) is not available".to_string(),
                );
            }
        }
        ArtifactKind::Kernel => {
            if !ctx.builders.kernel_available() {
                missing.push(
                    "kernel builder (container toolchain via draupnir backend-oci) is \
                     not available — inject one with `Builders::with_kernel`"
                        .to_string(),
                );
            }
        }
        ArtifactKind::Package => {
            if !ctx.builders.package_available() {
                missing.push(
                    "package builder (the format's toolchain in a pinned container) is \
                     not available — inject one with `Builders::with_package`"
                        .to_string(),
                );
            }
            // A package that stages the output of a cargo variant needs the cargo
            // toolchain to produce it — the container kind's `from_variant` rule,
            // and the same reason: reporting "ready" and then failing to find an
            // input several minutes in is a precheck that answered the wrong
            // question.
            if let Some(cell) = set.package(&variant.name) {
                if !cell.from_variant.is_empty() && !ctx.builders.cargo_available() {
                    missing.push(
                        "cargo/rustc toolchain not found (needed for from_variant inputs)"
                            .to_string(),
                    );
                }
            }
        }
    }
    if missing.is_empty() {
        Precheck::Ready
    } else {
        Precheck::Missing(missing)
    }
}

// ─── helpers ─────────────────────────────────────────────────────────────────

/// The names of the sub-builds a variant composes: its `embeds` (sibling members
/// compiled IN) plus, for a container, each cargo `from_variant` layer. Order:
/// embeds first, then container variant-deps; de-duplicated.
fn sub_build_names(set: &BuildVariantSet, variant: &BuildVariant) -> Result<Vec<String>> {
    let mut names: Vec<String> = Vec::new();
    for e in &variant.embeds {
        if !names.contains(e) {
            names.push(e.clone());
        }
    }
    if variant.kind == ArtifactKind::Container {
        if let Some(cref) = &variant.container_ref {
            let c = set.require_container(cref)?;
            for d in c.variant_deps() {
                let d = d.to_string();
                if !names.contains(&d) {
                    names.push(d);
                }
            }
        }
    }
    Ok(names)
}

/// Resolve a sub-build name to a declared variant: first by variant name, then by
/// suite `member` (an `embeds` entry names a member). A clear error otherwise.
fn resolve_variant<'a>(set: &'a BuildVariantSet, name: &str) -> Result<&'a BuildVariant> {
    if let Some(v) = set.get(name) {
        return Ok(v);
    }
    if let Some(v) = set.variants.iter().find(|v| v.member.as_deref() == Some(name)) {
        return Ok(v);
    }
    bail!(
        "assemble: sub-build '{}' names no declared variant or member",
        name
    )
}

/// A stable content-addressed key for a variant descriptor — the idempotence
/// cache key. SHA-256 over the variant's serde JSON.
fn variant_digest(variant: &BuildVariant) -> String {
    let bytes = serde_json::to_vec(variant).unwrap_or_default();
    nornir_hash::sha256_hex(&bytes)
}

/// Live-feed rows for an image/VM target build — the target-kind peer of
/// [`build_feed_rows`], labelled with the target `step` (`image-build`/`vm-build`).
fn target_feed_rows(
    build_id: &str,
    ts: i64,
    target: &str,
    success: bool,
    artifact_count: usize,
    cores: u32,
    step: &str,
) -> Vec<BuildInfoRow> {
    let state = if success { "done" } else { "failed" };
    vec![
        BuildInfoRow::new(build_id, ts, BuildInfoKind::Step)
            .step(step)
            .level(state)
            .kv(step, target.to_string()),
        BuildInfoRow::new(build_id, ts, BuildInfoKind::Metric)
            .step(step)
            .kv("artifacts", artifact_count.to_string()),
        BuildInfoRow::new(build_id, ts, BuildInfoKind::Metric)
            .step(step)
            .kv("cores", cores.to_string()),
    ]
}

/// Toolchain presence probe: `rustc -vV` + `cargo -vV` both answer.
fn toolchain_present() -> bool {
    let tc = Toolchain::detect();
    !tc.rustc.is_empty() && !tc.cargo.is_empty()
}

/// `rustup target list --installed` probe. Degrades to `true` when rustup is
/// absent (best-effort — never a false "missing").
fn rustup_target_installed(triple: &str) -> bool {
    match Command::new("rustup")
        .args(["target", "list", "--installed"])
        .output()
    {
        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
            .lines()
            .any(|l| l.trim() == triple),
        _ => true,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::build_info::InMemBuildInfoSink;
    use crate::container::ContainerBuild;
    use crate::image_build::{BuiltImage, ImageBuilder, VmImageBuilder};
    use crate::provenance::{Artifact, BuildProvenance, HostInfo, Outcome};
    use crate::variant::{ArtifactKind, Profile, ServiceShape};
    use std::collections::BTreeSet;

    /// A fake [`ImageBuilder`] returning a known [`BuiltImage`] (no daemon).
    struct FakeImageBuilder {
        built: BuiltImage,
    }
    impl ImageBuilder for FakeImageBuilder {
        fn build(&self, _image: &ContainerBuild, _root: &Path) -> Result<BuiltImage> {
            Ok(self.built.clone())
        }
    }

    /// A fake [`VmImageBuilder`] returning a known artifact hash (no KVM).
    struct FakeVmBuilder;
    impl VmImageBuilder for FakeVmBuilder {
        fn build(&self, _vm: &VmBuild, _root: &Path) -> Result<Vec<Artifact>> {
            Ok(vec![Artifact {
                path: "artifacts/bzImage".into(),
                bytes: 6,
                sha256: "ab".repeat(32),
            }])
        }
    }

    /// A fake [`Assemble`] that stands in for real cargo: it synthesizes an
    /// artifact file (deterministic bytes per variant name) and captures REAL
    /// provenance over it — so the whole lineage + content_id machinery runs with
    /// no `cargo`. Container/VM builds drive the injected fakes. Availability
    /// probes are simulated via the two flags.
    struct FakeAssemble<'a> {
        image: Option<&'a dyn ImageBuilder>,
        vm: Option<&'a dyn VmImageBuilder>,
        iso: Option<&'a dyn IsoImageBuilder>,
        analyzer: Option<&'a dyn ArtifactAnalyzer>,
        cargo_ok: bool,
        targets_ok: bool,
    }

    impl<'a> FakeAssemble<'a> {
        fn cargo_only() -> Self {
            FakeAssemble {
                image: None,
                vm: None,
                iso: None,
                analyzer: None,
                cargo_ok: true,
                targets_ok: true,
            }
        }
    }

    impl<'a> Assemble for FakeAssemble<'a> {
        fn build_cargo(
            &self,
            set: &BuildVariantSet,
            name: &str,
            repo_root: &Path,
            _sink: &dyn BuildInfoSink,
        ) -> Result<BuildOutcome> {
            let v = set.require(name)?.clone();
            let dir = repo_root.join("target/release");
            std::fs::create_dir_all(&dir)?;
            let bin = v
                .bin
                .clone()
                .or_else(|| v.package.clone())
                .unwrap_or_else(|| v.name.clone());
            let art = dir.join(&bin);
            std::fs::write(&art, format!("ARTIFACT {name}"))?;
            let rec = BuildProvenance::capture(
                &v,
                "testrepo",
                repo_root,
                Toolchain {
                    rustc: "rustc 1.85.0".into(),
                    host: "x86_64-unknown-linux-gnu".into(),
                    cargo: "cargo 1.85.0".into(),
                    ..Default::default()
                },
                HostInfo { cores: 8, memory_bytes: 0 },
                Outcome::Success,
                &[art.clone()],
            )?;
            Ok(BuildOutcome {
                variant: name.to_string(),
                artifacts: vec![art],
                attestation: rec.attest()?,
                success: true,
            })
        }

        fn build_container(
            &self,
            image: &ContainerBuild,
            repo: &str,
            repo_root: &Path,
        ) -> Result<TargetBuildOutcome> {
            let b = self.image.context("no image builder")?;
            image_build_container(b, image, repo, repo_root)
        }

        fn build_vm(
            &self,
            vm: &VmBuild,
            repo: &str,
            repo_root: &Path,
        ) -> Result<TargetBuildOutcome> {
            let b = self.vm.context("no vm builder")?;
            image_build_vm(b, vm, repo, repo_root)
        }

        fn build_iso(
            &self,
            iso: &IsoBuild,
            repo: &str,
            repo_root: &Path,
        ) -> Result<TargetBuildOutcome> {
            let b = self.iso.context("no iso builder")?;
            image_build_iso(b, iso, repo, repo_root)
        }

        fn cargo_available(&self) -> bool {
            self.cargo_ok
        }
        fn target_installed(&self, _triple: &str) -> bool {
            self.targets_ok
        }
        fn container_available(&self) -> bool {
            self.image.is_some()
        }
        fn vm_available(&self) -> bool {
            self.vm.is_some()
        }
        fn iso_available(&self) -> bool {
            self.iso.is_some()
        }
        fn analyze_artifact(
            &self,
            artifact_path: &Path,
            content_id: &str,
            build_id: &str,
        ) -> Vec<BuildInfoRow> {
            match self.analyzer {
                Some(a) => a.analyze(artifact_path, content_id, build_id),
                None => Vec::new(),
            }
        }
    }

    /// A fake [`ArtifactAnalyzer`] that emits ONE metric row per artifact carrying
    /// the `content_id` it was handed — proving dispatch fired the seam with the
    /// artifact's real content id (no `object`/`gimli` needed for the wiring test).
    struct FakeAnalyzer;
    impl ArtifactAnalyzer for FakeAnalyzer {
        fn analyze(
            &self,
            artifact_path: &Path,
            content_id: &str,
            build_id: &str,
        ) -> Vec<BuildInfoRow> {
            vec![BuildInfoRow::new(build_id, now_micros(), BuildInfoKind::Metric)
                .step("binary-scan")
                .kv("analyzed_path", artifact_path.display().to_string())
                .content_id(content_id)]
        }
    }

    fn exe_variant(name: &str) -> BuildVariant {
        BuildVariant {
            name: name.to_string(),
            bin: Some(name.to_string()),
            features: vec!["core".to_string()],
            profile: Profile::Release,
            ..Default::default()
        }
    }

    /// (a) Each kind dispatches to the right builder and the receipt's
    /// `output.kind()` matches the variant kind; cargo kinds prove `Build`, image
    /// kinds prove `Target`.
    #[test]
    fn each_kind_dispatches_and_output_kind_matches() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/usr/local/bin");

        // Declare a container + a delegating container variant, a vm + delegate.
        let toml = r#"
[build]
[[build.variant]]
name = "img"
kind = "container"
container_ref = "app-image"

[[build.variant]]
name = "boot"
kind = "vm"
vm_ref = "app-vm"

[[build.container]]
name = "app-image"
base = "scratch"
tags = ["app-image:1"]

[[build.vm]]
name = "app-vm"
kernel = "artifacts/bzImage"
rootfs = "artifacts/rootfs.cpio.gz"
"#;
        let mut set = BuildVariantSet::from_toml_str(toml).unwrap();
        set.variants.push(exe_variant("tool")); // Exe
        set.variants.push(BuildVariant {
            name: "corelib".into(),
            package: Some("corelib".into()),
            kind: ArtifactKind::Lib,
            // A library is linked by whoever consumes it — the fat/thin axis has no
            // answer for it, and it must SAY so rather than let the omitted key read
            // as the 'fat' default.
            shape: ServiceShape::NotApplicable,
            ..Default::default()
        });
        set.variants.push(BuildVariant {
            name: "svc".into(),
            bin: Some("svc".into()),
            member: Some("svc".into()),
            kind: ArtifactKind::Service,
            shape: ServiceShape::FatService,
            ..Default::default()
        });
        set.variants.push(BuildVariant {
            name: "web".into(),
            package: Some("web".into()),
            kind: ArtifactKind::Wasm,
            target: Some("wasm32-unknown-unknown".into()),
            ..Default::default()
        });

        let fake_img = FakeImageBuilder {
            built: BuiltImage {
                image_ref: "app-image:1".into(),
                digest: "sha256:1111111111111111111111111111111111111111111111111111111111111111"
                    .into(),
                bytes: 4096,
                archive: None,
            },
        };
        let fake_vm = FakeVmBuilder;
        let builders = FakeAssemble {
            image: Some(&fake_img),
            vm: Some(&fake_vm),
            iso: None,
            analyzer: None,
            cargo_ok: true,
            targets_ok: true,
        };
        let sink = InMemBuildInfoSink::new();
        let ctx = AssembleCtx::new(root, exec, &sink, &builders);

        for (name, want, is_target) in [
            ("tool", ArtifactKind::Exe, false),
            ("corelib", ArtifactKind::Lib, false),
            ("svc", ArtifactKind::Service, false),
            ("web", ArtifactKind::Wasm, false),
            ("img", ArtifactKind::Container, true),
            ("boot", ArtifactKind::Vm, true),
        ] {
            let v = set.get(name).unwrap().clone();
            let a = assemble(&set, &v, &ctx).unwrap_or_else(|e| panic!("assemble {name}: {e:#}"));
            assert_eq!(a.kind(), want, "{name}: output.kind() matches variant kind");
            assert_eq!(a.attestation.kind(), want, "{name}: proof kind matches");
            match (&a.attestation, is_target) {
                (AssembledProof::Target(_), true) => {}
                (AssembledProof::Build(_), false) => {}
                _ => panic!("{name}: wrong proof flavour"),
            }
        }
    }

    /// A container declaration + the delegating variant, as TOML — the fixture the
    /// failed-build tests below and `each_kind_dispatches` both need.
    fn container_set() -> BuildVariantSet {
        BuildVariantSet::from_toml_str(
            r#"
[build]
[[build.variant]]
name = "img"
kind = "container"
container_ref = "app-image"

[[build.container]]
name = "app-image"
base = "scratch"
tags = ["app-image:1"]
"#,
        )
        .unwrap()
    }

    /// An [`ImageBuilder`] whose engine refuses — the night the build broke.
    struct RefusingImageBuilder;
    impl ImageBuilder for RefusingImageBuilder {
        fn build(&self, _image: &ContainerBuild, _root: &Path) -> Result<BuiltImage> {
            anyhow::bail!("engine refused: no such base image")
        }
    }

    /// **A FAILED container assemble files a record, end to end.**
    ///
    /// The whole chain a producer walks: `assemble` → `Assembled` →
    /// `as_build_provenance` → the ONE `BuildProvenance` the ledger holds, plus the
    /// `build_info` rows the sink received under the same `build_id`.
    ///
    /// RED before the fix, at the first line — `build_container` discarded the
    /// captured `Outcome::Failed` attestation with `built?`, so `assemble` returned
    /// `Err` and there was no receipt to file at all:
    /// > `a failed build must still produce a receipt: engine refused: no such base image`
    #[test]
    fn a_failed_container_assemble_files_a_readable_build_record() {
        let dir = tempfile::tempdir().unwrap();
        let set = container_set();
        let builders = FakeAssemble {
            image: Some(&RefusingImageBuilder),
            vm: None,
            iso: None,
            analyzer: None,
            cargo_ok: true,
            targets_ok: true,
        };
        let sink = InMemBuildInfoSink::new();
        let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders);
        let v = set.get("img").unwrap().clone();

        let a = assemble(&set, &v, &ctx)
            .unwrap_or_else(|e| panic!("a failed build must still produce a receipt: {e:#}"));

        assert!(!a.success(), "the receipt must say the build failed");

        // 1. The record the ledger stores. `as_build_provenance` is the seam the
        //    producer uses; asserting through it rather than reaching into the
        //    `Target` arm is what proves the filed row is the failed one.
        let record = a.attestation.as_build_provenance(&v);
        assert_eq!(record.outcome, Outcome::Failed, "the FILED record says Failed");
        assert!(record.artifacts.is_empty(), "and claims no artifact it did not produce");
        assert_eq!(record.variant.name, "img", "named by the declared cell");

        // 2. The `build_info` rows the sink received, joined to that record by
        //    build_id. A record with no lineage under its own id is the disconnected
        //    pile this projection exists to prevent.
        let rows = sink.rows();
        assert!(!rows.is_empty(), "a failed build still emits its lineage");
        assert!(
            rows.iter().all(|r| r.build_id == record.id),
            "every row joins the filed record by build_id"
        );
        let step = rows
            .iter()
            .find(|r| r.kind == "step" && r.step_id == "image-build")
            .expect("an image-build step row");
        assert_eq!(step.level, "failed", "and the step row says failed, not done");
        assert!(
            rows.iter().any(|r| r.kind == "provenance"),
            "the provenance manifest rows are there too"
        );

        // The other side, without which a `Failed`-to-everything implementation
        // passes: the SAME fixture with a working builder files a green record.
        let ok = FakeImageBuilder {
            built: BuiltImage {
                image_ref: "app-image:1".into(),
                digest: "sha256:3333333333333333333333333333333333333333333333333333333333333333"
                    .into(),
                bytes: 4096,
                archive: None,
            },
        };
        let good_builders = FakeAssemble {
            image: Some(&ok),
            vm: None,
            iso: None,
            analyzer: None,
            cargo_ok: true,
            targets_ok: true,
        };
        let good_sink = InMemBuildInfoSink::new();
        let good_ctx =
            AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &good_sink, &good_builders);
        let good = assemble(&set, &v, &good_ctx).unwrap();
        assert!(good.success(), "a working builder still reports success");
        assert_eq!(
            good.attestation.as_build_provenance(&v).outcome,
            Outcome::Success,
            "and files a Success record"
        );
    }

    /// **`assemble` hands a container the pin of the kernel rows that name it** (2026-09-22) —
    /// the wiring half of the toolchain-tag fix: the `--build kernel` compose builds
    /// `nordisk-kernel-toolchain` through THIS arm (dwarves `assemble_container`), so a pin the
    /// arm did not pass is a check that never runs. A builder that answers to the 2026-09-15
    /// rebuild is refused, and was never asked to build under `:latest`.
    ///
    /// **Fails before the fix**: the arm passed the declaration as read, unpinned; the build
    /// ran under `nordisk-kernel-toolchain:latest` and succeeded.
    #[test]
    fn assemble_pins_a_container_to_the_kernel_rows_that_name_it() {
        struct Recorder(std::cell::RefCell<Vec<(Vec<String>, String)>>);
        impl ImageBuilder for Recorder {
            fn build(&self, image: &ContainerBuild, _root: &Path) -> Result<BuiltImage> {
                self.0.borrow_mut().push((image.tags.clone(), image.expected_sha256.clone()));
                Ok(BuiltImage {
                    image_ref: image.primary_tag().unwrap_or_default().into(),
                    digest: "sha256:311bc06f56379229e2f037cee44dea50a8d7b07bdffdfa51963abe37aa5872f8".into(),
                    bytes: 1,
                    archive: None,
                })
            }
        }
        const PIN: &str = "78eaa55a651d5bd76aae36db0f3307e267a92d2a547ee338a17822a103e0320e";
        let mut set = BuildVariantSet::from_toml_str(
            r#"
[build]
[[build.variant]]
name = "nordisk-kernel-toolchain"
kind = "container"
container_ref = "nordisk-kernel-toolchain"

[[build.container]]
name = "nordisk-kernel-toolchain"
base = "scratch"
tags = ["nordisk-kernel-toolchain:latest"]
"#,
        )
        .unwrap();
        set.kernels.push(crate::kernel::KernelBuild {
            name: "tunnr-kernel".into(),
            toolchain_image: crate::kernel::KERNEL_TOOLCHAIN_IMAGE.into(),
            toolchain_image_sha256: PIN.into(),
            ..crate::kernel::KernelBuild::default()
        });
        let dir = tempfile::tempdir().unwrap();
        let rec = Recorder(Default::default());
        let builders = FakeAssemble { image: Some(&rec), vm: None, iso: None, analyzer: None, cargo_ok: true, targets_ok: true };
        let sink = InMemBuildInfoSink::new();
        let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders);
        let v = set.get("nordisk-kernel-toolchain").unwrap().clone();
        let a = assemble(&set, &v, &ctx).expect("a refused build still files its receipt");
        assert!(!a.success(), "the 2026-09-15 rebuild is not the pinned toolchain");
        let asked = rec.0.borrow();
        assert_eq!(asked.len(), 1);
        assert_eq!(asked[0].1, PIN, "the arm handed the builder the kernel row's pin");
        assert_eq!(asked[0].0, vec![crate::image_build::staging_tag("nordisk-kernel-toolchain")], "and it built under the staging tag, never :latest");
    }

    /// **A failed build must never be served back from the idempotence cache.**
    ///
    /// This only became reachable when a red target build started returning `Ok` so
    /// its record could be filed: `cache.put` ran on every `Ok`, so the next request
    /// for the same variant would hit the cache, return a receipt, and run no
    /// builder — one bad build made permanent.
    ///
    /// RED with the `&& assembled.success()` removed from the `cache.put` guard:
    /// > `a FAILED build was served from the idempotence cache: the builder never ran`
    #[test]
    fn a_failed_build_is_not_cached_and_the_retry_really_rebuilds() {
        use std::sync::atomic::{AtomicUsize, Ordering};

        struct CountingRefusal(AtomicUsize);
        impl ImageBuilder for CountingRefusal {
            fn build(&self, _image: &ContainerBuild, _root: &Path) -> Result<BuiltImage> {
                self.0.fetch_add(1, Ordering::SeqCst);
                anyhow::bail!("engine refused: no such base image")
            }
        }

        let dir = tempfile::tempdir().unwrap();
        let set = container_set();
        let builder = CountingRefusal(AtomicUsize::new(0));
        let builders = FakeAssemble {
            image: Some(&builder),
            vm: None,
            iso: None,
            analyzer: None,
            cargo_ok: true,
            targets_ok: true,
        };
        let sink = InMemBuildInfoSink::new();
        let cache = InMemAssembleCache::new();
        let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders)
            .idempotent(&cache);
        let v = set.get("img").unwrap().clone();

        assemble(&set, &v, &ctx).unwrap();
        assemble(&set, &v, &ctx).unwrap();
        assert_eq!(
            builder.0.load(Ordering::SeqCst),
            2,
            "a FAILED build was served from the idempotence cache: the builder never ran"
        );

        // The other side. Idempotence must still WORK, or "never cache" would pass
        // this test while quietly disabling the feature.
        let ok = FakeImageBuilder {
            built: BuiltImage {
                image_ref: "app-image:1".into(),
                digest: "sha256:4444444444444444444444444444444444444444444444444444444444444444"
                    .into(),
                bytes: 4096,
                archive: None,
            },
        };
        let good_builders = FakeAssemble {
            image: Some(&ok),
            vm: None,
            iso: None,
            analyzer: None,
            cargo_ok: true,
            targets_ok: true,
        };
        let good_sink = InMemBuildInfoSink::new();
        let good_cache = InMemAssembleCache::new();
        let good_ctx =
            AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &good_sink, &good_builders)
                .idempotent(&good_cache);
        assemble(&set, &v, &good_ctx).unwrap();
        let after_first = good_sink.rows().len();
        assemble(&set, &v, &good_ctx).unwrap();
        let cached = good_sink
            .rows()
            .into_iter()
            .skip(after_first)
            .any(|r| r.level == "cache-hit");
        assert!(cached, "a SUCCESSFUL build is still cached — idempotence still works");
    }

    /// (b) The lineage carries provenance + dep + artifact rows, and the artifact
    /// row's `content_id` equals the built artifact's hash (RED when broken).
    #[test]
    fn lineage_has_provenance_dep_artifact_and_content_id_is_the_hash() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let mut set = BuildVariantSet::default();
        set.variants.push(exe_variant("tool"));

        let builders = FakeAssemble::cargo_only();
        let sink = InMemBuildInfoSink::new();
        let ctx = AssembleCtx::new(root, Path::new("/usr/bin"), &sink, &builders);
        let v = set.get("tool").unwrap().clone();
        let a = assemble(&set, &v, &ctx).unwrap();

        assert!(a.lineage.iter().any(|r| r.kind == "provenance"), "provenance rows present");
        assert!(
            a.lineage.iter().any(|r| r.kind == "dep" && r.value == "core"),
            "a dep row for the enabled feature is present"
        );
        let artifact = a
            .lineage
            .iter()
            .find(|r| r.kind == "artifact")
            .expect("an artifact row");
        // content_id == sha256 of the produced artifact bytes.
        let expected = nornir_hash::sha256_hex(b"ARTIFACT tool");
        assert_eq!(artifact.content_id, expected, "artifact content_id IS the hash");

        // The sink saw the same rows the receipt carries (best-effort emission).
        assert!(!sink.rows().is_empty(), "lineage emitted to the sink");
    }

    /// (c) `precheck` returns `Missing` with a target reason when the variant's
    /// `--target` is not installed (simulated).
    #[test]
    fn precheck_missing_when_target_not_installed() {
        let root = Path::new(".");
        let mut set = BuildVariantSet::default();
        let mut v = exe_variant("crosstool");
        v.target = Some("aarch64-unknown-linux-gnu".into());
        set.variants.push(v.clone());

        // cargo present, but the target is NOT installed.
        let builders =
            FakeAssemble { image: None, vm: None, iso: None, analyzer: None, cargo_ok: true, targets_ok: false };
        let sink = InMemBuildInfoSink::new();
        let ctx = AssembleCtx::new(root, Path::new("/usr/bin"), &sink, &builders);

        let pc = precheck(&set, &v, &ctx);
        assert!(!pc.is_ready(), "not ready: {pc:?}");
        assert!(
            pc.reasons().iter().any(|r| r.contains("aarch64-unknown-linux-gnu")),
            "the missing target is the reason: {pc:?}"
        );

        // With the target installed AND cargo present → Ready.
        let ok = FakeAssemble::cargo_only();
        let ctx_ok = AssembleCtx::new(root, Path::new("/usr/bin"), &sink, &ok);
        assert!(precheck(&set, &v, &ctx_ok).is_ready());
    }

    /// (d) A composite fat-embeds-lib assembles the lib FIRST and the parent's
    /// `dep` row `content_id` equals the lib's artifact `content_id` — the lineage
    /// graphs by content id.
    #[test]
    fn composite_threads_lib_content_id_into_parent_dep_row() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let mut set = BuildVariantSet::default();
        // The embedded lib.
        set.variants.push(BuildVariant {
            name: "corelib".into(),
            package: Some("corelib".into()),
            kind: ArtifactKind::Lib,
            // A library is linked by whoever consumes it — the fat/thin axis has no
            // answer for it, and it must SAY so rather than let the omitted key read
            // as the 'fat' default.
            shape: ServiceShape::NotApplicable,
            ..Default::default()
        });
        // The fat service that embeds it.
        set.variants.push(BuildVariant {
            name: "app".into(),
            bin: Some("app".into()),
            member: Some("app".into()),
            kind: ArtifactKind::Service,
            shape: ServiceShape::FatService,
            embeds: BTreeSet::from(["corelib".to_string()]),
            ..Default::default()
        });

        let builders = FakeAssemble::cargo_only();
        let sink = InMemBuildInfoSink::new();
        let ctx = AssembleCtx::new(root, Path::new("/usr/local/bin"), &sink, &builders);

        let parent = set.get("app").unwrap().clone();
        let a = assemble(&set, &parent, &ctx).unwrap();

        // The lib's artifact content_id, computed independently.
        let lib_cid = nornir_hash::sha256_hex(b"ARTIFACT corelib");
        let dep = a
            .lineage
            .iter()
            .find(|r| r.kind == "dep" && r.name == "embeds" && r.value == "corelib")
            .expect("the parent has an embeds dep row for corelib");
        assert_eq!(
            dep.content_id, lib_cid,
            "parent dep row content_id == the lib's artifact content_id"
        );
    }

    /// (e) A cycle in `embeds` is a clear error, not an infinite recursion.
    #[test]
    fn cycle_in_embeds_errors() {
        let mut set = BuildVariantSet::default();
        set.variants.push(BuildVariant {
            name: "a".into(),
            bin: Some("a".into()),
            member: Some("a".into()),
            kind: ArtifactKind::Service,
            embeds: BTreeSet::from(["b".to_string()]),
            ..Default::default()
        });
        set.variants.push(BuildVariant {
            name: "b".into(),
            bin: Some("b".into()),
            member: Some("b".into()),
            kind: ArtifactKind::Service,
            embeds: BTreeSet::from(["a".to_string()]),
            ..Default::default()
        });

        let builders = FakeAssemble::cargo_only();
        let sink = InMemBuildInfoSink::new();
        let dir = tempfile::tempdir().unwrap();
        let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/bin"), &sink, &builders);

        let a = set.get("a").unwrap().clone();
        let err = assemble(&set, &a, &ctx).expect_err("a<->b embed cycle must error");
        assert!(format!("{err:#}").contains("cycle"), "clear cycle error: {err:#}");
    }

    /// Idempotence: a second assemble of the same variant is a cache hit — it emits
    /// a `cache-hit` step row and does not rebuild.
    #[test]
    fn idempotent_second_assemble_is_a_cache_hit() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let mut set = BuildVariantSet::default();
        set.variants.push(exe_variant("tool"));

        let builders = FakeAssemble::cargo_only();
        let sink = InMemBuildInfoSink::new();
        let cache = InMemAssembleCache::new();
        let ctx = AssembleCtx::new(root, Path::new("/usr/bin"), &sink, &builders).idempotent(&cache);

        let v = set.get("tool").unwrap().clone();
        let first = assemble(&set, &v, &ctx).unwrap();
        let second = assemble(&set, &v, &ctx).unwrap();

        assert_eq!(first.digest_hex(), second.digest_hex(), "same receipt digest");
        assert!(
            second
                .lineage
                .iter()
                .any(|r| r.kind == "step" && r.level == "cache-hit"),
            "the cache hit emits a step row"
        );
    }

    /// A container/VM kind without an injected builder is an honest error.
    #[test]
    fn container_without_injected_builder_errors() {
        let toml = r#"
[build]
[[build.variant]]
name = "img"
kind = "container"
container_ref = "app-image"
[[build.container]]
name = "app-image"
base = "scratch"
tags = ["app-image:1"]
"#;
        let set = BuildVariantSet::from_toml_str(toml).unwrap();
        let builders = Builders::new(); // no image builder injected
        let sink = InMemBuildInfoSink::new();
        let dir = tempfile::tempdir().unwrap();
        let ctx = AssembleCtx::new(dir.path(), Path::new("/"), &sink, &builders);
        let v = set.get("img").unwrap().clone();
        let err = assemble(&set, &v, &ctx).expect_err("no ImageBuilder → error");
        assert!(format!("{err:#}").contains("ImageBuilder"), "{err:#}");
    }

    /// (slice 4b) RED-WHEN-BROKEN: `dispatch` fires the injected [`ArtifactAnalyzer`]
    /// right after producing a cargo artifact, keyed by the artifact's `content_id`,
    /// and threads its rows into BOTH the receipt lineage and the sink. Without an
    /// analyzer injected the same assemble produces NO scan rows (the seam is
    /// opt-in). Any regression that stops dispatch from calling the seam — or keys
    /// it by the wrong id — turns this red.
    #[test]
    fn dispatch_runs_injected_analyzer_keyed_by_content_id() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let mut set = BuildVariantSet::default();
        set.variants.push(exe_variant("tool"));

        // The artifact's content_id is the sha256 of the bytes FakeAssemble writes.
        let expected_cid = nornir_hash::sha256_hex(b"ARTIFACT tool");

        // WITH an analyzer injected: a `binary-scan` metric row appears, carrying
        // the artifact's content_id.
        let analyzer = FakeAnalyzer;
        let builders = FakeAssemble {
            image: None,
            vm: None,
            iso: None,
            analyzer: Some(&analyzer),
            cargo_ok: true,
            targets_ok: true,
        };
        let sink = InMemBuildInfoSink::new();
        let ctx = AssembleCtx::new(root, Path::new("/usr/bin"), &sink, &builders);
        let v = set.get("tool").unwrap().clone();
        let a = assemble(&set, &v, &ctx).unwrap();

        let scan = a
            .lineage
            .iter()
            .find(|r| r.kind == "metric" && r.step_id == "binary-scan")
            .expect("the analyzer's binary-scan row is threaded into the lineage");
        assert_eq!(
            scan.content_id, expected_cid,
            "analyzer keyed by the artifact's content_id"
        );
        // The sink saw it too (produce → analyse → persist is one pass).
        assert!(
            sink.rows().iter().any(|r| r.kind == "metric" && r.step_id == "binary-scan"),
            "the scan row was emitted to the sink"
        );

        // WITHOUT an analyzer: the seam is a no-op — no scan rows at all.
        let bare = FakeAssemble::cargo_only();
        let sink2 = InMemBuildInfoSink::new();
        let ctx2 = AssembleCtx::new(root, Path::new("/usr/bin"), &sink2, &bare);
        let a2 = assemble(&set, &v, &ctx2).unwrap();
        assert!(
            !a2.lineage.iter().any(|r| r.step_id == "binary-scan"),
            "no analyzer injected → no scan rows"
        );
    }

    // ── THE ARTIFACT GUARD ───────────────────────────────────────────────────
    //
    // gunnar's `gunnar-appliance-iso`, MEASURED 2026-08-05 on oden with the forge
    // at dwarves d984df2 + edda 876e2aa, twice, in a fresh worktree:
    //
    //     'gunnar-appliance-iso' → iso gunnar-appliance-iso (digest e0bf876b22f44e95…)
    //     BUILT 'gunnar-appliance-iso' in 145.5s — 0 artifact(s)
    //     buildthing gunnar: built gunnar-appliance-iso        ← exit 0
    //
    //     'gunnar-appliance-iso' → iso gunnar-appliance-iso (digest eb27d0b4d1a0039f…)
    //     BUILT 'gunnar-appliance-iso' in 1.0s — 0 artifact(s)  ← exit 0
    //
    // No `.iso-out/gunnar-appliance-iso.iso` after either. Two digests over two runs
    // that wrote nothing, because the digest is over the provenance RECORD (built_at,
    // host, outcome) and was being presented as the artifact's identity.
    //
    // The composer had bailed on its third step both times and said so — into
    // `TargetBuildOutcome::error`, which the three target arms of `dispatch` dropped.

    /// A declared appliance-ISO cell: a `kind = "iso"` variant carrying the ISO handle
    /// in its own name, over a `[[build.iso]]` that chains a cargo variant. gunnar's
    /// shape, reduced to what the dispatch reads.
    fn iso_set() -> BuildVariantSet {
        BuildVariantSet::from_toml_str(
            r#"
[build]
[[build.variant]]
name = "appliance-iso"
kind = "iso"

[[build.iso]]
name = "appliance-iso"
volume_id = "APPLIANCE"
format = "uefi"
staging = "iso-root"
"#,
        )
        .unwrap()
    }

    /// An [`IsoImageBuilder`] that fails the way the real appliance composer failed —
    /// on a declared input that is not there.
    struct RefusingIsoBuilder;
    impl IsoImageBuilder for RefusingIsoBuilder {
        fn engine(&self) -> &str {
            "korp-appliance"
        }
        fn build(&self, iso: &IsoBuild, _root: &Path) -> Result<Vec<Artifact>> {
            anyhow::bail!(
                "iso '{}': declares `staging = \".iso-out/appliance-root\"` but it is not a \
                 directory",
                iso.name
            )
        }
    }

    /// An [`IsoImageBuilder`] that reports SUCCESS and masters nothing — the second,
    /// quieter red, and the one no error string can catch.
    struct EmptyIsoBuilder;
    impl IsoImageBuilder for EmptyIsoBuilder {
        fn engine(&self) -> &str {
            "korp-appliance"
        }
        fn build(&self, _iso: &IsoBuild, _root: &Path) -> Result<Vec<Artifact>> {
            Ok(Vec::new())
        }
    }

    /// An [`IsoImageBuilder`] that masters a real file — the GREEN side, without
    /// which a `produced()`-says-no-to-everything implementation passes.
    struct MasteringIsoBuilder;
    impl IsoImageBuilder for MasteringIsoBuilder {
        fn engine(&self) -> &str {
            "korp-appliance"
        }
        fn build(&self, iso: &IsoBuild, root: &Path) -> Result<Vec<Artifact>> {
            let out = crate::image_build::iso_output_path(root, iso);
            std::fs::create_dir_all(out.parent().unwrap())?;
            std::fs::write(&out, b"CD001 not really, but real bytes on disk")?;
            Ok(vec![Artifact::hash_file(root, &out)?])
        }
    }

    /// **A builder that FAILED must not come back as a receipt that says it built.**
    ///
    /// The receipt still exists — a red target build files its provenance, which is
    /// the contract `capture_target_build` was given deliberately. What is new is
    /// that it can be ASKED, and that the builder's own sentence survives the trip.
    ///
    /// SEEN RED before the fix: `produced()` did not exist, `Assembled` had no
    /// verdict field, and the only thing a consumer could reach was
    /// `artifact_content_ids().len() == 0` — the number dwarves printed beside
    /// `BUILT`.
    #[test]
    fn an_iso_builder_that_failed_is_not_a_receipt_that_built() {
        let dir = tempfile::tempdir().unwrap();
        let set = iso_set();
        let builders = FakeAssemble {
            image: None,
            vm: None,
            iso: Some(&RefusingIsoBuilder),
            analyzer: None,
            cargo_ok: true,
            targets_ok: true,
        };
        let sink = InMemBuildInfoSink::new();
        let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders);
        let v = set.get("appliance-iso").unwrap().clone();

        let a = assemble(&set, &v, &ctx).expect("a failed target build still files a receipt");
        assert!(!a.success(), "the receipt says the build failed");
        assert!(a.artifact_content_ids().is_empty(), "and it produced nothing");

        let why = a.produced().expect_err("a failed build is not a produced thing");
        let msg = format!("{why:#}");
        assert!(
            msg.contains(".iso-out/appliance-root"),
            "the builder's OWN reason survives to the caller — this is the sentence that \
             was being discarded inside nornir-build-thing: {msg}"
        );
        assert!(msg.contains("appliance-iso"), "and it names the cell: {msg}");
    }

    /// **A builder that reported SUCCESS and wrote nothing is a red that names the
    /// path it did not write.**
    ///
    /// This is the shape no error string can catch, and the one the guard exists for:
    /// there is no failure to propagate, only an empty artifact list. The message has
    /// to carry the expected path, because `0 artifact(s)` is a number and
    /// `<repo>/.iso-out/appliance-iso.iso` is a place to look.
    #[test]
    fn an_iso_that_mastered_nothing_names_the_file_it_did_not_write() {
        let dir = tempfile::tempdir().unwrap();
        let set = iso_set();
        let builders = FakeAssemble {
            image: None,
            vm: None,
            iso: Some(&EmptyIsoBuilder),
            analyzer: None,
            cargo_ok: true,
            targets_ok: true,
        };
        let sink = InMemBuildInfoSink::new();
        let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders);
        let v = set.get("appliance-iso").unwrap().clone();

        let a = assemble(&set, &v, &ctx).expect("the builder returned Ok, so there is a receipt");
        // The trap this guard must not fall into: the build DID succeed by every
        // signal the old code read.
        assert!(a.success(), "the outcome is Success — nothing red to key on");

        let why = a
            .produced()
            .expect_err("success with no artifact is not a produced thing");
        let msg = format!("{why:#}");
        let expected = crate::image_build::iso_output_path(dir.path(), set.iso("appliance-iso").unwrap());
        assert!(
            msg.contains(&expected.display().to_string()),
            "the red names WHERE the ISO was expected ({}), not just how many came out: {msg}",
            expected.display()
        );
        assert!(
            !expected.exists(),
            "and the file it names is genuinely absent (the guard is not reading a stale one)"
        );
    }

    /// **The green side, asserted on the BYTES.** Without it the two reds above pass
    /// against a `produced()` that always says no.
    #[test]
    fn an_iso_that_mastered_a_file_is_a_produced_thing() {
        let dir = tempfile::tempdir().unwrap();
        let set = iso_set();
        let builders = FakeAssemble {
            image: None,
            vm: None,
            iso: Some(&MasteringIsoBuilder),
            analyzer: None,
            cargo_ok: true,
            targets_ok: true,
        };
        let sink = InMemBuildInfoSink::new();
        let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders);
        let v = set.get("appliance-iso").unwrap().clone();

        let a = assemble(&set, &v, &ctx).unwrap();
        a.produced().expect("a mastered ISO IS a produced thing");

        // Applied output, off the disk — not the receipt's word for it. The path the
        // receipt reports is what gets stat'd, so a receipt naming a file nobody wrote
        // cannot pass this.
        let paths = a.artifact_paths();
        assert_eq!(paths.len(), 1, "one mastered ISO, one artifact row: {paths:?}");
        let on_disk = dir.path().join(&paths[0]);
        let bytes = std::fs::metadata(&on_disk)
            .unwrap_or_else(|e| panic!("the receipt names {} and it is not there: {e}", on_disk.display()))
            .len();
        assert_eq!(bytes, 40, "the artifact row points at the bytes that were written");
    }
}