crates/nornir-build-thing/tests/role_linkage_contract.rs
//! **THE CONTRACT GUARD: a `server` must be `fat`.**
//!
//! The constellation-wide contract is `workspace_nordisk/.nornir/thin-fat-client-pattern.md`
//! (owner, amended 2026-07-23):
//!
//! > **server → FAT** · **client / UI → THIN** — *"ett UI normalt sett är THIN"*
//!
//! The reasoning is not stylistic. A `server` **is** the capability and **owns the state**.
//! A build declaring `role = "server"` while `linkage = "thin"` says it is the authority and
//! simultaneously that it holds nothing and must reach someone else — those cannot both be
//! true, and the declaration is therefore false *in the data*, before a single byte is
//! compiled.
//!
//! # Why this needed a guard
//!
//! `dwarves/.nornir/build-variants.toml` carried exactly that pair for
//! `dwarves-server` — `role = "server"`, `linkage = "thin"`, `reaches = ["nornir"]` — while
//! the thing systemd actually runs is `dwarves-full` (`role = "server"`, `linkage = "fat"`,
//! `embeds = ["nornir", "holger", "skade"]`). So the variant named "server" was not the
//! server, and nothing said so. holger had already shipped the honest shape next door:
//! `agent` / `agent-online`, `client` · `thin` · `reaches = ["holger"]`.
//!
//! # What this does NOT flag, deliberately
//!
//! **`client` + `fat` is legal** — it is the third delivery mode in the same contract
//! (EMBED: another service hosts this one's pane in-process). holger's `ui-embedded` is
//! exactly that and must stay green. A guard that reddened on it would be wrong, and a
//! guard that reds on legitimate rows gets switched off — which is how the source-drift
//! check in this very repo became dead code.
//!
//! So the rule is deliberately narrow: **only `server` + `thin` is a contract violation.**
//! It is the one pairing that cannot be defended.
//!
//! # Relationship to the MEASURED audit
//!
//! [`crate::linkage`] already audits the compiled artifact and reports `ThinButLinks` when a
//! binary declared thin measurably links a server entry point — *"the declaration is a lie"*.
//! That catches a lie about the **binary**. This catches a lie about the **declaration**, and
//! it runs with no build at all. Both are wanted: the declaration is what humans read and
//! what `xtask` will build from.
use std::path::{Path, PathBuf};
/// A violation, carrying enough to fix it without re-deriving anything.
#[derive(Debug)]
struct Violation {
file: String,
variant: String,
reaches: String,
}
/// Locate every checked-in `.nornir/build-variants.toml` across the constellation.
///
/// Reuses [`nornir_testmatrix::citations::sibling_checkouts`] rather than re-rolling the
/// sibling walk (L5 — reuse, do not twin).
fn variant_files() -> Vec<PathBuf> {
let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// crates/nornir-build-thing → crates → <repo root>
let repo_root = here
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or(here);
let mut roots = vec![repo_root.clone()];
roots.extend(nornir_testmatrix::citations::sibling_checkouts(&repo_root));
roots
.into_iter()
// Skip git WORKTREES. `sibling_checkouts` accepts anything with a `.git` entry, but in
// a worktree `.git` is a FILE pointing at the real repo, not a directory. A worktree is
// a transient view of a branch — often a stale one — so letting it red this guard means
// an abandoned checkout can fail the canonical repos for a violation that no longer
// exists in any of them. Found the first time this ran: `dwarves-wt-bt` still carried
// the pre-fix `role = "server"` row long after dwarves itself was corrected.
.filter(|r| r.join(".git").is_dir())
.map(|r| r.join(".nornir").join("build-variants.toml"))
.filter(|p| p.is_file())
.collect()
}
/// Minimal line-oriented reader for the `[[build.variant]]` blocks.
///
/// Deliberately hand-rolled over `toml`: this guard must run even when the manifest fails to
/// deserialise into the typed model, because a schema break is exactly when the contract is
/// most likely to have been violated. A guard that cannot run on a broken input is not a
/// guard.
fn violations_in(path: &Path) -> Vec<Violation> {
let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
let file = path.to_string_lossy().to_string();
let mut out = Vec::new();
for block in text.split("[[build.variant]]").skip(1) {
// Stop at the next top-level table so a following section cannot bleed in.
let block = block.split("\n[").next().unwrap_or(block);
let field = |key: &str| -> Option<String> {
block.lines().find_map(|l| {
let l = l.trim();
if l.starts_with('#') {
return None;
}
let (k, v) = l.split_once('=')?;
(k.trim() == key).then(|| v.trim().trim_matches('"').to_string())
})
};
let (Some(role), Some(linkage)) = (field("role"), field("linkage")) else { continue };
if role == "server" && linkage == "thin" {
out.push(Violation {
file: file.clone(),
variant: field("name").unwrap_or_else(|| "<unnamed>".into()),
reaches: field("reaches").unwrap_or_else(|| "-".into()),
});
}
}
out
}
/// **The guard.** No variant may declare itself a `server` while linking `thin`.
///
/// # What this test may FAIL on, and why it changed (2026-09-22)
///
/// It used to fail on every `.nornir/build-variants.toml` the sibling walk could
/// reach. MEASURED on t14s: that is five violations in four other repositories —
/// `gunnar/gunnar-clock-musl`, the same row in an ad-hoc `gunnar-installer-reads-cd`
/// clone, `gunnar-ui`'s two console BFF rows and `holger-ui/mvp-ui` — so edda's
/// suite was red for declarations edda cannot change, and had been for long enough
/// that the red stopped being read.
///
/// A repository's test suite must be decidable from its own contents. So:
///
/// * **edda's own declaration is held to the contract and FAILS the test.**
/// * **A sibling's declaration is REPORTED, never failed** — printed with the file
/// and the row, so the sweep still finds drift and names an owner. Set
/// `NORNIR_ROLE_LINKAGE_STRICT=1` to fail on those too, which is what a
/// constellation-wide gate (a `workspace_nordisk` run, or a box that holds only
/// canonical checkouts) should do.
///
/// This is the same shape `vocabulary_compat` needed and for the same reason: a
/// guard that another lane's working tree can turn red gets switched off, and a
/// guard nobody reads guards nothing.
#[test]
fn a_server_variant_may_not_be_thin() {
let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let repo_root = here
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or(here);
let strict = std::env::var("NORNIR_ROLE_LINKAGE_STRICT").is_ok();
let files = variant_files();
let mut ours: Vec<Violation> = Vec::new();
let mut theirs: Vec<Violation> = Vec::new();
for p in &files {
let mine = p.starts_with(&repo_root);
for v in violations_in(p) {
if mine || strict {
ours.push(v);
} else {
theirs.push(v);
}
}
}
if !theirs.is_empty() {
eprintln!(
"role/linkage contract — {} violation(s) in SIBLING checkouts, reported for their \
owners (set NORNIR_ROLE_LINKAGE_STRICT=1 to fail on them here):\n{}",
theirs.len(),
theirs
.iter()
.map(|v| format!(" {} :: {} (reaches {})", v.file, v.variant, v.reaches))
.collect::<Vec<_>>()
.join("\n")
);
}
let found = ours;
assert!(
found.is_empty(),
"contract violation — `role = \"server\"` with `linkage = \"thin\"`.\n\
A server IS the capability and OWNS its state; a thin build owns nothing and must \
reach someone else. Both cannot be true.\n\
See workspace_nordisk/.nornir/thin-fat-client-pattern.md (server → FAT).\n\
If the variant really is a CLI/agent that talks to a server, say `role = \"tool\"` \
(or \"client\") — holger's `agent` / `agent-online` are the reference shape.\n\n{}",
found
.iter()
.map(|v| format!(" {} :: {} (reaches {})", v.file, v.variant, v.reaches))
.collect::<Vec<_>>()
.join("\n")
);
}
/// **A guard that matches nothing passes forever.** Prove the scanner actually reaches the
/// checked-in manifests and parses real rows, so the green above means something.
#[test]
fn the_scanner_reaches_the_real_manifests() {
let files = variant_files();
assert!(
files.len() >= 3,
"found only {} build-variants.toml — the sweep has narrowed and the contract guard \
is policing almost nothing. Expected the constellation's manifests as siblings.",
files.len()
);
let variants: usize = files
.iter()
.filter_map(|p| std::fs::read_to_string(p).ok())
.map(|t| t.matches("[[build.variant]]").count())
.sum();
assert!(
variants >= 20,
"parsed only {variants} variants across {} files — the block splitter is broken",
files.len()
);
}
/// The narrowness is deliberate and load-bearing: `client` + `fat` is the EMBED mode and
/// must stay legal. Pin it, so nobody "tightens" the rule into reddening holger's
/// `ui-embedded` and gets the whole guard disabled.
#[test]
fn client_plus_fat_is_legal_because_that_is_the_embed_mode() {
let tmp = std::env::temp_dir().join("nbt-contract-embed-mode.toml");
std::fs::write(
&tmp,
"[[build.variant]]\nname = \"ui-embedded\"\nrole = \"client\"\nlinkage = \"fat\"\n",
)
.expect("write fixture");
assert!(
violations_in(&tmp).is_empty(),
"client+fat must NOT be flagged — it is the contract's third delivery mode (EMBED)"
);
let _ = std::fs::remove_file(&tmp);
}