The build thing pattern
nornir:class=design
A build thing is what replaces the build script — the slot maven or gradle occupies. It is functionality, not data:
variants,validate,plan,graph,judge_artifact,describe,provenance. None of that serializes, which is why it is a Rust trait compiled into the app rather than a TOML somebody has to remember to write.
This document is for two readers:
- a developer adding a build thing to another app — jump to Adding a build thing to your app;
- a reader of facett who needs to know how the UI side plugs in — jump to The facett side.
holger is the worked example throughout, because it is the only app whose build thing exists today.
Everything below was checked against the code on 2026-07-22. Where the design and the shipped code differ, the difference is stated in What has shipped and what has not rather than smoothed over.
1. The shape
| where | crate | what it is |
| the model + the trait + the ABI + the host | edda/crates/nornir-build-thing |
plugin.rs (guest + types), plugin_host.rs (wasmtime loader), variant.rs (the declaration), spec.rs (the file-cache path) |
| holger's build thing | edda/crates/nornir-buildspec-holger |
743 lines: the declaration, the airgap rule, the plan, and export_build_thing! |
| the UI | facett/facett-buildthing |
BuildThingDeck (three panes), BuildGraphPane (graph → facett-graphpan) |
| a host that mounts it | dwarves/crates/dwarves/src/build_thing.rs + dwarves/dwarves-demo/src/build_thing.rs |
the forge engine and the ⚒ tab |
1.1 An app describes itself in a crate it depends on
The declarations used to be <repo>/.nornir/build-variants.toml — hand-written, one per repo, and nothing failed when one was missing. That is why a build-space coverage table could read SPACE UNDECLARED and NO BUILD SPACE.
The fix is a placement rule: put the build declaration in a crate the application depends on. An app that compiles has then described itself by construction, and a missing declaration is a compile error rather than a silent gap. The rationale is written up in edda/crates/nornir-build-thing/src/spec.rs (module docs) and edda/crates/nornir-build-thing/src/plugin.rs (module docs).
1.2 One implementation, two link targets
nornir-buildspec-holger/Cargo.toml:
[lib]
crate-type = ["cdylib", "rlib"]
- the
rlibis what the app links, natively; - the
cdylibbuilt forwasm32-unknown-unknownis the runtime plugin.
There is no cfg fork in the implementation. export_build_thing!(…) adds the wasm exports and nothing else, so the plugin cannot say something the app did not. If a native impl and a wasm impl could drift, the whole thing rots exactly the way the TOML did.
The one thing that is cfg-forked is the host-fact accessors in plugin::host — on wasm they are extern declarations the host binds; natively they read a process-wide NativeFacts the embedding app sets once (host::set_native_facts). The build thing itself calls the same three functions either way.
1.3 The forge depends on no app
The obvious next step — have dwarves dep: every buildspec leaf so it can enumerate them — is a trap: the forge could then only ever build what it knew at its own compile time, and a new app or a new variant would require rebuilding the forge. So the spec travels as code at the source and data at the boundary: dwarves loads a .wasm and calls it.
dwarves/crates/dwarves/Cargo.toml therefore pulls nornir-build-thing = { features = ["plugin-host", "artifact-section"] } and no buildspec crate at all.
The proof is edda/crates/nornir-buildspec-holger/tests/loaded_as_a_plugin.rs::a_forge_that_never_heard_of_holger_lists_validates_and_plans_it: it copies the module into a tempdir with no Cargo.toml, no .nornir, no repo beside it, loads it through the generic plugin_host loader, and gets the menu, the rev pin, the validation verdicts, the plan and the artifact judgement out of bytes alone. It never names the holger crate.
2. The import list IS the permission list
plugin::host (in edda/crates/nornir-build-thing/src/plugin.rs) declares exactly three imports:
pub const NAMESPACE: &str = "nordisk";
pub const IMPORTS: &[&str] = &[IMPORT_GIT_REV, IMPORT_READ_MANIFEST, IMPORT_LOG];
| import | why it is safe |
nordisk::git_rev |
the host reads the revision; the plugin cannot run git |
nordisk::read_manifest |
a map lookup, not a file open — a path the host did not offer simply misses, so there is no traversal to defend against |
nordisk::log |
a line of text out, nothing in |
PluginHandle::load_with enumerates module.imports() and refuses anything outside that list by name, before instantiating:
build-thing wasm requests host capabilities that are NOT granted: wasi_snapshot_preview1::fd_write.
The permission list is exactly `nordisk::{git_rev, read_manifest, log}` — a build thing asks the
forge questions; it does not open files, sockets or clocks.
There is no filesystem, no socket, no clock, no environment and no process spawn — and no way to name one.
Two tests hold this from both sides. plugin_host::tests::a_module_asking_for_a_capability_is_refused_by_name proves the host refuses; loaded_as_a_plugin.rs::the_plugin_imports_only_what_the_host_grants parses the shipped module's own import section with wasmparser and asserts holger's real plugin asks for nothing more (and that git_rev genuinely is there, so the rev pin is not something the plugin invented).
That second test is not decoration. It caught chrono's default wasmbind feature pulling __wbindgen_placeholder__::__wbindgen_describe into the import section — which is why nornir-build-thing/Cargo.toml carries:
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
with a comment saying exactly that. uuid is similarly confined to [target.'cfg(not(target_arch = "wasm32"))'.dependencies], because uuid/v4 needs an OS randomness source and there is none on wasm32-unknown-unknown — a hard compile error, not a runtime one.
Beyond imports, plugin_host::Limits caps linear memory (256 pages), fuel (200 M per call — so an infinite loop terminates instead of hanging the forge) and reply size (8 MiB). plugin_host::tests::an_infinite_loop_exhausts_fuel_instead_of_hanging_the_host proves the fuel cap.
2.1 Decision: a core wasm32-unknown-unknown module, not a wasip2 component
Considered and rejected. The component model (wasm32-wasip2 + WIT) is the more fashionable answer. It was not chosen, for one decisive reason: the security requirement is the sandbox is granted nothing.
- A wasip2 component links WASI. You must then actively deny each capability — filesystem, sockets, clock, environment — and a missed denial is silent.
- A core module has nothing to deny. It cannot name a syscall. The guarantee is structural instead of configured.
The hand-rolled ABI cannot drift, because both halves live in one file: the guest side is the export_build_thing! macro and the host side is PluginHandle, and both are in plugin.rs / plugin_host.rs. Everything crossing the boundary is one JSON string.
The condition that would reverse it: WIT buying something the plugins actually need — rich types across the boundary, rather than a single JSON string. The upgrade is then a swap of the plugin::abi module and PluginHandle, with no change to any BuildThing implementation.
(A related, earlier decision is recorded in spec.rs: a JSON cache at .nornir/build-spec.json with a drift guard, deferred in favour of wasm once the requirement turned out to be validate and judge_artifact — verbs — rather than a variant list. spec.rs's "Why not WASM (yet)" section predates plugin.rs and reads as stale; the BuildSpecSource trait it defines is the file-cache path, still used by JsonSpecSource.)
3. Plan as data
BuildThing::plan(&self, variant) -> Option<BuildPlan>:
pub struct PlanStep {
pub name: String,
pub program: String, // "cargo". Never a shell.
pub args: Vec<String>, // argv, already materialized
pub env: Vec<(String, String)>,
}
pub struct BuildPlan {
pub variant: String,
pub steps: Vec<PlanStep>,
pub env: Vec<(String, String)>,
pub expected_artifacts: Vec<String>, // checked to EXIST afterwards
pub rationale: Option<String>,
}
program + args as an array, never a shell string. A string has to be re-parsed somewhere, re-parsing means quoting, and quoting means command injection — in the process that holds the SSH deploy keys and the publish rights. An argv vector has no parse step.
The build thing performs nothing. It returns a plan; the forge decides whether, where and how to execute it. That is what makes a plan inspectable, diffable, dry-runnable, cacheable and gateable — and it is why the sandbox needs no capabilities at all. A build thing that could run a command would need a process capability; one that returns a list of commands needs nothing.
expected_artifacts is how "cargo exited 0 and produced nothing" stops counting as success: dwarves re-stats every artifact off disk after the run, and BuildReport::is_green() requires a file to exist, not an exit code (dwarves/crates/dwarves/src/build_thing.rs).
When a build genuinely must react to output, the forge runs step n, hands the result back, and asks for step n+1 — a step loop, still with no raw capability.
4. Git-pinned identity
A build thing's identity is (repo, rev). A build thing is only true for the revision it was built from: features get renamed, variants come and go, an airgap rule changes which crates it forbids. A build thing without a rev is the build-variants.toml rot again wearing a compiled wrapper.
pub struct SpecProvenance { pub repo: String, pub rev: String, pub dirty: bool, pub source_crate: String }
pub enum RevMatch { Matches{..}, MatchesButDirty{..}, Mismatch{..}, Unknown{..} }
RevMatch::refuse_rebuild() is true for Mismatch and Unknown — an unknown revision is never treated as a match. The message:
REFUSING to rebuild: the build thing describes c0ffeec0ffee and this tree is at dddddddddddd —
features get renamed and variants come and go, so its recipe is not this tree's
The default provenance() calls host::git_rev(), i.e. the plugin learns the rev by asking the host over an import — it cannot run git. Asserted in loaded_as_a_plugin.rs: the test loads with HostFacts::at_rev(rev, false) and asserts prov.rev == rev.
TomlBuildThing (dwarves' wrapper for repos that still declare a TOML) returns an empty rev deliberately, so an unpinned declaration says so rather than silently reporting a revision it does not have.
5. Reading a build thing out of an artifact — without running it
An artifact is meant to carry its own build thing's wasm module in a linker section:
pub const SECTION: &str = ".nordisk.buildthing";
plugin_host::read_section(path) (feature artifact-section, object crate) parses the object file and returns the module.
dwarves runs the artifact's build thing, never the artifact. Running it would be wrong twice:
- It frequently cannot work. An aarch64 / musl / other-OS artifact will not run on an x86 forge — and cross-built variants are exactly the ones you most need to identify remotely. An interrogation method that fails on cross-compilation fails at its job.
- It is remote code execution from a registry, on the host holding the deploy keys. A hostile artifact gets a capability-free wasm sandbox instead.
loaded_as_a_plugin.rs::a_build_thing_is_read_out_of_a_binary_without_executing_it hand-assembles a genuine little-endian aarch64 ELF64 (EM_AARCH64 = 183) — a file this x86 box cannot execute — carrying the real holger module in .nordisk.buildthing, reads it back byte-for-byte, loads it, and gets holger's three-server menu out of it. It also asserts that a binary with no section returns Ok(None), not Err: no build thing is a rendered state, never an absent row.
Status: the reading half ships and is tested. Nothing in the constellation currently writes the section into a real binary — see §9.
6. The axes
Three axes, all declared rather than inferred.
role — what a variant IS to the product
Server · Client · Library · Tool. A build space is not a flat vector: holger ships three servers and two clients from one tree, and "which of these five do I want" is unanswerable until you know which question you are asking. The pane renders one section per role.
The trait's default roles() reads role = "server" | … off the declaration and falls back to a single Server group only when no variant declares one — honest for a single-role product, wrong for anything that also ships a client, which is why declaring it is worth doing.
linkage — how a variant talks to its siblings
| value | meaning |
fat |
siblings compiled IN; self-contained at runtime |
thin |
built without them; reached over gRPC — needs them running |
mixed |
some embedded, some reached |
not-applicable |
the axis genuinely does not apply (a library, a build with no sibling edge) |
not-applicable exists because an undeclared linkage deserializes as fat, i.e. a positive claim of self-containment nobody made. The deck draws it in its own colour, asserted by facett-buildthing/src/deck.rs::an_inapplicable_linkage_is_not_drawn_as_fat.
Linkage is a projection of the declaration's existing ServiceShape (TOML key linkage, alias shape) — not a second field to keep in sync. embeds says which siblings; linkage says how. Each entry also carries a runtime sentence produced by Linkage::runtime_line(embeds, reaches), e.g. "NEEDS SKADE RUNNING — reached over gRPC, not compiled in".
Fat is the default and the normal case; thin is the supported exception — see workspace_nordisk/.nornir/thin-fat-client-pattern.md.
default — the normal case, exactly one per role
BuildMenu::check_defaults() returns the reasons it is not so, and an empty result means well-formed:
- a role with variants and no default →
"role 'server' has 3 variant(s) and NO default — the caller has to guess which is the normal case" - a role with two defaults → named, both of them.
A menu of five with nothing marking which is normal makes every caller guess. The deck surfaces these problems in the pane (⚠ …) and in state_json (default_problems) rather than swallowing them.
(There is a parallel BuildVariantSet::check_defaults() in variant.rs for the declaration side.)
ecosystem — neutral by shape, cargo-only in scope
VariantRef carries ecosystem: Ecosystem (today: Cargo only) and an opaque selector: serde_json::Value the forge never reads — it passes it back when asking for a plan. Together with PlanStep being a generic program + args rather than a cargo invocation, that is the whole multi-ecosystem concession: there is no factory, no per-ecosystem registry and no second implementation, because there is no second producer. A JAR's maven build thing would need no BuildPlan change.
BuildMenu::cargo_set is Option<BuildVariantSet> for the same reason: a non-cargo build thing leaves it None, and the cargo-specific views — the feature axis above all — must then report NOT APPLICABLE rather than "0 of 0", which would be the vacuous-green bug by category error.
7. holger, the worked example
7.1 The declaration
edda/crates/nornir-buildspec-holger/src/lib.rs, condensed:
pub const REPO: &str = "holger";
pub const SOURCE_CRATE: &str = "nornir-buildspec-holger";
pub const REMOTE: &str = "ssh://git@codeberg.org/nordisk/holger.git";
pub const SERVER_VARIANTS: [&str; 3] = ["embedded", "mini", "full"];
pub const CLIENT_VARIANTS: [&str; 2] = ["ui", "agent"];
/// Every Rust outbound-HTTP client an airgap tier is forbidden to link.
/// `hyper`/`tonic`/`h2` are deliberately ABSENT: they are the INBOUND serve
/// stack, which every tier legitimately links. A denylist that fails a server
/// for being a server would be turned off within a week.
pub const FORBIDDEN_OUTBOUND_CLIENTS: &[&str] =
&["reqwest", "ureq", "isahc", "curl", "surf", "attohttpc", "awc", "http-client"];
pub const AIRGAP_TIERS: &[&str] = &["embedded", "mini"];
pub const FEDERATING_TIER: &str = "full";
pub fn build_variants() -> BuildVariantSet {
let mut set = BuildVariantSet::default();
set.offers = ["embedded", "mini", "full", "sign", "testmatrix", "served", "gui"]…;
set.exclusive = vec![vec!["embedded", "mini", "full"]…]; // compile_error! in the CLI
set.required.insert("holger-embedded", vec!["embedded"]); // mirrors [[bin]] required-features
set.required.insert("holger-mikro", vec!["mini"]);
set.required.insert("holger-full", vec!["full"]);
set.required.insert("holger-ui", vec!["gui"]);
set.downstream.insert("holger", ["skade"]); // the embed allowlist
set.variants = vec![ /* embedded, mini, full, ui, agent */ ];
set
}
#[derive(Debug, Default, Clone, Copy)]
pub struct HolgerBuildThing;
impl BuildThing for HolgerBuildThing { /* manifest, variants, roles, plan, validate,
judge_artifact, rebuild_recipe */ }
nornir_build_thing::export_build_thing!(HolgerBuildThing);
The menu it produces:
| role | variant | package | bin | features | linkage |
| server | embedded |
holger-server-cli |
holger-embedded |
embedded |
thin (composes nothing) |
| server | mini |
holger-server-cli |
holger-mikro |
mini |
thin → skade |
| server | full (normal) |
holger-server-cli |
holger-full |
full |
fat, embeds skade |
| client | ui (normal) |
holger-ui |
holger-ui |
gui |
thin → holger |
| client | agent |
holger-agent-cli |
holger-agent |
(default features) | thin → holger |
ui and agent are real crates that were declared nowhere before this build thing existed. holger-ui's bin carries required-features = ["gui"] (holger/server/ui/Cargo.toml:152), gui sat in offers with no variant enabling it, and cargo build --all-targets silently skips a required-features target — so nothing ever compiled it. Same rot as korp's spark41 and nornir's scip, in the tier a human actually looks at.
7.2 A real plan
HolgerBuildThing.plan("embedded") returns, as data:
{
"variant": "embedded",
"steps": [
{ "name": "build-embedded",
"program": "cargo",
"args": ["build", "-p", "holger-server-cli", "--bin", "holger-embedded",
"--features", "embedded", "--no-default-features",
"--profile", "release"] },
{ "name": "egress-closure-embedded",
"program": "cargo",
"args": ["tree", "-e", "no-dev", "-p", "holger-server-cli",
"--features", "embedded", "--no-default-features"] }
],
"expected_artifacts": ["target/release/holger-embedded"],
"rationale": "'embedded' is an airgap tier: the build is followed by a dependency-closure
resolve so the zero-egress claim can be CHECKED rather than asserted"
}
Two things worth reading twice:
- the plan plans its own evidence. An airgap tier appends the closure resolve, so the forge gathers the facts
judge_artifactwill be asked about — rather than the plugin trying (and failing, having no filesystem) to gather them itself.fullgets one step and the rationale"'full' materializes to one cargo invocation". - nothing in a plan is a shell. Asserted:
assert!(p.steps.iter().all(|s| s.program == "cargo")).
plan() also calls host::log(…), and the acceptance test asserts the line arrived on the host side over nordisk::log — the one import that lets a plugin speak at all.
7.3 The rule that is not data
The reason this is code and not a schema:
the airgap tiers (
embedded,mini) must link no outbound HTTP client.
No field in any JSON expresses that. It is a predicate over a built artifact's dependency closure, and only holger knows it applies to two of its five variants and not the third. It lives in HolgerBuildThing::judge_artifact, and it runs inside the forge's wasm sandbox.
The host resolves the closure and passes crate names in via ArtifactFacts; the plugin decides what they mean. It also refuses in two directions that a naive check would get wrong:
- an artifact with no closure supplied is refused —
"an UNMEASURED zero-egress claim is not a zero-egress claim"; - a
fullthat links no HTTP client is refused —"the upstream-registry federation is missing from this artifact". Afullwith the federation compiled out is not the artifact it claims to be.
validate() carries the pre-build half of the same rule: an airgap tier may not request served or gui. The generic validator accepts that combination — both features are in offers and not exclusive — and holger refuses it, which is the cleanest possible demonstration that the app's rules are not the declaration's. See lib.rs::an_airgap_tier_may_not_ask_for_an_egress_feature.
7.4 The evidence — and why it discriminates
Recorded in the crate docs (nornir-buildspec-holger/src/lib.rs), measured through the forge on 2026-07-22:
| tier | bin | size | build | egress |
embedded |
holger-embedded |
26.7 MB | 32.1 s | none |
mini |
holger-mikro |
26.7 MB | 10.5 s | none |
full |
holger-full |
32.4 MB | 27.9 s | links reqwest + ureq |
Re-checked on this box: the three binaries on disk are 26.9 MB / 26.9 MB / 32.6 MB (they have been relinked since; treat the recorded numbers as the point-in-time measurement, not a contract), and cargo tree -e no-dev over each tier's own package + features confirms embedded and mini pull neither reqwest nor ureq while full pulls both.
The full row is the point. A guarantee that every variant satisfies is a guarantee nobody is testing. full federating is what proves the check is not vacuously true — which is what makes this evidence rather than decoration. The same asymmetry is asserted as a unit test (the_airgap_verdict_discriminates_between_the_tiers) and again through the wasm boundary in the acceptance test.
7.5 The plugin
$ cargo build -p nornir-buildspec-holger --target wasm32-unknown-unknown --release
$ ls -l target/wasm32-unknown-unknown/release/nornir_buildspec_holger.wasm
403,865 bytes (≈ 394 KB)
tests/loaded_as_a_plugin.rs builds that module fresh (never a checked-in copy) and proves, in four tests:
- a forge that never heard of holger lists, validates and plans it from bytes in a tempdir with no
Cargo.tomland no repo; - the shipped module's import section asks for nothing outside
nordisk::{git_rev, read_manifest, log}; - a build thing is read out of a forged aarch64 ELF this box cannot execute, round-trips byte for byte, and still answers;
- a file that is not a build thing is refused with a reason.
If the wasm32-unknown-unknown target is not installed the tests skip with a printed reason rather than going red — "your toolchain is thin" is not a failure of the code under test.
8. The facett side
facett/facett-buildthing is the UI half. It runs no builds and owns no build logic: the host hands it a BuildMenu and a BuildGraph (both plain serde types from nornir-build-thing), and it draws them.
8.1 BuildThingDeck — three real panes
facett/facett-buildthing/src/deck.rs
| idx | pane | what it owns |
0 PANE_VARIANTS |
Variants | the role-grouped menu — linkage chip, normal chip, the runtime sentence, and ▶ Build |
1 PANE_GRAPH |
Graph | what the selected variant compiles in and what it needs running |
2 PANE_RESULT |
Result | what the last build produced — BUILT/FAILED chip, artifact paths + sizes, the compiler's own diagnostics verbatim |
A pane that merely renders is not a pane: it has its own rect, its own chrome and a divider a human can drag. Otherwise the first readout that grows pushes its neighbour off the bottom.
Two mounting paths, one owner:
pub fn view_pane(&mut self, idx: usize, ui: &mut Ui) -> Vec<Msg>
is the primary path — it renders one pane into a rect the host has already decided, which is what facett_app::scene needs to build a deck out of real scene panes (each with its own chrome, fold, minimise, maximise). It mirrors facett_warehousedeck::WarehouseDeck::view_pane, and facett-deploykey mirrors it in turn.
Facet::ui draws all three stacked with its own ~30-line draggable dividers, for a host that mounts the deck as a single tab body. That is what dwarves' ⚒ tab does today (dwarves-demo/src/build_thing.rs calls self.deck.ui(ui)), because its shell keys pane decomposition off a table this crate does not own.
The hand-rolled divider is a deliberate narrow exception rather than a reuse of facett_app::SplitPane: SplitPane takes ownership of two Box<dyn Facet> children, which is incompatible with also serving view_pane(idx) from the same three panes. Two owners of one pane is the twin worth avoiding, so the deck keeps one owner. When a host mounts the primary path this code is not reached.
Interaction leaves the deck as messages the host drains:
pub enum Msg { SelectVariant(String), Build(String) }
The deck opens on the app's declared normal case (default_for(Role::Server), then Client, then the first entry) — never on "first", which is an accident of declaration order.
8.2 Facet only — never Facet + Panel
BuildThingDeck implements Facet only. facett-core carries a blanket impl<T: Facet> Panel for T, so it is a Panel for free with nothing to disambiguate at a call site.
Implementing both by hand is what produced E0034 (multiple applicable items) on ui / update_json in dwarves' pane. Don't. BuildGraphPane implements neither trait — it stays inherent-methods-only and is driven by the deck shell.
8.3 graph() returns data, not SVG
pub enum NodeKind { Variant, Step, Artifact, Embedded, Reached }
pub enum EdgeKind { Plan, Produces, Embeds, Reaches }
pub struct GraphNode {
pub id: String, pub label: String, pub kind: NodeKind,
pub detail: Option<String>,
pub selects: Option<String>, // the variant a click on this node selects
}
Decision: data, not SVG. SVG was the other option and is the wrong one:
- facett renders egui natively, so an SVG would mean shipping a parser and a rasteriser;
- and it would throw away hit-testing, hover, selection, zoom and live status — everything that makes the picture worth having.
A node the user can click to build is worth more than a picture of one.
BuildGraph is plain serde with no egui type anywhere near it, because it crosses the wasm boundary. The plugin cannot draw, and should not be able to.
The condition that would reverse it: a consumer that is not an egui application and needs a portable picture — a web report, a PDF, a chat attachment. That is a renderer added beside facett-graphpan, taking the same BuildGraph; it is not a change to what graph() returns.
Embeds draws solid; Reaches draws dashed (EdgeKind::dashed()). That single visual distinction is the fat/thin story: holger full embeds skade — it is in the binary; holger mini reaches it — it must be running. A table states that; a picture shows it before you have read a word.
8.4 Rendering reuses facett-graphpan
facett/facett-buildthing/src/graph_pane.rs maps BuildGraph → facett_graphpan::GraphModel and hands it to the shared facett_graphpan::draw_graph, which already owns pan, scroll-zoom, click-to-select, downstream highlighting and GraphResponse.clicked_node.
No second renderer, no second layout. The only "layout" in the file is column(kind) — variant(0) → embedded/reached(1) → step(2) → artifact(3), left to right in story order. Everything else the file adds is the domain mapping: which kind gets which categorical colour, which edge is dashed. Colours for the canvas come from the live ui.visuals(), so the pane follows whatever theme facett-core's look presets have in force.
The round trip that pays for graph() returning data:
if let Some(id) = response.clicked_node {
if let Some(sel) = self.graph.nodes.iter().find(|n| n.id == id)
.and_then(|n| n.selects.clone()) {
self.pending_select = Some(sel); // taken by the deck, raised as Msg::SelectVariant
}
}
The pane never mutates the selection itself — the menu stays the one owner. Asserted in facett by deck.rs::clicking_a_graph_node_selects_that_variant, and end-to-end in the host by dwarves-demo/src/build_thing.rs::robot_clicking_a_graph_node_selects_that_variant, which clicks mini's root node and asserts the selection, the deck state and the graph pane's root all moved to mini.
8.5 Honesty properties
These are the ones worth keeping when this code is touched:
BuildGraph::dangling_edges()is an oracle that a renderer never has to invent a node to hang an edge from. It is asserted on holger's real graphs and surfaced inBuildGraphPane::state_json.- An unknown variant yields an empty graph —
bt.graph("ghost")returnsnodes: [], edges: []. Not a panic, and not a fabricated node. The pane renders it as "an empty graph is a missing declaration, not an empty build". - An empty deck says the declaration is ABSENT, not that the build space is empty: "no build thing loaded — this is an ABSENT declaration, not an empty build space".
pane_countstays 3 even with nothing to show. - A declaration bug is shown, not swallowed —
check_defaults()problems render as⚠ …in the pane and appear instate_json["default_problems"]. - An out-of-range
view_pane(idx)draws a visible marker rather than nothing, so a host whose pane tree has drifted sees the drift instead of a blank rect that reads as "no data". select()returns whether it changed, so a host fetches a fresh graph on a change instead of re-fetching every frame.- A red build keeps the compiler's own rendered diagnostics, verbatim, and green means an artifact exists on disk — not that a command exited zero.
Everything above is asserted headlessly through state_json / update_json; cargo test -p facett-buildthing is 14 unit tests + 2 egui_kittest robot tests (CPU, no wgpu) and passes.
Adding a build thing to your app
Five steps. nornir-buildspec-holger is 743 lines including its 12 tests; the mechanical part is about 60.
1 — a new edda leaf. edda/crates/nornir-buildspec-<app>/Cargo.toml:
[package]
name = "nornir-buildspec-<app>"
edition = "2021"
[lib]
# `cdylib` is the wasm32 plugin the forge loads; `rlib` is what the app links.
crate-type = ["cdylib", "rlib"]
[dependencies]
# Dep-light on purpose: this crate must build for wasm32-unknown-unknown.
nornir-build-thing = { version = "0.1.0", path = "../nornir-build-thing" }
serde_json = "1"
[dev-dependencies]
nornir-build-thing = { path = "../nornir-build-thing", features = ["plugin-host", "artifact-section"] }
tempfile = "3"
wasmparser = "0.221"
Any dependency you add must build for wasm32-unknown-unknown and import nothing. Check default-features = false on anything time-, random- or IO-adjacent (chrono's wasmbind, uuid/v4, getrandom).
2 — declare the space. A pub fn build_variants() -> BuildVariantSet with offers, exclusive, required (mirror your real [[bin]] required-features), downstream (the embed allowlist), and one BuildVariant per build point. Per variant set role, is_default (exactly one per role), kind, shape (linkage), embeds / reaches, package, bin, features, no_default_features, profile.
3 — implement BuildThing. Only two methods are mandatory:
impl BuildThing for MyBuildThing {
fn manifest(&self) -> Manifest { … } // abi, repo, source_crate, summary
fn variants(&self) -> BuildVariantSet { build_variants() }
}
roles, menu, provenance, plan, graph, validate, rebuild_recipe and describe all have working defaults. Override the ones where your app knows something the declaration cannot express:
validate— a combination the generic rules permit and your product forbids;judge_artifact— a predicate over a built artifact's dependency closure;plan— a codegen step, a wasm post-process, a packaging step, or (like holger) a step that gathers the evidencejudge_artifactwill be judged on;roles— richer group summaries than the derived ones.
Implementations must be pure. No filesystem, no network, no clock, no process. Everything you need is passed in, or comes over one of the three imports. An impl that reaches for a syscall compiles natively and traps in the plugin — exactly the drift this design exists to prevent.
4 — export the plugin. One line at the bottom of lib.rs:
nornir_build_thing::export_build_thing!(MyBuildThing);
The type must be BuildThing + Default. Native builds see nothing; the wasm32 cdylib gains bt_abi_version / bt_alloc / bt_dealloc / bt_call.
5 — the two tests that matter. Copy the shape from nornir-buildspec-holger/tests/loaded_as_a_plugin.rs:
- build the module for
wasm32-unknown-unknown, load it throughPluginHandle::load_file_within a tempdir with no source tree, and assert the menu / validate / plan / provenance you expect. Skip (with a printed reason) if the target is not installed. - parse the module's own import section with
wasmparserand assert every import is innornir_build_thing::plugin::host::IMPORTS. This is the test that catches a dependency quietly dragging WASI or wasm-bindgen in.
Then have the app depend on the leaf, so a missing declaration is a compile error.
What has shipped and what has not
Stated plainly, because a doc that describes the intent as though it were the build is worse than no doc.
| claim | status on 2026-07-22 |
| the trait, the ABI, the guest macro, the dispatcher | ships, tested |
| the wasmtime host + the import permission list + fuel/memory/reply caps | ships, tested |
holger's build thing, native and as a wasm32 plugin |
ships, 16 tests green |
| plan-as-data, rev pin, artifact judgement over the wasm boundary | ships, tested |
read_section — reading a build thing out of a (cross-built, hostile) artifact |
ships, tested against a hand-forged aarch64 ELF |
writing .nordisk.buildthing into a real built binary |
not implemented. There is no emitter and no build.rs anywhere in the constellation. The test forges the ELF by hand. |
holger depending on nornir-buildspec-holger |
not wired. No holger Cargo.toml references the crate; holger still declares .nornir/build-variants.toml, and the "compile error if absent" guarantee is not yet in force for holger. |
| dwarves loading the wasm at runtime | not wired. dwarves links plugin-host + artifact-section but nothing in the repo constructs a PluginHandle. dwarves/crates/dwarves/src/build_thing.rs reads each checkout's .nornir/build-variants.toml and wraps it in TomlBuildThing, which implements the same trait so menu() and graph() come from the same default bodies the wasm path uses. That is why the deck renders three repos today; what a TOML cannot supply is per-app logic. |
BuildThingRef + resolvers (artifact section / manifest key / registry / warehouse) |
does not exist. No such type anywhere. What exists is spec::SpecOrigin — app-emitted · legacy-toml · compiled-in · undeclared — each with an is_guaranteed() flag and a human line; dwarves' ⚒ header renders the label and the line, coloured by whether it is guaranteed. NoBuildThing as a named state does not exist either; the equivalents that do are SpecOrigin::Undeclared, read_section() -> Ok(None), and the deck's ABSENT-declaration label. |
nornir-buildspec-holger in edda's workspace |
is a member (members = ["crates/*"]), though edda's own header comment still lists build-thing as "queued". |
A recorded disagreement
holger/.nornir/build-variants.toml (rewritten 2026-07-22 22:17) and nornir-buildspec-holger::shape_of() disagree about linkage, and the TOML says so out loud rather than quietly resolving it:
- the TOML classes all three server tiers and
uias fat, andagentas the only genuinely thin artifact, on the ground that holger has noskadedependency at all — searching everyCargo.tomlin the holger workspace forskadefinds two hits and both are prose in comments. Where skade actually enters holger isgui→modgunn/warehouse→dep:skade, i.e.holger-ui. - the build thing classes
fullas fat-embedding-skade,embedded/minias thin, anduias thin-reaching-holger.
So the graph currently drawn for holger full shows a solid embeds skade edge that no Cargo dependency backs. Reconciling the two is open work; until then, read the embeds/reaches edges on holger's server tiers as declared intent rather than as measured fact. The Embeds-solid / Reaches-dashed mechanism is correct and is exercised for real on korp (korp genuinely embeds skade — asserted in dwarves-demo).
Where to look
| you want | read |
| the trait, the types, the ABI, the permission list | edda/crates/nornir-build-thing/src/plugin.rs |
| the loader and the sandbox limits | edda/crates/nornir-build-thing/src/plugin_host.rs |
the declaration schema (BuildVariant, BuildVariantSet, validate) |
edda/crates/nornir-build-thing/src/variant.rs |
the file-cache path + SpecOrigin |
edda/crates/nornir-build-thing/src/spec.rs |
| a complete worked build thing | edda/crates/nornir-buildspec-holger/src/lib.rs |
| the acceptance proof | edda/crates/nornir-buildspec-holger/tests/loaded_as_a_plugin.rs |
the three panes and the Facet-only rule |
facett/facett-buildthing/src/deck.rs |
| the graph mapping onto the shared renderer | facett/facett-buildthing/src/graph_pane.rs |
the forge engine (real builds, egress audit, is_green) |
dwarves/crates/dwarves/src/build_thing.rs |
| the host that mounts the deck | dwarves/dwarves-demo/src/build_thing.rs |
| fat vs thin as a suite-wide pattern | workspace_nordisk/.nornir/thin-fat-client-pattern.md |
Which recipe reader this repo uses, and why (lane AJ, 2026-08-30)
Reader: nornir-build-thing 0.1.12, PATH (xtask, facett-buildthing).
Why: in-constellation. facett ships NO .nornir/build-variants.toml of its own — it is a build-thing VIEWER, drawing another repo's declared BuildVariantSet as egui widgets — so it is a pure reader and its pin decides what other repos' recipes it can draw. Both sites had said the floating "0.1" while the lock pinned 0.1.10: a viewer three generations behind the recipes it renders.
The rule itself — [nornir-build-thing] schema = N, a refusal that names the version to upgrade to, and a loader that refuses to DROP a key it cannot read — lives once, in edda .nornir/build-design.md, "THE COMPATIBILITY RULE". The cross-version guard that would have caught the 2026-08-30 skew is edda/nornir-build-thing-skew.