Gunnar

nordisk/dwarves

.nornir/buildthing-guide.md

<!-- nornir:class: guide -->
# Build Thing — guide

> **Class:** `guide` · audience: a techie driving `dwarves buildthing` or the ⚒ tab over a real checkout.
> This is the `f0` file for the build thing itself — what it is, its vocabulary, and the two roads onto it.
> The design source of truth is the module doc of edda's
> [`nornir-build-thing/src/plugin.rs`](https://codeberg.org/nordisk/edda) — condensed here, never contradicted.
> Siblings: [holger-build-thing-design.md](holger-build-thing-design.md) (the holger build target),
> [buildthing-installer.md](buildthing-installer.md) (the per-site installer door).

## What a build thing IS

**A build thing is FUNCTIONALITY, not a table** (plugin.rs, verbatim). Two halves:

1. **The declared build space** — `<repo>/.nornir/build-variants.toml`: the rows a repo
   can be built as. Data, on disk, diffable.
2. **The `BuildThing` impl** — code, in a crate the app depends on (`gunnar-build-thing`,
   `holger-build-space`, …). The app compiles it in, so the app describes **and
   validates** itself; a missing build thing is a compile error, not a file nobody
   noticed was absent. Validity logic (an INVALID chip, a refused option) is code and
   does not serialize — that is why the JSON-export idea was rejected.

For an artifact **pulled from a registry**, the same crate compiles to
`wasm32-unknown-unknown` — one source, two link targets — and the artifact carries that
wasm module in the `.nordisk.buildthing` linker section plus a receipt. The forge reads
the section with an object parser and calls the build thing in a capability-less
sandbox. **The forge never executes the artifact**: an aarch64/musl artifact cannot run
on an x86 forge anyway, and running one is remote code execution on the box holding the
deploy keys. If a pulled artifact must genuinely RUN, that is modgunn's OCI or tunnr's
KVM, never bare on the forge host.

## Vocabulary

| word | is | notes |
|---|---|---|
| **variant** | one named `[[build.variant]]` row — a point in the repo's build space | the unit `--variant` names; `--atom`/`--cell` are retired aliases, kept forever |
| **kind** | the output type: `exe` · `lib` · `wasm` · `service` · `container` · `vm` · `iso` · `installer` · `kernel` | cargo builds `exe`/`lib`/`wasm`/`service`; every other kind **delegates to its own same-named table** (`[[build.iso]]`, `[[build.kernel]]`, …) bound by name, exactly like `iso` |
| **features** | ONE axis of a variant — the cargo feature set | `dwarves-ui` vs `dwarves-ui-gpu` differ ONLY here |
| **role** | `server` · `client` · `solo` · `library` · `tool` | who the artifact is for |
| **linkage** | `fat` (static/musl-shaped) vs dynamic | |
| **suites** | the deliveries (utgåvor) a variant belongs to (`MVP`, `ORIGINAL`) | `--suite` is required once a product declares more than one |
| **profile** | `debug` · `release` (± more) | cargo profile per row |

A bare delegate table (`[[build.kernel]]` with no variant row) is **INERT** — it
validates and renders but is never dispatched. The `[[build.variant]]` row of the same
name is what makes it a build point.

**Kind → pane** (`PaneKind::for_artifact`, edda `contract.rs` — data, no product `if`s;
a recipe may override):

```text
exe                    -> Exec     argv + stdout, run in a container
container · service    -> Log      start/stop + log + ports
iso · vm · installer   -> Console  serial AND graphical, both shown, never a toggle
lib · wasm · kernel    -> None     nothing to run — kernel gets the configurator LEAF instead
```

## Options and params

`[[build.installer.options]]` rows (`OptionSpec`, edda `options.rs`):

- an option is **opt-out of a compiled capability**: `off` means the cargo `feature`
  behind it is SUBTRACTED from the appliance binary — the code is **not compiled in**,
  not present-but-declining. Not a runtime flag.
- `params_when_on` / `params_when_off` gate which params are accepted: WireGuard off ⇒
  `--param hub_conf` is **refused by name** rather than staged onto a medium whose
  binary cannot read it; DHCP off ⇒ the static address becomes required.
- `bakes` (`OptionBake`) records what the choice bakes onto the medium.

`--param` values land on declared `ParamSpec` rows (edda `params.rs`): `name=value`,
`name=@path` (file contents), `name=@fixture` (the forge mints one). Landing is either
a kernel-cmdline `boot_param` token or a `staged_file` in the staging tree. A name that
reads as a **secret** (`private` · `secret` · `passw` · `token` · `psk` · `preshared`)
is refused by name — cmdline, declaration and build record are all readable. Unknown
names, wrong shapes: refused by name, never ignored.

## Kernel as options (2026-08-26)

Every `[[build.kernel]]` repo's kconfig symbols are **declared option rows** — the same
vocabulary as installer options, emitted by edda `kernel_features.rs::option_rows`:

- a **preset** is not a separate mechanism: it is the named **default-bundle** the rows
  start from (`full` · `normal` · `slim` · `nuked`); `default=on` literally means
  "enabled in the chosen preset".
- `requires` is the **Kconfig NAME hierarchy**: `P` parents `C` when `P` is the longest
  symbol present that prefixes `C_…` — derived, never hand-listed.
- **contract locks**: a symbol of the measured boot contract is `LOCKED`, and dropping
  it is refused **naming what breaks**.
- the operator's decision (`KernelDecision`) renders one **veto fragment** — every drop
  spelled `# CONFIG_X is not set`, cascade included, so the file states the whole
  consequence of the clicks. The **same decision through a pane click and a CLI flag is
  the same bytes** (edda 37b5b5f).
- the veto fragment rides `merged_fragment` **LAST** (preset → profile → the repo's
  committed fragment → the operator — later wins), and a fragment that vetoes anything
  **moves the content id**; a comment-only fragment must not (`changes_anything`).

## Road 1 — the ⚒ tab

The scene (`dwarves-gui/src/build_scene.rs`) knows **no product by name**:

```text
1  type an ARTIFACT NAME     the code resolves the recipe itself
2  the home matrix appears   every row that recipe declares
3  pick ONE row              one at a time
4  pick ONE column           the delivery targets that row declares
   …the feature/package axis: click a package away, see which feature goes dark
   …the declared options/params render as settings; invalid input disarms the door
5  ▶ BYGG                    a pane appears BELOW, per the kind's PaneKind
```

The **kernel leaf** is recipe-gated (`Forge::browse`), three honest states:
nothing resolved → the `BROWSE_NONE_HINT` ("pick a recipe" — claims nothing about the
forge); a recipe with no `[[build.kernel]]` → `Leaf::Refused` **naming the recipe**; a
recipe that declares one → the configurator itself, answering "what is in a `slim`
kernel" with nothing selected at all.

## Road 2 — the CLI

```sh
dwarves buildthing <product> [--suite S] [--variant V] [--list] [--list-options]
                   [--param K=V]… [--opt name=on|off]…
                   [--opt kernel.preset=P] [--opt kernel.<symbol>=off]…
dwarves buildthing <product> --installer [--variant CELL] \
                   --kernel <bzImage> --app <app.znippy> [--param K=V]…
```

`<product>` is a member name (resolved `$NORDISK_ROOT/<p>` → `../<p>` → `<p>`; the
chosen checkout and the rule that chose it are printed), a path, or a build-thing
**coordinate** (`gunnar-build-thing:0.1.0`) — the receipt's pointer typed by hand for
the before-first-build case. The verb contributes NO variant name, NO feature string,
NO bin name: it walks the ONE resolver every member's `cargo xtask build` uses.
The installer door takes its kernel donor explicitly: `--kernel <bzImage>` (or
`$KORP_TEST_KERNEL`) plus the packed `--app`, composed by
`korp_installer::isocompose::ForgeComposer` under the `appliance-iso` feature.

Measured on tunnr, 2026-08-26:

```text
$ dwarves buildthing tunnr --list-options            # 49 declared rows, builds nothing
── tunnr-kernel · kind kernel
  opt  kernel.preset  bundles: full · normal · slim · nuked  default=nuked
  opt  kernel.efi   CONFIG_EFI   default=on  LOCKED(breaks CONFIG_EFI_STUB)
  opt  kernel.unix  CONFIG_UNIX  default=on
  opt  kernel.virtio CONFIG_VIRTIO default=on  LOCKED(breaks CONFIG_VIRTIO, CONFIG_VIRTIO_PCI, CONFIG_VIRTIO_NET, CONFIG_VIRTIO_BLK)
  opt  kernel.wireguard CONFIG_WIREGUARD default=off        # …44 more rows

$ dwarves buildthing tunnr --variant tunnr-kernel --opt kernel.preset=slim --list
kernel decision: preset=slim profile=vanilla clicked_off=[] (changes nothing)
  tunnr-kernel   server   kernel   (not a cargo build — built by the kernel builder)

$ dwarves buildthing tunnr --variant tunnr-kernel --opt kernel.virtio=off
--opt kernel.virtio=off REFUSED — dropping CONFIG_VIRTIO breaks the measured
boot contract: CONFIG_VIRTIO, CONFIG_VIRTIO_PCI, CONFIG_VIRTIO_NET, CONFIG_VIRTIO_BLK

$ dwarves buildthing tunnr --variant tunnr-kernel --opt kernel.unix=off    # a REAL build
```

That last build ran to completion in the bounded toolchain container:
`.kernel-out/tunnr-kernel/kernel.config` carries `# CONFIG_UNIX is not set` (line 729),
the bzImage came out 1717248 bytes against the untouched preset's measured 1725440, and
the pin (`tunnr-kernel.kernel-pin.json`) seals the moved decision —
`fragment_sha256 d437b919…`, `bzimage_sha256 d4ed666b…`. The veto reached the build's
own merge and the content id moved with it. One vocabulary throughout: what
`--list-options` prints is exactly what `--opt`/`--param` accept, read off the same
declarations, so the listing and the refusals cannot disagree.

## Provenance — `<artifact>.intoto.jsonl`

With `DWARVES_PROVENANCE_KEY` set, every green cargo build gets a **DSSE envelope over
an in-toto Statement v1 / SLSA Provenance v1** beside each artifact
(`crates/dwarves/src/provenance_step.rs`, feature `sign-step`): subject = file name +
sha256, `buildType` = edda `BUILD_TYPE_VARIANT_V1`, `resolvedDependencies` = every
source tree the container mounted (`git+https://git.gunnar.rs/<org>/<repo>` +
`gitCommit`) and the toolchain image digest, `builder.id` =
`https://git.gunnar.rs/nordisk/dwarves/buildthing@v1`. The seal is verified against
its root before the build turns green.

- `DWARVES_PROVENANCE_KEY=dev` — the PUBLISHED development key (derived from korp's
  published `DEVELOPMENT_SEED`; keyid `428a6180…7616`, the build prints its Holger
  `keys:` row). Proves the path, vouches for nothing.
- `DWARVES_PROVENANCE_KEY=<dir>` — `provenance-signing.pk8` (0600) +
  `provenance-signing.der` + `korp-root-ca.der`: a real key, never inside a container.

Verify: `cosign verify-blob-attestation --key <pub.pem> --signature <a>.intoto.jsonl
--type slsaprovenance1 --insecure-ignore-tlog=true <a>`, or holger-provenance's
`verify_slsa` example (allow-list the builder id above).

## Guards a contributor must know

- **The scene names no product.** `dwarves-gui/tests/robot_build_scene.rs`
  (`the_scene_source_names_no_product`) tokenises the scene's own source and scans it
  for 24 product names — zero may appear. The only
  kind dispatch is `PaneKind::for_artifact`; a new kind gets a pane by data, never by
  an `if` over a name.
- **Mirrors change together.** Where a repo keeps `build-variants.toml` on disk beside
  a compiled build thing (gunnar: `CELLS`), `tests/toml_parity.rs` holds the two to
  saying the same thing — the file is a mirror, not a second source of truth.
- **One writer per rule (LAW 5).** Resolution, refusal wording, the kernel feature
  model, the option vocabulary — each lives ONCE in edda `nornir-build-thing`; the CLI,
  the pane and every product xtask call it. A brought-over join is a twin; do not.