Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

.nornir/suite-atoms-design.md

nornir — the SUITE × ATOM build model

Design doc (-design class, see .nornir/docs.toml). Landed 2026-08-01 on feat/four-atoms; revised 2026-08-03 to the settled build matrix. Authority: workspace_nordisk/.nornir/build-matrix-2026-08-02.md, which supersedes SUITE-MODEL.md where they disagree. The declaration this describes is .nornir/build-variants.toml; that file is the source of truth and this one is the prose.

⚠ WHAT CHANGED 2026-08-03 — the set is the matrix, and nothing else

Rickard: "ta bort allt inte stämmer med matrisen". nornir's matrix row count is five, and carrying a suite tag is what makes an atom a Build Thing — so an atom that is not in the matrix does not get to sit in the declaration tagged.

  • nornir-thin → nornir-cli (bin and atom), and its marker feature thin → atom-cli. An atom named -thin that measurably is not thin was a contradiction on its face; the matrix names this row nornir-cli, matching holger's cli and dwarves' dwarves-cli.
  • nornir-watch left the set. One feature flag on the CLI is not a delivery. The [[bin]], the inotify feature and the CI line all stay.
  • nornir-mcp left the set, and its capability moved INTO the server: Rickard, "mcp skall väl ingå inkompilerat i server!" src/bin/nornir-mcp.rs is now the library module nornir::mcp_stdio, served by nornir-server mcp. <x>-server is the fat atom — "everything compiled in" — so MCP belongs in it rather than beside it.
  • nornir-ui stays mixed, and now it is MEASURED — see §1.1. The matrix asks for thin; the bytes say 76,829 storage-engine symbols. The declaration reports the fact and §5 records the gap.

nornir ships ONE delivery (suites = ["ORIGINAL"], base_suite = "ORIGINAL") and five atoms. There is exactly one build verb:

cargo xtask build --suite ORIGINAL [--atom <name>] [--list]

1. The atoms as landed

atom role linkage real bin source
⭐ nornir-server server fat nornir-server src/bin/nornir-server.rs
⭐ nornir client fat nornir src/bin/nornir.rs
nornir-cli client mixed nornir-cli src/bin/nornir.rs
⭐ nornir-ui client mixed nornir-ui src/bin/urdr-threads.rs
⭐ nornir-solo solo fat nornir-solo src/bin/urdr-threads.rs

Every row carries suites = ["ORIGINAL"]; there is no other suite name in this repo. tests/build_variants.rs::the_declared_set_is_exactly_the_matrix fails on a missing row AND on an extra one, and ::only_the_original_suite_exists fails on any other tag.

Two rows left on 2026-08-03 and are not coming back without a decision: nornir-watch (client/fat, bin kept, buildable by name) and nornir-mcp (tool/fat, folded into nornir-server mcp). See the tombstone at the bottom of .nornir/build-variants.toml for what each one built and what still works.

1.1 ★ linkage MEASURED, not asserted (2026-08-03, oden)

