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");
}
}