# 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](#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](#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](#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`:
```toml
[lib]
crate-type = ["cdylib", "rlib"]
```
* the **`rlib`** is what the app links, natively;
* the **`cdylib` built for `wasm32-unknown-unknown`** is 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:
```rust
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:
```toml
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>`:
```rust
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.
```rust
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:
```rust
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:
```rust
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:
```json
{
"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_artifact` will be asked about — rather
than the plugin trying (and failing, having no filesystem) to gather them
itself. `full` gets 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 `full` that links **no** HTTP client is refused —
`"the upstream-registry federation is missing from this artifact"`. A `full`
with 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:
1. a forge that never heard of holger lists, validates and plans it from **bytes
in a tempdir with no `Cargo.toml` and no repo**;
2. the shipped module's import section asks for **nothing** outside
`nordisk::{git_rev, read_manifest, log}`;
3. a build thing is read out of a **forged aarch64 ELF this box cannot execute**,
round-trips byte for byte, and still answers;
4. 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:**
```rust
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:
```rust
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
```rust
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:
```rust
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 in `BuildGraphPane::state_json`.
* **An unknown variant yields an empty graph** — `bt.graph("ghost")` returns
`nodes: [], 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_count` stays 3 even with nothing to show.
* **A declaration bug is shown, not swallowed** — `check_defaults()` problems
render as `⚠ …` in the pane and appear in `state_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`:
```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:
```rust
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 evidence `judge_artifact` will 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`:
```rust
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 through
`PluginHandle::load_file_with` in 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 `wasmparser` and assert every import
is in `nornir_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 `ui` as **fat**, and `agent` as the
only genuinely **thin** artifact, on the ground that **holger has no `skade`
dependency at all** — searching every `Cargo.toml` in the holger workspace for
`skade` finds two hits and both are prose in comments. Where skade actually
enters holger is `gui` → `modgunn/warehouse` → `dep:skade`, i.e. `holger-ui`.
* the build thing classes `full` as fat-embedding-skade, `embedded`/`mini` as
thin, and `ui` as 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`.