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

//! **The three types the 2026-08-06 build-thing design is built on.** Nothing
//! here does any work; it exists so three lanes can be written at once without
//! building against each other.
//!
//! The design is in `workspace_nordisk/PLAN.md`, section *"BUILD THING — MERGAD
//! DESIGN, beslutad 2026-08-06"*. The short version:
//!
//! * a build thing is a **published crate** — code, not data, not a wasm module
//!   shipped inside the things it builds;
//! * a built artifact carries a **receipt** naming the recipe that built it and
//!   every resolved setting, so the artifact alone is enough to rebuild it;
//! * a **coordinate** addresses a recipe when no artifact exists yet to read a
//!   receipt out of.
//!
//! # Why the receipt is not the recipe
//!
//! A recipe that travels inside every product it built is duplicated in every
//! product it built. The receipt is a few hundred bytes and says *which* recipe
//! ran; the recipe itself is fetched by [`Coordinate`] when someone actually
//! wants to rebuild. This is the same split as a lockfile against a registry.
//!
//! # The property all of this exists for
//!
//! An ISO on a USB stick in 2029, with no network, can be rebuilt. Every design
//! choice here that looks like extra work is that property being paid for:
//! resolved values rather than defaults, a content hash rather than a version
//! alone, the receipt inside the artifact rather than beside it in a register.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::variant::ArtifactKind;

/// The linker section a built artifact carries its [`Receipt`] in.
///
/// Read it with an object parser — never by executing the artifact. Parsing an
/// object file is not running it, which is why a receipt can be read out of a
/// binary for an architecture this host could not execute.
///
/// This is the same section [`crate::plugin::SECTION`] names. It now carries the
/// receipt rather than a wasm module; the name is unchanged because nothing has
/// shipped in it yet.
pub const RECEIPT_SECTION: &str = ".nordisk.buildthing";

/// The receipt schema version, written into every [`Receipt`].
///
/// A reader that meets a higher number must refuse rather than guess. A receipt
/// that is misread is worse than a receipt that is missing: the first answers
/// the rebuild question wrongly and the second answers it not at all.
pub const RECEIPT_VERSION: u32 = 1;

/// Which panel the build-thing scene opens for a finished artifact.
///
/// This is the whole switch. The scene renders what a recipe declares and knows
/// no product names — a product name appearing in the scene's source means
/// somebody put an `if` back.
///
/// # The default is derived, not decided
///
/// [`PaneKind::for_artifact`] maps every [`ArtifactKind`] to a pane. A recipe may
/// override it, but it never has to, and the override is the exception rather
/// than the interface.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PaneKind {
    /// Nothing to run: an `--lib` or a wasm module. The pane shows the artifact
    /// and its receipt, and stops there.
    None,
    /// An argv field and stdout. What an executable can offer and no more.
    Exec,
    /// Start, stop, a log, and whatever ports the recipe declared.
    Log,
    /// Run, reboot, stop, and a console that switches between serial and
    /// graphical.
    Console,
}

impl PaneKind {
    /// The pane an artifact kind gets unless its recipe says otherwise.
    ///
    /// `Service` is [`PaneKind::Log`] rather than [`PaneKind::Exec`]: a daemon
    /// started from an argv field with no way to stop it again is a worse
    /// affordance than no affordance.
    #[must_use]
    pub const fn for_artifact(kind: ArtifactKind) -> Self {
        match kind {
            // A built kernel is an artifact + receipt, not a runnable: a bare
            // `bzImage` boots nothing without a medium/rootfs around it — the
            // vm/iso kinds that CONSUME it are the ones with consoles.
            // A package is a produced FILE SET — the wheel, the sdist, the sums
            // and the receipt. There is nothing to run: `pip install` is what
            // consumes it, and a pane offering an argv field for a wheel would be
            // offering an affordance that cannot work.
            ArtifactKind::Lib
            | ArtifactKind::Wasm
            | ArtifactKind::Kernel
            | ArtifactKind::Package => Self::None,
            ArtifactKind::Exe => Self::Exec,
            ArtifactKind::Container | ArtifactKind::Service => Self::Log,
            ArtifactKind::Vm | ArtifactKind::Iso | ArtifactKind::Installer => Self::Console,
        }
    }
}

/// Where a recipe lives, and which bytes of it.
///
/// Two ways to obtain one, one thing to obtain:
///
/// ```text
/// from an artifact   read it out of the receipt
/// by hand            type it, before any artifact exists
/// ```
///
/// # `hash` is what makes this honest
///
/// `krate@version` says which recipe was *intended*. The hash says which one
/// actually ran. They differ the day two artifacts carrying the same version
/// number are not identical, and that is exactly the day the field is needed and
/// far too late to add it. It is [`Option`] only because a coordinate typed by
/// hand before the first build has nothing to pin to yet.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Coordinate {
    /// The published crate name, e.g. `gunnar-build-thing`.
    pub krate: String,
    /// The published version, e.g. `0.3.1`.
    pub version: String,
    /// The recipe's content hash. `None` only before a first build has pinned it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hash: Option<String>,
}

