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

nordisk/facett

.nornir/build-thing-pattern.md

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:

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"]

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.

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:

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 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:

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:

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:

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:

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:

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:

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:

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:

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.