The matrix asks for <x>-ui = thin. nornir cannot honour it, and this is the evidence rather than the claim — the leanest atom built at exactly its declared flags, symbol table counted (holger's technique from server/ui/tests/thin_client.rs):

cargo build --release --bin nornir-ui --no-default-features --features viz
→ 268,655,240 bytes

  datafusion                 61,790      nornir::server               0
  iceberg                     8,841      nornir_server                0
  parquet                     6,198      tonic::transport::server     0
  skade                       1,061      LocalHolger                  0

Storage embedded / server reached is exactly ServiceShape::Mixed. mixed had been the honest word all along; until tests/atom_linkage.rs it simply had no evidence behind it. That guard re-measures on every CI run and fails in both directions — including the day the engine becomes optional, when it will demand the declaration be upgraded to thin.

mixed is not undocumented. It is declared, parsed, rendered and dispatched on in edda/crates/nornir-build-thing: variant.rs:116 (ServiceShape::Mixed), :138 (parsed), :160 (rendered), :316 (documented as one of fat|thin|mixed|not-applicable), plugin.rs:312, linkage.rs:318, service.rs:65.

⭐ = one of the shared four-atom target quartet (server · CLI · UI · solo).

Measured — all seven built through the one verb, 2026-08-01

⚠ Historical, and the names are the names of that day. This table was taken before the 2026-08-03 revision: nornir-thin is now nornir-cli, and nornir-watch / nornir-mcp are no longer atoms (see the header). The bytes and the --version text are left exactly as measured rather than retro-edited — a measurement that gets rewritten to match a later decision stops being one.

cargo xtask build --suite ORIGINAL --atom <a> for each, every one exit=0, then each binary run. This is the applied proof that the declared feature sets reach distinct artifacts; the unified test run in §2.3 cannot show it, because cargo unifies features there.

atom bytes --version (after v0.5.4 (<sha>) — )
nornir-server 356,626,360 atom nornir-server · linkage fat · holger=embedded · watch=poll · server=in-process
nornir 379,875,168 atom nornir · linkage fat · holger=embedded · watch=poll · server=in-process
nornir-thin 371,586,528 atom nornir-thin · linkage mixed · **holger=remote** · watch=poll · server=in-process
nornir-ui 259,824,480 atom nornir-ui · linkage mixed · holger=remote · watch=poll · **server=remote**
nornir-solo 324,990,864 atom nornir-solo · linkage fat · holger=embedded · watch=poll · **server=in-process**
nornir-watch 380,182,896 atom nornir-watch · linkage fat · holger=embedded · **watch=inotify** · server=in-process
nornir-mcp 342,833,816 atom nornir-mcp · linkage fat · holger=embedded · watch=poll · **server=remote**

Each discriminator lands in exactly the atom that declares it: embed-holger dropped only in nornir-cli/nornir-ui, inotify only in nornir-watch, server absent from nornir-ui and nornir-mcp. nornir-ui is the smallest at 259.8 MB against nornir-watch's 380.2 MB — a 120 MB spread that no descriptor could fake.

Independently, on the two GUI-source atoms:

$ strings -a nornir-solo | grep -c 'NORNIR-SOLO: gRPC listening on'   → PRESENT
$ strings -a nornir-ui   | grep -c 'NORNIR-SOLO: gRPC listening on'   → ABSENT

⚠ The (<sha>) in VERSION_LINE can lag the working tree: build.rs does not re-run on every commit, so an incremental rebuild can stamp an older sha onto freshly compiled code. Read the sha as "at least this commit", and treat the cargo xtask build exit code plus the behaviour above as the real evidence.

Exactly one default = true per (suite, role) group: nornir-server for server, nornir for client, nornir-solo for solo, nornir-mcp for tool.

What each one is for


2. The defect this pass fixed

2.1 The bin collision — measured RED

Three variants (nornir, nornir-thin, nornir-watch) all declared bin = "nornir", same package, same profile, same target. Three different feature builds therefore wrote one path and the last silently overwrote the others. BuildVariantSet::check_bins() against the declaration as it stood, 2026-08-01:

3 variants emit the SAME artifact — bin 'nornir' (package nornir, profile release,
target <host>) — so the last build silently OVERWRITES the others and nothing on
disk says which one you have: nornir, nornir-thin, nornir-watch. Give each its own
`bin` name off the same source (holger: holger-mikro / holger-full), or fold them
into one variant.

Fixed holger's way: distinct [[bin]] names off one source, told apart by required-features (Cargo.toml, the [[bin]] block). check_bins() is now empty, guarded by tests/build_variants.rs::no_two_atoms_write_the_same_file.

2.2 The role lie — and the two atoms that did not exist

All three of those variants also declared role = "server". Bin nornir is the CLI. The real daemon (nornir-server) and the real GUI (urdr-threads, now nornir-ui) were built by no variant at all. So the declaration described three copies of one client, called them servers, and omitted the two artifacts a deploy actually needs. Every atom now names the bin it really produces and the role that bin really plays.

2.3 Distinct bins are only half a fix

Two identically-compiled files under different names would pass check_bins() perfectly. So nornir::atom_line(bin) (src/lib.rs) names the atom, and every launchable prints it in --version:

$ nornir --version
nornir v0.4.x (<sha>) — atom nornir · linkage fat · holger=embedded · skade=embedded · watch=poll · server=in-process

$ nornir-cli --version
nornir v0.4.x (<sha>) — atom nornir-cli · linkage mixed · holger=remote · skade=embedded · watch=poll · server=in-process
⚠ Why the atom name is a parameter, not a cfg! (fixed 2026-08-01)

atom_line originally derived the name from the discriminating features — cfg!(feature = "solo"), then cfg!(feature = "thin"), then cfg!(feature = "inotify"). That could not work, and it shipped broken.

Cargo unifies features across every bin of one package in one invocation. A two-bin probe makes it unambiguous: with [[bin]] two carrying required-features = ["alpha"] and [[bin]] one carrying none, cargo build --features alpha gives both alpha = true. So cargo test --features thin builds nornir and nornir-thin off the one source with thin on for both, and the feature-derived name reported nornir-thin from each. Measured RED under exactly that command:

left:  "nornir v0.5.4 (339d6b2) — atom nornir-thin · linkage fat · holger=embedded …"
right: "nornir v0.5.4 (339d6b2) — atom nornir-thin · linkage fat · holger=embedded …"

The same defect reached a shipped artifact by the other route: nornir-ui's declared feature set names none of the three discriminators, so the fall-through else arm made the GUI atom call itself atom nornir — the CLI's name. That was measured on the real cargo xtask build --atom nornir-ui output, not in a test.

atom_line now takes env!("CARGO_BIN_NAME"), which is per-bin and cannot be unified, so a binary can only ever report the atom it is. The rest of the line (linkage / holger / watch / server) stays cfg!-derived and correctly describes the compilation the process came out of. The per-atom feature split is a property of the declared feature sets and is proven by building the atoms — the --version table in §1 is that measurement — not by a unified test run.

The same reasoning fixed the solo gate: see §3.

tests/build_variants.rs::the_atom_marker_reaches_the_binary runs all three built binaries (nornir, nornir-thin, nornir-ui) under that hostile unified build and requires each to name itself. That is the applied proof that the atom selector selects rather than decorating three copies of one file. It is #[cfg(feature = "thin")], so ci_compiles_the_atom_marker_feature (always compiled) requires .woodpecker.yml to build the feature — otherwise the file would read green having asserted nothing.


3. solo — what it means and why it is its own role

nornir-solo is the same GUI source as nornir-ui, built with solo = ["server", "viz"]. That feature lights a block at the top of src/bin/urdr-threads.rs which, before the window opens:

  1. resolves the bind address and bearer through server_grpc::{bind_addr, resolve_bearer} — the one reader of each, shared with the standalone daemon;
  2. spawns nornir::server_grpc::run_with_token on a dedicated OS thread with its own tokio runtime (the GUI owns the main thread; eframe::run_native never returns);
  3. polls TcpStream::connect against the real bind address until it is accepted;
  4. prints NORNIR-SOLO: gRPC listening on 127.0.0.1:7878 and points the UI half at it via NORNIR_SERVER;
  5. bails loudly if the socket never accepts, rather than opening a UI that claims a server it does not have.

Start it and you have the whole product: no second process, no port to configure, nothing else to install.

Why a third role rather than a flavour of the other two

Both mistakes are silent, which is the whole argument: the distinction changes what a consumer of the menu is allowed to do with the artifact.

Three things solo deliberately does NOT do

The divergence on a fresh box

nornir-server MUST refuse to boot without a bearer. A solo appliance whose whole promise is "nothing else to install" must not, so with no token anywhere it listens open on the resolved (loopback by default) address and says so on stderr. That is why resolve_bearer() returns Option instead of bailing.

The proof it is not vacuous

tests/solo_embeds_server.rs, headless via --robot-remote (no display needed, and the process stays alive to be probed):

⚠ The anchor could not have worked as first written (fixed 2026-08-01)

start_embedded_server() was called under a bare #[cfg(feature = "solo")]. But solo is a package feature, and both nornir-ui and nornir-solo are built from src/bin/urdr-threads.rs in the SAME --features solo,robot-remote invocation the anchor runs in — so the block was compiled into nornir-ui too, and the anchor was probing a UI binary that really did start a server. The anchor described itself as "one feature less"; in that run it was not one feature less.

korp hit the identical wall from the other side and it is visible in bytes: its korp-ui artifact grew from 56,362,176 B (built as its own atom) to 264,616,336 B after a --features solo guard run rebuilt it with the embed. korp works around it by running its negative leg as a SECOND cargo invocation (--no-default-features --features client).

nornir fixes it by construction instead, which is stronger — the negative is proven inside the same hostile, solo-featured build:

#[cfg(feature = "solo")]
if env!("CARGO_BIN_NAME") == "nornir-solo" {
    start_embedded_server()?;
}

The feature is still load-bearing: [[bin]] nornir-solo has required-features = ["solo"], so without it the appliance binary does not exist.

⚠ robot-remote did not compile at all

Both applied tests above are #[cfg(all(feature = "solo", feature = "robot-remote"))], so neither had ever run: src/viz/robot_remote.rs:79 called app.inject_test_results_for_test(Vec::new()), an injector that ce553b9 ("viz: cut the test/bench/coverage/leaderboard DISPLAY tabs") removed along with the 🧪 Test tab. A hard E0599, sitting on origin/main (measured at eab671a), invisible because robot-remote is in no default feature set and no CI command built it — the "named by a variant, compiled by nothing" debt this file's own header warns about, with a compile error inside it.

The call only ever wanted the splash side-effect, so it is now app.age_past_splash_for_test() — a new one-writer in src/viz/app.rs that also absorbs the byte-identical two-line wind-back fourteen inject_*_for_test helpers each carried (LAW 5). The --features solo,robot-remote CI step is what keeps it compiled from here on.


4. What is outside the five, and why (revised 2026-08-03)

src/bin/nornir-mcp.rs → src/mcp_stdio.rs (the library module nornir::mcp_stdio, #[cfg(feature = "mcp")]), dispatched from src/bin/nornir-server.rs:

nornir-server mcp     # MCP over stdio (rmcp transport::stdio) — was `nornir-mcp`
nornir-server         # the gRPC daemon on :7878 — unchanged

Why a subcommand and not an RPC. MCP is stdio: the agent SPAWNS the process and speaks JSON-RPC over its stdin/stdout. The gRPC daemon owns :7878 and has no client stdin to read, so folding MCP into server_grpc would have deleted the capability while looking like it kept it. The two modes are mutually exclusive by construction — the mcp arm returns before server_grpc::run() — so one binary never holds the port and owns stdio at once. The daemon's existing SubmitMcpCalls RPC is the other direction and is untouched: it ingests this module's per-call telemetry into the warehouse mcp_requests table.

What a person runs after this change. Same protocol, same tool names, same stdio framing; only the executable changed:

claude mcp add nornir --env NORNIR_SERVER=http://localhost:7878 \
  --env NORNIR_SERVER_TOKEN=<token> -- nornir-server mcp

(nornir-mcp.json in the repo root carries the same edit.) Nothing needs installing or restarting for the change to be correct — but an installed /usr/local/bin/nornir-mcp keeps working until it is replaced, because it is a binary that already exists on disk.

5. Named gaps — what could NOT be landed

The one change that would make thin reachable, and it is a project rather than a declaration edit: put skade / skade-katalog / iceberg / parquet / datafusion behind an embed-warehouse feature so a client can reach the warehouse over gRPC instead of opening it. That is the only route to the matrix's <x>-ui = thin for nornir. tests/atom_linkage.rs will go RED the day it lands, demanding the declaration be upgraded — the gap cannot be forgotten.

6. The one build verb, and what was purged for it

cargo xtask build [--suite <s>] [--atom <a>] [--list] — xtask/src/main.rs. Its whole body is nornir_build_thing::suite_cli::run(root, &args): no variant name, no feature string, no cargo argv of its own. demo-wasm, deploy-pages and bench are not build paths and all stay.

Purged / rerouted:

The bin rename and every caller updated

urdr-threads → nornir-ui, matching korp → korp-ui and dwarves-demo → dwarves-ui. The product is unchanged: the window title is still Urðr Threads, and the source file keeps its name (the same call korp made, keeping path = "src/main.rs"). Callers updated:

Not renamed, deliberately: the robot scene id "urdr-threads" (src/viz/shell_scene.rs, tests/robot_scene.rs, .nornir/scene-design.md) — that is the product name in a data feed, not a launchable.

7. Guards added

tests/build_variants.rs (new — nornir had no build-variants test; the only thing validating the file was one inotify question):

tests/solo_embeds_server.rs (new) — §3.

tests/inotify_watch_wiring.rs is unchanged and still green: the --features inotify CI line it asserts is still there.

tests/rayon_free_law.rs already existed at origin/main (eab671a, landed by another pass on 2026-07-31) — the brief that asked for it to be created was working from a stale tree.