impl Coordinate {
    /// Parse `krate:version` or `krate:version:hash`.
    ///
    /// # Errors
    ///
    /// Returns the offending string when there are not two or three
    /// colon-separated parts, or when any part is empty. An empty part is
    /// rejected rather than defaulted: `gunnar-build-thing::a91f4c` silently
    /// meaning "any version" is how a rebuild ends up on the wrong recipe.
    pub fn parse(s: &str) -> Result<Self, String> {
        let parts: Vec<&str> = s.split(':').collect();
        let (krate, version, hash) = match parts.as_slice() {
            [k, v] => (*k, *v, None),
            [k, v, h] => (*k, *v, Some((*h).to_string())),
            _ => return Err(s.to_string()),
        };
        if krate.is_empty() || version.is_empty() || hash.as_deref() == Some("") {
            return Err(s.to_string());
        }
        Ok(Self {
            krate: krate.to_string(),
            version: version.to_string(),
            hash,
        })
    }
}

impl std::fmt::Display for Coordinate {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}:{}", self.krate, self.version)?;
        if let Some(h) = &self.hash {
            write!(f, ":{h}")?;
        }
        Ok(())
    }
}

/// What a built artifact carries about its own build, in [`RECEIPT_SECTION`].
///
/// # Two traps that make a receipt lie, both paid for in the types
///
/// **Ordering.** `settings` and `inputs` are [`BTreeMap`], never [`HashMap`].
/// Two identical builds must serialise byte-identically, and a hash map's
/// iteration order is not a property you can rely on across runs.
///
/// [`HashMap`]: std::collections::HashMap
///
/// **Resolved values, not defaults.** `settings` records what the build actually
/// used, including every value the caller never mentioned. A default that
/// changes in the next release otherwise makes every old receipt retroactively
/// false — it would claim a setting the build never had.
///
/// Absolute paths and timestamps do not belong in either map. They change
/// between two builds that are otherwise identical, and a receipt that differs
/// for no reason cannot be compared.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Receipt {
    /// [`RECEIPT_VERSION`] at the time of writing.
    pub receipt_version: u32,
    /// The recipe that built this. Its `hash` is always populated here — a
    /// receipt is written after the build, so the bytes that ran are known.
    pub build_thing: Coordinate,
    /// The atom this artifact is, e.g. `korp-server`.
    pub atom: String,
    /// The delivery target, e.g. `exe`, `container`, `iso-installer`.
    pub target: String,
    /// Every build setting, resolved. Sorted, so the receipt is reproducible.
    pub settings: BTreeMap<String, String>,
    /// Source commit, toolchain, target triple. Sorted, for the same reason.
    pub inputs: BTreeMap<String, String>,
}

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

    /// The two-part and three-part forms both parse, and round-trip.
    #[test]
    fn a_coordinate_survives_a_round_trip_with_and_without_a_hash() {
        for s in [
            "gunnar-build-thing:0.3.1",
            "gunnar-build-thing:0.3.1:a91f4c2e",
        ] {
            let c = Coordinate::parse(s).expect("should parse");
            assert_eq!(c.to_string(), s, "round trip changed {s}");
        }
    }

    /// LAW 2: the refusals are the point of the parser, so they get the test.
    ///
    /// Every one of these is a coordinate that would otherwise resolve to *some*
    /// recipe rather than none, which is the failure worth preventing.
    #[test]
    fn a_coordinate_refuses_every_shape_that_would_resolve_to_the_wrong_recipe() {
        for bad in [
            "gunnar-build-thing",        // no version at all
            "gunnar-build-thing:",       // empty version
            ":0.3.1",                    // empty crate
            "gunnar-build-thing:0.3.1:", // empty hash, not "no hash"
            "a:b:c:d",                   // four parts
            "",
        ] {
            assert!(
                Coordinate::parse(bad).is_err(),
                "{bad:?} parsed, and it must not"
            );
        }
    }

    /// The scene's whole switch, asserted so a new `ArtifactKind` cannot be
    /// added without a decision about which pane it opens.
    ///
    /// ⚠ **It could not do that until 2026-09-06, and this is the hollow-guard
    /// shape LAW 2 names.** The list below was written out BY HAND, so a tenth
    /// kind arriving with no considered pane would have taken the compiler's
    /// answer in `for_artifact` and stayed green here — a test whose whole stated
    /// purpose is "a new kind cannot be added without a decision", passing for a
    /// kind nobody decided about. The sweep over
    /// [`ArtifactKind::ALL`](crate::variant::ArtifactKind::ALL) below is the half
    /// that makes the sentence true; the named assertions stay because they say
    /// which pane and why.
    #[test]
    fn every_artifact_kind_maps_to_the_pane_the_design_names() {
        use ArtifactKind::{Container, Exe, Iso, Kernel, Lib, Service, Vm, Wasm};

        // The sweep FIRST: every kind the enum has must be named below. `ALL` is
        // generated from the same list the enum is, so it cannot fall behind it.
        let named = [
            Exe,
            Lib,
            Wasm,
            Container,
            Vm,
            Iso,
            ArtifactKind::Installer,
            Service,
            Kernel,
            ArtifactKind::Package,
        ];
        for k in ArtifactKind::ALL {
            assert!(
                named.contains(k),
                "{k:?} has no pane decision in this test — a kind was added to the \
                 enum and nobody said which pane it opens"
            );
        }

        assert_eq!(PaneKind::for_artifact(Exe), PaneKind::Exec);
        assert_eq!(PaneKind::for_artifact(Container), PaneKind::Log);
        assert_eq!(PaneKind::for_artifact(Service), PaneKind::Log);
        assert_eq!(PaneKind::for_artifact(Iso), PaneKind::Console);
        assert_eq!(PaneKind::for_artifact(Vm), PaneKind::Console);
        assert_eq!(PaneKind::for_artifact(Lib), PaneKind::None);
        assert_eq!(PaneKind::for_artifact(Wasm), PaneKind::None);
        // A bare bzImage runs nothing by itself — the artifact + its pin file
        // ARE the pane; the vm/iso that consume it own the consoles.
        assert_eq!(PaneKind::for_artifact(Kernel), PaneKind::None);
        // A produced file set has nothing to launch: the pane shows the four
        // files and the receipt, and stops there.
        assert_eq!(PaneKind::for_artifact(ArtifactKind::Package), PaneKind::None);
    }

    /// Where the keys of `settings` land in a serialised receipt, in order of
    /// appearance.
    ///
    /// Exists so the ordering guard below can be pointed at a string it did not
    /// produce, and therefore be watched failing.
    fn settings_keys_in_wire_order(json: &str) -> Vec<String> {
        let tail = json
            .split("\"settings\":{")
            .nth(1)
            .expect("no settings object");
        let body = tail
            .split('}')
            .next()
            .expect("unterminated settings object");
        body.split(',')
            .filter_map(|pair| pair.split(':').next())
            .map(|k| k.trim().trim_matches('"').to_string())
            .filter(|k| !k.is_empty())
            .collect()
    }

    /// LAW 2, on the guard below: the ordering check catches an unsorted object.
    ///
    /// Without this, `two_receipts_built_in_different_orders_serialise_alike`
    /// would be blind — serialising *one* map twice yields identical bytes even
    /// for a `HashMap`, because a given map's iteration order is fixed for its
    /// lifetime. That version of the test was written first and could never have
    /// reported red.
    #[test]
    fn the_ordering_check_reports_red_on_an_unsorted_object() {
        let unsorted = r#"{"settings":{"zulu":"x","alpha":"x"},"atom":"a"}"#;
        let keys = settings_keys_in_wire_order(unsorted);
        assert_eq!(keys, vec!["zulu", "alpha"], "the extractor missed the keys");
        assert!(
            !keys.windows(2).all(|w| w[0] <= w[1]),
            "the check calls an unsorted object sorted, so it can never fail"
        );
    }

    /// Two receipts holding the same settings, inserted in opposite orders,
    /// serialise to the same bytes — and those bytes are key-sorted.
    ///
    /// This is what the `BTreeMap` choice exists for. Swap the field to a
    /// `HashMap` and the sortedness assertion falls over.
    #[test]
    fn two_receipts_built_in_different_orders_serialise_alike() {
        let keys = ["zulu", "gpu", "alpha", "suite", "base"];
        let forward: BTreeMap<String, String> = keys
            .iter()
            .map(|k| ((*k).to_string(), "x".to_string()))
            .collect();
        let mut settings = BTreeMap::new();
        for k in keys.iter().rev() {
            settings.insert((*k).to_string(), "x".to_string());
        }
        let r = Receipt {
            receipt_version: RECEIPT_VERSION,
            build_thing: Coordinate::parse("korp-build-thing:0.3.1:a91f4c2e").unwrap(),
            atom: "korp-server".to_string(),
            target: "iso-installer".to_string(),
            settings,
            inputs: BTreeMap::new(),
        };
        let mut other = r.clone();
        other.settings = forward;

        let a = serde_json::to_string(&r).expect("serialise");
        let b = serde_json::to_string(&other).expect("serialise");
        assert_eq!(a, b, "insertion order leaked into the receipt");

        let wire = settings_keys_in_wire_order(&a);
        assert_eq!(wire.len(), keys.len(), "extractor lost keys: {wire:?}");
        assert!(
            wire.windows(2).all(|w| w[0] <= w[1]),
            "receipt keys are not sorted on the wire: {wire:?}"
        );

        let back: Receipt = serde_json::from_str(&a).expect("deserialise");
        assert_eq!(back, r, "a receipt did not survive its own round trip");
    }
}