crates/nornir-build-thing/src/recipe.rs
//! **The recipe** — nornir's `impl BuildThing`, the ONE declaration of what this
//! repository builds. `.nornir/build-variants.toml` is emitted from
//! [`NornirBuildThing::variants`] (see [`crate::emit`]); nothing here reads it.
//!
//! # What nornir declares
//!
//! ONE delivery (`ORIGINAL`) and FIVE atoms, and the five are the fleet build
//! matrix's five rows (`workspace_nordisk/.nornir/build-matrix-2026-08-02.md`)
//! with nothing added:
//!
//! | variant | role | linkage | bin | what it is |
//! |---|---|---|---|---|
//! | `nornir-server` ⭐ | server | fat | `nornir-server` | the daemon: tonic gRPC on :7878, all 20 services, sole owner of the redb registry and the iceberg warehouse lock |
//! | `nornir` ⭐ | client | fat | `nornir` | the CLI, everything compiled in |
//! | `nornir-cli` | client | mixed | `nornir-cli` | the CLI that REACHES holger instead of embedding it |
//! | `nornir-ui` | client | mixed | `nornir-ui` | the GUI (`src/bin/urdr-threads.rs`), `viz` alone |
//! | `nornir-solo` ⭐ | solo | fat | `nornir-solo` | the one-box shape, server embedded |
//!
//! # `linkage = "mixed"` is a DEFINED value, and this is where nornir needs it
//!
//! The build-matrix doc once called `mixed` "a fourth linkage value nobody
//! documented". It is declared, parsed, rendered and dispatched on in the
//! evaluator ([`ServiceShape::Mixed`], parsed from `"mixed"|"mix"|"configurable"`,
//! audited as embedding like `fat`, mapped to `AgentMode::Fat` because it holds
//! state). `nornir-cli` and `nornir-ui` link skade in-process and REACH holger
//! over the wire — neither `fat` nor `thin` is true of them, and saying either
//! would be the lie this axis exists to prevent.
//!
//! # `embeds` is declared ONLY on `nornir-server`, and that is a shape rule
//!
//! Not because the other atoms embed nothing — they all link skade, and the fat
//! ones link holger too — but because `validate_shape` REJECTS `embeds` on a
//! non-service kind: *"`embeds` requires kind 'service' (or 'container'); kind
//! 'exe' produces no composed service"*. The information is not lost: `linkage`
//! carries it and `reaches` names the wire edges. Making the CLI `kind =
//! "service"` to keep the field would be worse — `service` wraps as a
//! skidbladnir `ServiceSpec` with a system user and a unit, which is exactly the
//! headless deploy the `solo` role exists to prevent.
//!
//! # Two rows the matrix does not have, and why they are not here
//!
//! * **`nornir-watch`** — the `nornir` CLI plus `inotify`. One feature flag on an
//! existing atom is not a delivery, and the CLI already degrades honestly
//! without it. The `[[bin]]` and `src/knowledge/watch.rs` STAY and
//! `.woodpecker.yml` still compiles `--features inotify`, so the loop cannot
//! rot; what is gone is the claim that it is a separate product.
//! * **`nornir-mcp`** — Rickard, 2026-08-03: *"mcp skall väl ingå inkompilerat i
//! server!"* MCP is a CAPABILITY of the service, not a delivery beside it. The
//! stdio loop moved into the library as `nornir::mcp_stdio` and `nornir-server`
//! serves it as `nornir-server mcp`. The capability did not leave with the atom.
//!
//! # The feature axis is UNDECLARED, and that is written down rather than hidden
//!
//! [`offers`] is EMPTY, which the evaluator reads as *unconstrained*: every
//! feature the rows name is accepted without ever being checked against
//! `Cargo.toml`. nornir has ~40 real features and the five atoms name seventeen
//! counting the union. This is recorded as known debt rather than closed by
//! inventing atoms — an unconstrained feature axis reads as green because
//! nothing can fail it, which is the same vacuous-green the `linkage`, `default`
//! and `bin` axes exist to close. Closing it means listing nornir's real feature
//! space and writing a reason for each unbuilt one, the way skade and znippy do,
//! and it is the next thing this crate should grow.
use std::collections::{BTreeMap, BTreeSet};
use nbt::cargo_container::CargoToolchain;
use nbt::contract::Coordinate;
use nbt::plugin::{BuildThing, Manifest, Role, ABI_VERSION};
use nbt::variant::{AllowEntry, ArtifactKind, BuildVariant, BuildVariantSet, Profile, ServiceShape};
/// The crate a receipt names as its recipe.
pub const KRATE: &str = "nornir-build-thing";
/// The content hash over this crate's sources, computed by `build.rs`.
pub const CONTENT_HASH: &str = env!("NORNIR_BUILD_THING_CONTENT_HASH");
/// The one package every atom is built out of.
pub const PACKAGE: &str = "nornir";
/// The suite node every atom belongs to.
pub const MEMBER: &str = "nornir";
/// **The delivery this repo ships in.** `suites` is a LIST — one row may ship in
/// more than one flavour — but nornir has exactly one, and the tag is not
/// redundant: it makes `cargo xtask build --suite ORIGINAL` the SAME command in
/// korp, dwarves, nornir and modgunn, and holger's second delivery is `--suite
/// MVP` with no new flag and no tooling special case. An EMPTY list is not a
/// third flavour, it is a row delivery cannot select.
pub const SUITE: &str = "ORIGINAL";
/// nornir's build thing.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct NornirBuildThing;
impl NornirBuildThing {
/// The coordinate a receipt names this recipe by: `krate:version:hash`.
#[must_use]
pub fn coordinate() -> Coordinate {
Coordinate {
krate: KRATE.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
hash: Some(CONTENT_HASH.to_string()),
}
}
}
fn s(x: &str) -> String {
x.to_string()
}
fn set(xs: &[&str]) -> BTreeSet<String> {
xs.iter().map(|x| s(x)).collect()
}
/// The features the two FAT client-side atoms share. `nornir` takes this union
/// plus `embed-holger`; `nornir-cli` takes it plus `atom-cli` and WITHOUT
/// `embed-holger` — those two names are the whole fat/mixed difference, and
/// writing the shared part once is what stops the two lists from drifting apart
/// by a typo nothing would notice.
fn client_core() -> Vec<&'static str> {
vec![
"server",
"viz",
"mcp",
"net-scan",
"docs-export",
"robot",
"vector",
"embed-tract",
"embed-ort",
"gen-candle",
"gen-ollama",
"manual-embed",
"claude",
]
}
fn feats(base: &[&str], extra: &[&str]) -> Vec<String> {
base.iter().chain(extra.iter()).map(|f| s(f)).collect()
}
/// The five atoms, in matrix order.
pub fn variants() -> Vec<BuildVariant> {
vec![
// ⭐ ATOM 1/5 — THE DAEMON. `src/bin/nornir-server.rs` →
// `nornir::server_grpc::run()`. This is what systemd runs; it is the
// artifact `role = "server"` is actually about, and until 2026-08-01 NO
// variant built it — three copies of the CLI sat here calling themselves
// servers instead. `viz` is not optional for it: the server's `Viz.*`
// RPCs reuse `viz::knowledge`.
BuildVariant {
name: s("nornir-server"),
role: Some(Role::Server),
is_default: true,
suites: vec![s(SUITE)],
member: Some(s(MEMBER)),
package: Some(s(PACKAGE)),
bin: Some(s("nornir-server")),
kind: ArtifactKind::Service,
shape: ServiceShape::FatService,
no_default_features: true,
features: feats(&client_core(), &["embed-holger"]),
// The ONLY row that may carry these: see the module doc.
embeds: set(&["holger", "skade"]),
reaches: set(&["dwarves"]),
profile: Profile::Release,
..BuildVariant::default()
},
// ⭐ ATOM 2/5 — THE CLI, fat. `embed-holger` is THE fat/mixed
// discriminator and the one real compile-time linkage choice nornir has:
// `embed-holger = ["dep:holger-server-lib"]` and that dep is the only
// `optional = true` sibling. skade, iceberg and parquet are PLAIN deps —
// every nornir binary links the warehouse engine in-process, so no
// feature choice can make nornir thin.
BuildVariant {
name: s("nornir"),
role: Some(Role::Client),
is_default: true,
suites: vec![s(SUITE)],
member: Some(s(MEMBER)),
package: Some(s(PACKAGE)),
bin: Some(s("nornir")),
kind: ArtifactKind::Exe,
shape: ServiceShape::FatService,
no_default_features: true,
features: feats(&client_core(), &["embed-holger"]),
reaches: set(&["dwarves"]),
profile: Profile::Release,
..BuildVariant::default()
},
// ATOM 3/5 — THE CLI that reaches holger. `atom-cli` is the marker
// feature that keys the `nornir-cli` `[[bin]]`'s `required-features`,
// which is how three feature builds stopped writing ONE path: distinct
// `[[bin]]` names off one source, told apart by `required-features`.
// Renamed `thin` → `atom-cli` on 2026-08-03, because a marker named
// `thin` on an atom that is measurably `mixed` re-created the exact
// contradiction the rename removes, one layer down.
BuildVariant {
name: s("nornir-cli"),
role: Some(Role::Client),
is_default: false,
suites: vec![s(SUITE)],
member: Some(s(MEMBER)),
package: Some(s(PACKAGE)),
bin: Some(s("nornir-cli")),
kind: ArtifactKind::Exe,
shape: ServiceShape::Mixed,
no_default_features: true,
features: feats(&client_core(), &["atom-cli"]),
reaches: set(&["holger", "dwarves"]),
profile: Profile::Release,
..BuildVariant::default()
},
// ATOM 4/5 — THE GUI, `src/bin/nornir-ui.rs` (renamed from
// `urdr-threads` on 2026-08-01). `viz` ALONE: the GUI is the one atom
// whose feature list is not a slice of the client union, and that is the
// point — it carries no server, no embedders and no generators.
BuildVariant {
name: s("nornir-ui"),
role: Some(Role::Client),
is_default: false,
suites: vec![s(SUITE)],
member: Some(s(MEMBER)),
package: Some(s(PACKAGE)),
bin: Some(s("nornir-ui")),
kind: ArtifactKind::Exe,
shape: ServiceShape::Mixed,
no_default_features: true,
features: vec![s("viz")],
reaches: set(&["holger", "dwarves"]),
profile: Profile::Release,
..BuildVariant::default()
},
// ⭐ ATOM 5/5 — THE ONE-BOX SHAPE. `solo` is what embeds the server in
// the desktop artifact, and `tests/solo_embeds_server.rs` goes red if
// `.woodpecker.yml` stops compiling `--features solo,robot-remote`. A
// deliberately SHORTER feature list than the fat CLI: the generators and
// the remote embedders are not what a single box wants compiled in.
BuildVariant {
name: s("nornir-solo"),
role: Some(Role::Solo),
is_default: true,
suites: vec![s(SUITE)],
member: Some(s(MEMBER)),
package: Some(s(PACKAGE)),
bin: Some(s("nornir-solo")),
kind: ArtifactKind::Exe,
shape: ServiceShape::FatService,
no_default_features: true,
features: vec![
s("solo"),
s("server"),
s("viz"),
s("vector"),
s("embed-tract"),
s("manual-embed"),
s("embed-holger"),
],
reaches: set(&["dwarves"]),
profile: Profile::Release,
..BuildVariant::default()
},
]
}
/// **EMPTY, and said out loud.** See the module doc: nornir's feature space is
/// undeclared, the evaluator reads an empty `offers` as unconstrained, and that
/// is recorded debt rather than a closed axis. It is a function rather than a
/// literal so the day someone lists the ~40 real features, there is one place to
/// put them and [`allow_unbuilt`] below is already waiting for the reasons.
fn offers() -> Vec<&'static str> {
Vec::new()
}
/// **Features no atom builds, each with a written reason.** Empty while
/// [`offers`] is empty — an excuse for a feature nothing offers would be an
/// excuse for nothing. The two that will go here first are named in the module
/// doc: `inotify` (the `nornir-watch` `[[bin]]`, compiled by `.woodpecker.yml`
/// and guarded by `tests/inotify_watch_wiring.rs`, but named by no atom) and
/// `robot-remote` (compiled by the same CI line, named by no atom).
pub fn allow_unbuilt() -> BTreeMap<String, String> {
BTreeMap::new()
}
/// **The `[[bin]]` targets no atom builds, each with its reason** —
/// `[build.allow_unbuilt_bins]`. The forge's build gate
/// (`nbt::buildgate::tally_bins`) compares every bin cargo can see in this tree
/// against what the declared atoms produced; the five atoms are the fleet matrix
/// and nothing else (`the_five_atoms_are_the_matrix_rows…`), so every other bin is
/// named HERE with the reason it has no row. MEASURED 2026-10-16 on loki: ⚒ Build on
/// nornir reported "18 of 18 declared bins were NEVER BUILT" — five were the atoms
/// (unbuilt only because the sibling refusal stopped every cell), these thirteen had
/// no row and no reason. A new `[[bin]]` lands red until it gets one or the other.
fn allow_unbuilt_bins() -> BTreeMap<String, AllowEntry> {
const DRIVER: &str = "a bin of crates/mimir-driver, the Mímir v2 rustc driver: it links \
the compiler's own private crates, is EXCLUDED from this workspace, pins its own dated nightly in its \
rust-toolchain.toml and keeps its own Cargo.lock — the stable pinned \
nordisk-cargo-toolchain image cannot compile it, so no atom of this workspace can.";
const FIXTURE: &str = "a TEST FIXTURE package under crates/mimir-driver/tests/ (its own \
workspace, driven by mimir-driver's integration tests), not a deliverable.";
[
("mimir-driver", DRIVER),
("cargo-mimir-dump", DRIVER),
("mimir-capture", DRIVER),
("mimir-super", DRIVER),
("bctl", FIXTURE),
("twin", FIXTURE),
(
"bindrift_probe",
"the red-when-broken fixture binary of crates/bindrift-fixtures (its build.rs \
injects code the git diff never shows, for Mimir.BinaryDrift to catch) — a \
test subject, never shipped.",
),
(
"mimir-binary",
"the Mímir v2 binary-layer CLI (join one linked artifact onto a driver dump) — an \
operator/dev tool run from the workspace; not one of the five fleet-matrix rows.",
),
(
"mimir-room",
"the Mímir v2 code-room CLI (stats/find/reach/diff/bench over a lake) — a dev and \
bench tool; the room ships inside nornir-server as a library, not as this bin.",
),
(
"nornir-build-thing",
"this recipe crate's own emit door (`cargo run -p nornir-build-thing -- emit`); the \
forge loads the crate as wasm, the bin is a maintainer tool.",
),
(
"xtask",
"the `cargo xtask` dev runner — a maintainer tool, never a delivery.",
),
(
"nornir-demo",
"the Pages demo host: its wasm row is not declared until the owner decides how it \
enters the matrix (xtask refuses demo-wasm by name until then); not a fleet row.",
),
(
"nornir-viz-web",
"the viz-as-website crate (wasm bundle + a native dev window/serve path) — not one \
of the five fleet-matrix rows.",
),
]
.into_iter()
.map(|(k, v)| (s(k), AllowEntry::Reason(s(v))))
.collect()
}
/// **The suite dependency graph as an embed-allowlist**: member → the siblings
/// strictly DOWNSTREAM of it, which are the only ones a fat atom of that member
/// may compile IN. nornir may embed holger and skade; **dwarves is deliberately
/// NOT here** — nornir reaches the forge over the wire and compiling it in would
/// be the dependency inversion this list turns from a review comment into a
/// refusal.
fn downstream() -> BTreeMap<String, BTreeSet<String>> {
[
(s("nornir"), set(&["holger", "skade"])),
(s("holger"), set(&["skade"])),
]
.into_iter()
.collect()
}
/// **The pinned build image every cargo variant compiles in** — `[build.toolchain]`.
///
/// An absent table is not "builds on the host": `CargoToolchain::validate`
/// refuses it — *"no `image_sha256` — a named tag is a convenience, NOT a
/// pin"* — the code road closes, and the forge falls back to reading the
/// emitted toml as if it were the source, which is the description driving the
/// build it was written to describe. So the table is declared with the same
/// digest the rest of the constellation's recipes name.
///
/// The digest is the **MANIFEST** digest (64 lowercase hex, no `sha256:`). It
/// moves only when the image RECIPE moves, in the same commit.
/// MOVED 2026-09-20 (loki). The value below was
/// `2cf9b012c9d2ffa42b447e5b0d127184e5a3f998dd2574d771477e9c8e366659` — a
/// second mint of this same recipe which exists on exactly one box, t14s, and
/// sat there UNTAGGED (`<none>:<none>`, 2026-07-13). On loki it does not exist
/// at all, so `CargoToolchain::validate` refused every container build of this
/// repo BY DIGEST — correctly, and that is the whole point of a pin: it told
/// the truth about an environment that was not there. The digest now names the
/// mint that is tagged `nordisk-cargo-toolchain:live` and `:live-uefi` on both
/// boxes, and which composed the live gunnar appliance ISO; `gunnar` and
/// `holger` have pinned it since 2026-09-15. Read back on loki through the
/// engine door, not from podman hearsay:
/// `cargo run -p nornir-build-thing --features image-build --example
/// image_digest -- nordisk-cargo-toolchain:live` ->
/// `3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143`.
/// Same `rust_version` 1.97.1, and the target list below is a subset of what
/// that image carries (it adds `x86_64-unknown-uefi`), so the pin moves and
/// nothing else does.
///
/// **RE-PINNED 2026-10-16 to edda's recipe pin** (`nbt::CARGO_TOOLCHAIN_PIN`), the
/// recipe that adds `libgraphblas-dev`: nornir-solo and nornir-server link
/// `-lgraphblas` (the code room), and the `3d5115ba…` image had no GraphBLAS — both
/// cells failed to link on loki. The pin is no longer typed here: it is the digest
/// edda's recipe mints, moved with the recipe in one reviewed commit.
fn toolchain() -> Option<CargoToolchain> {
Some(CargoToolchain {
image: s("nordisk-cargo-toolchain"),
image_sha256: s(nbt::CARGO_TOOLCHAIN_PIN.digest),
rust_version: s("1.97.1"),
// What the pinned image CARRIES std for, not what nornir delivers.
// wasm32 is not optional: it is the target this crate's own plugin
// module is built for, and omitting it would refuse the code road by
// name before the container ever ran.
targets: vec![
s("x86_64-unknown-linux-gnu"),
s("x86_64-unknown-linux-musl"),
s("wasm32-unknown-unknown"),
],
timeout_secs: 3600,
mem_limit_mb: 8192,
cpus: 8,
network: false,
remap_paths: true,
// The checkouts nornir's own `path = "../…"` dependencies reach into.
// A container holding only nornir cannot resolve any of them, and the
// error it fails with names a manifest path, not this list. If a sibling
// path dep is added or removed, this list moves in the SAME commit.
// 2026-10-16: `Skidbladnir` (the root manifest's `skidbladnir` path dep) and
// `facett` (the optional viz facets + nornir-demo) were missing, and ⚒ Build on
// loki refused every cell in 0.3 s for it; `openzl-sys-rs` is reached one hop
// further (znippy-common's default `openzl` feature) — cargo loads it, so the
// container must carry it. `tests/declaration_guard.rs` walks the closure.
siblings: vec![
s("Skidbladnir"),
s("draupnir"),
s("edda"),
s("facett"),
s("holger"),
s("modgunn"),
s("openzl-sys-rs"),
s("skade"),
s("znippy"),
s("znippy-zoomies"),
],
})
}
impl BuildThing for NornirBuildThing {
fn manifest(&self) -> Manifest {
Manifest {
abi: ABI_VERSION,
repo: s("nornir"),
source_crate: s(KRATE),
summary: "nornir — the suite WAREHOUSE: the gRPC daemon that owns the redb \
workspace registry and the iceberg warehouse lock, and the four \
client shapes around it. ONE delivery, FIVE atoms, and the \
fat/mixed axis is one feature (`embed-holger`) rather than a \
naming convention."
.to_string(),
}
}
fn variants(&self) -> BuildVariantSet {
BuildVariantSet {
// ONE delivery, NAMED rather than left implicit. `check_suites` is
// VACUOUS below two suites (there is nothing to be a subset of) —
// that is correct, and `declares_suites` is what tells "checked and
// clean" apart from "nothing to check".
base_suite: Some(s(SUITE)),
offers: offers().into_iter().map(s).collect(),
allow_unbuilt: allow_unbuilt(),
allow_unbuilt_bins: allow_unbuilt_bins(),
downstream: downstream(),
variants: variants(),
toolchain: toolchain(),
..BuildVariantSet::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// **The set is the matrix, and nothing else.** Rickard 2026-08-03: *"ta
/// bort allt inte stämmer med matrisen"*. The two rows that left are named
/// so that re-adding one is a deliberate act and not a drift.
#[test]
fn the_five_atoms_are_the_matrix_rows_and_the_two_removed_stay_removed() {
let set = NornirBuildThing.variants();
assert_eq!(
set.names(),
vec!["nornir-server", "nornir", "nornir-cli", "nornir-ui", "nornir-solo"],
"the matrix has FIVE rows"
);
for gone in ["nornir-watch", "nornir-mcp", "nornir-thin"] {
assert!(!set.names().contains(&gone), "'{gone}' left the set deliberately");
}
}
/// **Carrying the suite tag is what makes an atom a build thing.** An
/// untagged row is not a third flavour, it is a row `--suite ORIGINAL`
/// cannot select.
#[test]
fn every_atom_is_tagged_original_and_the_base_suite_names_it() {
let set = NornirBuildThing.variants();
assert!(set.declares_suites(), "an untagged repo is outside the system");
assert_eq!(set.base_suite.as_deref(), Some(SUITE));
for v in &set.variants {
assert_eq!(v.suites, vec![s(SUITE)], "'{}'", v.name);
}
}
/// **`embeds` only on the service row** — the shape rule, asserted rather
/// than remembered, because the fix for a `validate_shape` refusal is to
/// drop the key and not to promote a CLI to `kind = "service"`.
#[test]
fn embeds_is_declared_on_the_daemon_alone_and_the_shape_gate_agrees() {
let set = NornirBuildThing.variants();
for v in &set.variants {
if v.name == "nornir-server" {
assert_eq!(v.embeds, set_of(&["holger", "skade"]), "the daemon composes both");
} else {
assert!(v.embeds.is_empty(), "'{}' is not service-shaped", v.name);
}
}
// The gate is per ROW and returns a named refusal, not a list.
for v in &set.variants {
set.validate_shape(v).unwrap_or_else(|e| panic!("{}: {e:#}", v.name));
}
}
/// **The fat/mixed axis is ONE feature, not a naming convention.** If
/// `embed-holger` ever appears on a `mixed` row, or leaves a `fat` one, the
/// declaration has started lying about what is compiled in.
#[test]
fn embed_holger_is_exactly_the_fat_rows() {
for v in &NornirBuildThing.variants().variants {
let fat = v.shape == ServiceShape::FatService;
let embeds_holger = v.features.iter().any(|f| f == "embed-holger");
assert_eq!(fat, embeds_holger, "'{}' — linkage and features disagree", v.name);
}
}
/// **The pin is a pin.** A named tag is a convenience; an environment that
/// can drift under you is a weather report.
#[test]
fn the_toolchain_is_pinned_by_digest_and_carries_the_targets_the_rows_name() {
let t = toolchain().expect("nornir declares a toolchain");
assert_eq!(t.image_sha256.len(), 64, "a named tag is a convenience, not a pin");
assert!(!t.network, "an attested build is hermetic");
assert!(
t.targets.iter().any(|x| x == "wasm32-unknown-unknown"),
"this crate's own plugin module is built for wasm32"
);
}
fn set_of(xs: &[&str]) -> BTreeSet<String> {
xs.iter().map(|x| s(x)).collect()
}
}