Gunnar

nordisk/dwarves

.nornir/build-thing-model-design.md

<!-- nornir:class: design -->
# build thing model — design

> **Class:** `design` · **Function axis:** `f0` the build-thing model (the whole) · `f1` the tree (feature ▸ setting ▸ sub-setting) · `f2` the impl answers the tree (wasm ABI) · `f3` typed consumption edges (`--build X`) · `f4` presets as `val` nodes · `f5` kinds bear their verbs · `f6` the laws over the tree
> **Strict:** this file states *only what the build-thing model IS* — its parts and why they are shaped this way.
> Sibling: [buildthing-guide.md](buildthing-guide.md) (the ⚒ pane and the CLI door); the trait lives in edda: `nornir-build-thing`.
> Written down 2026-08-26 from Rickard's rethink, in his vocabulary: **variant → features on the variant → settings / sub-settings → typ**. Never "cell".

A build thing is **functionality, not a table** (edda `plugin.rs`). Every repo that can be built ships a `<repo>-build-thing` crate, compiled **both** native and `wasm32-unknown-unknown` — one source, two link targets — and the forge (CLI *and* ⚒) loads the wasm and *calls* it. What ⚒ draws and what `dwarves buildthing` accepts are the same answers from the same module, so the two doors cannot drift. The `.nornir/build-variants.toml` is the still part (variants, kinds, edges, the choices made); the impl is the moving part (what *can* be chosen).

## The system as one — the function map (f0)

| part | lives in | what it is |
|---|---|---|
| `f1` the tree | edda `options.rs` / `params.rs` | one recursive tree per variant: **feature** (on/off), **setting** (typed value), **val** (one-of). Paths are the ids: `dhcp.static_ip`, `kernel.preset`, `kernel.unix` |
| `f2` the impl answers | edda `plugin.rs` (`BuildThing`) | `variants()` (static), `options(path)` (dynamic subtree), `try_toggle(path, on)` (the judge, cascade included), `actions(kind)` (verbs). Same methods over the wasm ABI |
| `f3` consumption edge | edda `tree.rs` (`ConsumptionEdge`, `EdgeDecl`, `wrong_typ`); `installer.rs` `kernel_donor` is its kernel-typed spelling | a feature bound to a coordinate `repo/variant` of a required **typ**: `kärna ↔ tunnr/tunnr-kernel : kernel`. Types must match at declaration time — and the edge IS a feature, so the line that switches it off takes the donor out of the plan |
| `f4` presets | edda `kernel.rs` (`Preset`) → general `val` | a `val` node bundling defaults for the subtree under it — `kernel.preset = full\|normal\|slim\|nuked`; the same node type at a variant's root is the "preconfig" (`preset = airgap`) |
| `f5` verbs | dwarves `build_scene.rs` (kind → pane) → edda data | the artifact's **typ on the receipt** decides RUN / BOOT / UNPACK; an impl may refine via `actions(kind)` |
| `f6` the laws | edda `laws.rs` | A declaration ↔ struct · B tree node ↔ declaration row · **C path ↔ flag** · D decision ↔ fragment · E receipt → recipe. Every impl calls `assert_bijective` |

## The tree (f1)

```
gunnar-installer                       (variant · typ installer)
├─ wireguard   (feature)   └─ wireguard.hub_conf         (setting)
├─ dhcp        (feature)   └─ dhcp.static_ip, dhcp.gateway   (settings, when off)
└─ kärna       (feature, edge → tunnr/tunnr-kernel : kernel)
   ├─ kernel.preset        (val: full | normal | slim | nuked)
   └─ kernel.<symbol>      (features, from the impl's model; cascade computed)
```

Three node kinds and recursion — nothing else. The kernel is not a special leaf; it is the first feature whose subtree is deep and delegated. Today's `OptionSpec` is flat (`params_when_on/off`, `bakes`); the tree adds `children` and the `val` kind, and keeps the flat spellings as unambiguous aliases (`--param static_ip=` ≡ `--param dhcp.static_ip=`).

## The two doors are one call (f2)

| click in ⚒ | the same on the prompt |
|---|---|
| gunnar ▸ gunnar-installer | `dwarves buildthing gunnar --variant gunnar-installer` |
| ☑ wireguard ▸ hub_conf = @fixture | `--opt wireguard=on --param wireguard.hub_conf=@fixture` |
| ☐ dhcp ▸ static_ip, gateway | `--opt dhcp=off --param dhcp.static_ip=10.0.2.15/24 --param dhcp.gateway=10.0.2.2` |
| ☑ kärna ▸ preset ▸ vetoes | `--opt kernel.preset=nuked --opt kernel.unix=off` |
| [BYGG kärnan] | `--build kernel` |

The path prefix *is* the brace: `--build tunnr { --opt unix=off }` and `--opt kernel.unix=off` are the same statement, because the feature path ↔ the coordinate through the declaration.

**What the wasm cannot do, and must not:** I/O. The module has no imports (no WASI — by design, see `plugin.rs`). An impl that needs the kernel *source* to answer `options("kernel")` asks the host through one narrow call, `fetch(coordinate) → bytes`; dwarves fulfils it (skidbladnir / draupnir, a container if that is what it takes). Capability is granted explicitly, never as a filesystem right.

## Consumption edges and `--build` (f3)

Two kinds of dependency a variant has:

| kind | flag | meaning |
|---|---|---|
| ready | `--opt <feature>=on\|off` | libs/features already in the recipe — pointed at |
| built | `--build <X>` | another build thing whose artifact is consumed — built as a step, with its own `--opt` |

`X` is a coordinate `repo/variant`; the short form (`--build tunnr`, `--build kernel`) resolves through the declaration when unambiguous. Without `--build`, the consumed artifact must exist and match its pin (fragment-sha proves preset + vetoes at once) — otherwise a refusal that spells out the `--build` line. The order of a chained build falls out of the edges: a DAG over build things, run by runa.

## Presets, pre-built (f4)

`full · normal · slim · nuked` are the four kernel presets — a `val`, not a flag of their own (`kernel.rs`: *"a preset is not a separate mechanism from the per-symbol rows: it is the bundle of defaults the rows start from"*). A preset with no vetoes always yields the same fragment-sha → the same pin → the bzImage can sit built in the ledger, so picking it costs nothing; only a veto forces a build. The pin lookup belongs in the suite warehouse / ledger, not a local `.kernel-out/` directory.

## Every consumer declares its surface requirements (f5)

**A feature of a consumer declares what it needs of the sub build-thing behind an edge, next to the feature, with a reason. The engine collects, unions recursively, refuses by name and INVERTS. In the inverted index a symbol that only a fragment set is `preset-only` — and `preset-only` means UNDECLARED, not unused.**

That last sentence is the rule this section exists for, and it is the one that was missing. The inverted index (`kernel.why.toml`) sorts every symbol of a built kernel into three classes:

| class | what it means | what to do about it |
|---|---|---|
| `feature` | a consumer's feature asked, and the row says who and why | nothing — this is the model working |
| `preset-only` | a fragment set it and no feature asked | **find its owner.** It is a candidate to nuke only AFTER somebody has tried and failed to own it |
| `kconfig-derived` | in the built `.config`, named by nobody (the base + `select`/`depends`) | out of scope: a solver's problem, not a declaration gap |

The trap is reading `preset-only` as *unused*. It is not evidence about the product at all until the consumers have spoken; with three features declaring, sixty symbols are "preset-only" and fifty-nine of them are load-bearing. So the index cannot answer "what can we remove?" until it can answer "what does everything need?", and the second question is the one the consumers answer.

**The law:** every POSITIVE line of a shipped fragment has a requirer, and each line that has none is named IN CODE with the reason nobody can own it. It is a test, not a habit — `kernel_requires::every_positive_fragment_line_has_an_owner_except_the_ones_named_here` asserts the exception list literally, so a new unowned line fails the build with its own symbol in the message.

**The bijection, both ways.** A declaration that cannot fail is not a declaration, so each one is proved in both directions: turning the feature OFF must REMOVE the requirement, and turning it ON must bring the symbol back BY CLOSURE rather than by a fragment line. The worked example is `CONFIG_TUN` (measured 2026-08-30, gunnar's `gunnar-installer-open` over tunnr's `nuked` donor): `--opt wireguard=off` produces a kernel in whose merged fragment and built `.config` the symbol does not appear at all; `--opt wireguard=on` puts it back, class `feature`, requirer `gunnar/gunnar-installer-open : wireguard`, +12 288 B of bzImage. A fragment line could only ever have been half of that.

**Where a row lives.** Next to the feature: an option's own `kernel_requires`, the group that owns the fact (`target.*`, `data.*`), or the PID-1 boot contract for what every appliance needs whatever it declares. The same symbol may have several requirers and that is the union, not a conflict — `CONFIG_INET` is required by gunnar's `dhcp` option AND by the boot contract, because PID 1 writes an `AF_INET` address before any option exists. Two requirers who DISAGREE, or a requirement over a donor's veto, is a refusal naming symbol · consumer · feature · reason.

**A negative is a declaration too.** `=n` over a donor `=y` is a NARROWING and it lands (`target.consoles = [vga]` takes `SERIAL_8250` out); `=y` over a donor's veto is refused. And an `=n` beside a donor veto that already says the same thing is the strongest shape there is: the donor's fragment states the absence, the consumer's row states why it is safe, and a feature that later asks for the opposite is refused by name instead of quietly regrowing the subsystem (`CONFIG_IPV6`, `CONFIG_WIREGUARD`, `CONFIG_MULTIUSER`).

**Values, not just booleans.** The surface carries `y · m · n`, a string (`CONFIG_FAT_DEFAULT_IOCHARSET="cp437"`) and a decimal int (`CONFIG_NR_CPUS=64`). The int variant is what made the SMP ceiling declarable at all: as a string it would have merged `CONFIG_NR_CPUS="64"`, which kconfig drops on `olddefconfig` and the canary then refuses. A hex int option is still not expressible, and the type says so where it is defined rather than failing quietly.

**Measured, 2026-08-30** (the kernel gitgunnar.cloud runs, `tunnr-kernel` over `nuked`):

| | feature | preset-only | kconfig-derived | bzImage |
|---|---|---|---|---|
| three features declaring | 49 | 36 | 764 | 2 388 992 B |
| the surface declared, `wireguard=off` | 65 | 19 | 764 | 2 364 416 B |
| the surface declared, `wireguard=on` | 66 | 19 | 764 | 2 376 704 B |

The 19 that remain are 18 `# CONFIG_X is not set` vetoes of the donor's own preset — sound, USB, wireless, DRM/FB, legacy storage, the IPv6 family: a product decision of the DONOR, which no consumer can honestly claim, each with its reason where the line is — plus `CONFIG_UNIX`, the one positive line left, kept `preset-only` on purpose because no PID-1 path is measured to open an `AF_UNIX` socket and a requirement is a measured need, never a habit.

**Where a group lives, and how it is typed (f5b, lane AF 2026-08-30).** A
declared setting GROUP — `[build.installer.data]`'s `fs`/`io` — is a set of
`val` nodes at reserved paths on the consumer's tree (`tree::DATA_FS_PATH`,
`DATA_IO_PATH`), exactly the road the root `preset` val already took: ignored by
the option-row loop, read back BY PATH, compared as a row by law A. Three things
follow, and each one closes a measured defect:

* the group is **spellable** — `--opt data.fs=fat`, `--opt data.io=safe` — and
  moving it moves the DONOR KERNEL too, because the closure reads the group off
  the resolved set (`ConsumerView::declared_data`). Before this, lane N flipped
  `fs = "fat"` in a scratch worktree to build a FAT medium;
* the group is **listed** — `flags_of` renders every choice, so `--list-options`
  needs no listing code of its own, and the summary line says which arm the
  recipe is on;
* a group that CANNOT be a val says so. `[build.installer.target]` is a set of
  lists, not a one-of, so `--list-options` prints it as `group target
  RECIPE-ONLY: …` with its effective shape. **An omission reads as "there is no
  such knob", which is the one thing a listing must never read as.**

And the `--opt` line is split by the TREE, never by a `name → bool` parser:
`SettingsTree::split_opts` returns this variant's features, this variant's vals
(each checked against its roster) and the pairs `behind_an_edge` that the DONOR's
door judges. `options::parse_cli_options` could only ever answer "an option is on
or off", and that sentence WAS the answer `--opt kernel.preset=nuked` got on the
ISO-compose path — a defect a consumer's guide documented as a rule ("do NOT type
this flag") for as long as it stood.

## The kernel is ONE sub build-thing — the second surface (f5c, lane AF 2026-08-30)

The model has said "the kernel is not a special leaf" since f1. It became true
on 2026-08-30, when a SECOND surface went through the same closure: a `typ =
container` donor exposes the TOOLS its recipe carries, and the consumer that
declares against it is **the kernel row itself** — a consumer as well as a donor,
which is the sentence the generic engine exists to make sayable.

```
gunnar/gunnar-installer  →  tunnr/tunnr-kernel  →  tunnr/nordisk-kernel-toolchain
      (Kconfig closure)          (tool closure)
```

`tunnr-kernel` places one tool requirement per STEP it actually runs
(`kernel.compile`, `kernel.lto`, `kernel.minify`, `kernel.probe-boot`), so
`toolchain.why.toml` answers "why is clang in this image?" with the row that
asked — and `image-only` (a package the recipe installs that no feature wants) is
the exact twin of `preset-only`, read the same way: a question, never a verdict.
MEASURED on the shipped rows: five of the image's thirteen tools are image-only.

**What was secretly kernel-shaped, and is not any more** — the finding this lane
was asked for:

| where | what it was | what it is |
|---|---|---|
| `BuildStep::requires` | `Option<KernelRequirements>` — `visit` computed a generic closure and then called `.as_kconfig()`, so a non-Kconfig closure was collected, unioned, refused correctly and **dropped on the floor** | `Option<SurfaceClosure>`, with `kernel_requires()` / `tool_requires()` as typed accessors |
| the step's pin | computed inside `visit` behind `if edge.typ == Kernel`, **twice** | `step_pin_for`, one arm per surface |
| `SurfaceClosure::union` | could not fail — with one variant every pair matched | a named refusal for a mixed pair |

Everything else was already generic: `Requirement`, `EdgeRequirement`, `Closure`,
`RequirementSource`, `closure_of_consumer`, the edge/typ check, the cycle
refusal, the reached-twice union. Those three are where "generic" had been
asserted rather than exercised, and a second surface is what exercised it.

## The forge is a SECOND reader, and the model has to survive that (f5d, lane AJ 2026-08-30)

**The model consequence, stated first:** in this model the CODE is the source and
the toml is emitted — but the toml is not written for one reader. The forge reads
every repo's recipe with the `nornir-build-thing` pin **dwarves** was built
against; the repo's own `<repo>-build-thing` crate, and its `toml_parity` test,
read the same bytes with the pin **that repo** was built against. Those two are
different programs at different versions, updated on different afternoons, and
until 2026-08-30 nothing compared them.

That is not an accident to be tidied away — it is what "the forge holds only the
artifact and can still ask the recipe that built it" MEANS. A model with one
writer and many readers has a version axis whether or not it admits to one.

**What it cost while unadmitted.** Measured, published 0.1.10 against gunnar's
committed recipe: the whole file unloadable on one additive key
(`unknown field kernel_requires`, because `OptionSpec` is
`deny_unknown_fields`), and — with that key removed so the load finishes — FOUR
paths dropped in silence (`build.installer.{0,1}.{target,data}`: the machine each
medium is built for, and what `/data` is laid with) with gunnar's own parity test
GREEN over them for four pin generations. The parity test was green because it
compares the file against the same loader that dropped the rows.

**The three consequences for this model:**

1. **A version is part of a recipe, not part of a toolchain.** A recipe now
   carries `[nornir-build-thing] schema = N` at ROOT level, computed by the
   emitter from what it declares. `code → toml` is unchanged; the emission simply
   says which vocabulary it used. See edda `.nornir/build-design.md`, "THE
   COMPATIBILITY RULE".
2. **A reader may refuse, but may never DISCARD.** "Ignore what you do not
   understand" is wrong for this format specifically, because every group in a
   recipe changes the artifact: a dropped `[build.installer.target]` is a medium
   built for a different machine, not a missing nicety. The loader refuses with
   the paths instead.
3. **The refusal names the fix, not the field.** "this recipe is schema 2, this
   reader understands 1 — upgrade nornir-build-thing to >= 0.1.12" is actionable
   by the operator who hit it; `unknown field kernel_requires, expected one of
   name, label, default, …` names a key that operator never wrote, in a file the
   code emitted.

**For the forge specifically:** `dwarves` is a reader like any other, and its pin
is now an exact floor (`0.1.12`, path) rather than the floating `"0.1"` it
carried — `^0.1.0`, which accepts every reader that ever existed and moves with
no commit. When the forge is too old for a repo's recipe it says so by name and
names the version to install, instead of reporting a parse error in somebody
else's file.

## Fifteen repos declare in code, and one cannot — what the sweep taught (f5e, lane AG 2026-08-30)

**The model consequence, stated first:** *the CODE is the source* is a statement
about a WRITER, and it is only true where a writer exists. Sixteen repos had a
`.nornir/build-variants.toml`; six had a recipe crate; and of those six, TWO
(gunnar and korp) kept the file as a **hand-maintained mirror** with a parity test
comparing the two copies. A mirror is not a smaller version of an emission — it is
a different thing, and it fails in ways an emission cannot.

**The emitter is ONE writer, in the engine.** `nornir_build_thing::emit`
(`recipe_toml` · `emitted_toml` · `write_emitted` · `first_difference`). A repo's
`emit.rs` is its own `EMITTED_HEADER` and three calls. The reason it is not a
seventy-line file per repo is not tidiness: the emission is three lines of
substance and the third one is the round trip, and an emission that does not read
back is not a description of anything. Ten copies is ten places to forget it.
`recipe_toml` refuses bytes that do not read back **equal** through
`BuildVariantSet::from_toml_str` — the one loader, carrying f5d's R1/R2/R3 — so an
emitted file is, by the time it is written, known to be readable at a schema it
declares with nothing silently dropped.

**What a mirror costs, measured.** gunnar's, on one file: two whole groups
(`[build.installer.target]`, `[build.installer.data]`) present in the toml and
absent from `recipe.rs` for four pin generations with `toml_parity` GREEN, because
the pinned loader dropped them before the comparison ran; and a schema header
that had to be **written by hand**, which is what a file with no writer looks
like. Both are impossible against an emission: there is one declaration, the file
is its projection, and a group at a path the reader would drop can no longer be
emitted OR read.

**What `toml_parity` becomes.** Not a parity test — three properties: the
committed bytes ARE the emission (byte for byte, so it fails for ANY reason they
differ); the file is INERT (an edit is visible to a reader and invisible to the
recipe, with the perturbation asserted visible first); and a key no reader has a
field for is REFUSED BY NAME rather than dropped. The third is lane AF's defect
put back and seen red.

### What writing nine recipes taught about the model

1. **The model fits everything it was pointed at.** Nine repos, and not one
   resisted `variant → features → settings/sub-settings → typ`. A pure library
   with a backend axis (draupnir), a repo with ONE atom and four fictions
   (modgunn), a repo whose seven rows are compile-only surfaces that exist so a
   test-gating feature does not rot (Skidbladnir), a graph root that must embed
   nothing (skade) — all four are the same four levels with different answers.
2. **The honest declaration is usually SMALLER than the toml it replaces, and the
   difference is prose.** gunnar 2680 → 546 lines, modgunn 268 → 67, korp (still
   to do) 1330 → ~148. Prose is not data and cannot be emitted; it moved to doc
   comments and to each repo's `.nornir/build-thing-design.md`, verbatim. A
   comment typed into an emitted file is deleted by the next `emit` without
   anybody noticing, which is the one thing every emitted file's header must say.
3. **`allow_unbuilt` is where a hand-written recipe hides its debt, and prose is
   not a check.** Every repo but two had features nobody built: modgunn six,
   Skidbladnir twelve, korp-collectors eleven, skade six, ordning two, draupnir
   four. Most were *already documented* — in comments, which no gate can read.
   `FeatureAxis::gate()` over the declaration turns each into a check, and the
   measurement that says why it matters is modgunn's: compiled one at a time on
   2026-08-01, **two of six were actually broken**. 33 %, not a theoretical risk.
4. **An `offers` space that is absent makes the gate VACUOUS**, and edda's own doc
   already said so from znippy-zoomies (nine features, zero built, `gate()` →
   `Ok(())`). Declaring `offers` is what gives the gate a question with an answer.
   Where a repo's `offers` deliberately covers less than its manifest — skade's
   and znippy's do — the recipe now says so in words, because an omission that
   looks like an oversight is the thing a recipe must never leave behind.
5. **The emission finds binaries nobody declared.** Three, in three repos:
   korp-collectors' `korp-chain-init` (the KVM guest's PID 1 for the a–e chain
   drill), Skidbladnir's `skidbladnir-scenario`, and znippy-zoomies' five bench
   bins. All are instruments, all now carry a reason in `allow_unbuilt_bins`.
6. **A group that equals its default is not written down, and that is right.**
   `InstallerBuild::target` is `skip_serializing_if = "Target::is_default"`, and
   gunnar's declared machine IS `Target::default()` exactly — so the emission
   contains no `[build.installer.target]`. The group is still DECLARED; it simply
   changes nothing today. The recipe asserts both halves, because the omission
   looks exactly like the loss it replaces.

### The one repo that resists, and it is the CONVENTION, not the model

**nornir.** `build_thing::code::recipe_crate` finds a recipe by PACKAGE NAME —
exactly `<repo>-build-thing` — and `code::take` builds it with
`cargo build -p <package> --lib --release --target wasm32-unknown-unknown`. The
repo is `nornir`, so its recipe crate would have to be named
`nornir-build-thing`: **the engine's own crate**, which nornir already depends on
twice. MEASURED in nornir's checkout with no recipe crate present:

```
$ cargo pkgid nornir-build-thing
error: specification `nornir-build-thing` is ambiguous
  nornir-build-thing@0.1.11        # registry, via published transitives
  nornir-build-thing@0.1.12        # path, ../edda/crates/nornir-build-thing
```

The forge's own build spec is **already** ambiguous there. Renaming the engine
dep inside the recipe crate compiles and leaves the spec ambiguous; naming the
crate something else wins the whole repo-local half and is invisible to the
forge; `[lib] name` hides the `.wasm` instead, because the cdylib's file name
comes from the package name.

This is a finding about the DISCOVERY CONVENTION: it keys on a name, and one
repo's name is the engine's. Two ways out, and it is the forge's call —

* accept `<repo>-recipe` as a second name (`recipe_crate` already reads each
  candidate's manifest; a few lines, still a name, but a general rule and not a
  table); or
* **find the recipe by what it IS** — the cdylib in the repo whose manifest
  depends on `nornir-build-thing` and is not `nornir-build-thing` itself. A fact
  rather than a spelling, so it survives the next collision too; it needs
  `-p <name>@<version>` at the build, which the manifest scan already knows.

Until one lands, `nornir/.nornir/build-thing-design.md` is nornir's declaration of
record and its `build-variants.toml` is the last hand-written one in the
constellation.

## The chain, at depth 2, with the closure in it (f5f, lane AK 2026-08-30)

Wave 3's product goal was reached on 2026-08-27 with a chain of depth 1 (`kernel → iso`).
With lane AF's SECOND surface declared (`tunnr-kernel` consumes
`tunnr/nordisk-kernel-toolchain : container`) the chain is depth 2 and the recursion is
exercised rather than asserted. MEASURED, from the CLI door, on the real declarations:

```
step 1/2 · kernel.toolchain → tunnr/nordisk-kernel-toolchain : container
step 2/2 · kernel → tunnr/tunnr-kernel : kernel · --opt kernel.preset=slim
           · after tunnr/nordisk-kernel-toolchain
```

and run: `chain · passed · 3/3 runa step(s)` — toolchain container **0.3 s**, kernel
**78.5 s cold** / **0.1 s BY PIN**, installer **232.3 s**. The planner needed no change:
it has been a consumer of edda's `BuildDag::derive` since `7881fd7`, and the donor's
donor falls out of that recursion.

**What the chain is FOR, stated plainly.** A donor built standalone has no consumer, so
it has no closure, so the symbols the consumer's features require are simply not in it.
The same kernel row through the two roads:

| road | requirer rows | `CONFIG_XFS_FS` | bzImage |
|---|---|---|---|
| `dwarves buildthing tunnr --variant tunnr-kernel --opt kernel.preset=slim` | 0 | absent | 2 208 768 B |
| `dwarves buildthing gunnar --variant gunnar-installer-open --build kernel --opt kernel.preset=slim` | **66 symbols · 5 features** | `=y` | 2 413 568 B |

That is why a preset can only be boot-proven THROUGH a consumer, and why the repair a
closure refusal names is the chained line and never the donor's own.

## What the kernel graph reflects today — honesty note

The canvas draws `KernelFeatureModel::derive` (edda `kernel_features.rs`): **edda's committed fragments** (re-counted 2026-08-30: `kconfig/base.config` 33, `deps-tinyconfig` 15, `hardened` 25, `nuked` 21, `profile` 4, `minimal` 1 — 99 lines, 94 distinct symbols), with edges from the **name hierarchy** (`P` parents `C` when `C` starts with `P_`), not from Kconfig `depends on`/`select`. The *choices* are true (the fragment is what the kernel is built from); the *map* is curated and its edges are guessed. Until `options("kernel")` parses real Kconfig from fetched source, the pane says so. **It does (2026-08-26)**: `kernel_scene::REFLECTS`, on the graph leaf whenever the `kärna` subgraph is unfolded, in the `kärna` node's menu, and in the canvas state as `reflects`.

## Gaps against this model (2026-08-26)

| # | model says | exists | missing | size |
|---|---|---|---|---|
| 1 | tree, three node kinds, paths | flat `OptionSpec`; `val` only as `kernel.preset` | `children`, `val`, paths; law C as path ↔ flag | M |
| 2 | impl answers the tree, dynamically | `KernelFeatureModel::derive` from bundled fragments | `options(path)` / `try_toggle` / `actions` on the trait and the wasm ABI; `fetch` host call; real Kconfig | M–L |
| 3 | typed consumption edge | **CLOSED 2026-09-18 (edda).** The edge is one value on ANY kind: `tree::ConsumptionEdge { feature, repo, variant, typ }` — the feature BESIDE the coordinate, because a requirement is a property of the feature that needs it. `[build.<kind>.edges.<feature>]` is its toml spelling and `[…kernel_donor]` is the kernel-typed one, converted in `declared_edges` so no reader learns which was written (proved verbatim on disk and in the emission: `the_kernel_donor_spelling_survives_the_general_edge_verbatim`). ONE typ refusal (`tree::wrong_typ`) serves both the load (`validate_edges_with`) and the plan (`BuildDag::derive`), naming consumer · feature · coordinate · both typs. `BuildDag::derive` walks general edges of any typ, donors first, deepest first, a ring refused by name, depth ≥ 2 live over tunnr's `toolchain` container edge. **The bijection:** an edge is consumed only while its feature is ON — `--opt kernel.toolchain=off` takes `tunnr/nordisk-kernel-toolchain` out of gunnar's plan, `=on` puts it back with the same step, line and pin, and the toggle is answered AT the edge instead of being handed to the donor's door (which judged `kernel.toolchain` as a kconfig symbol until it was). `--build X` of an edge the same line switched off is refused by name. | the iso/installer half of the old row stands: a live iso still has no kernel edge of its own on every repo (host vmlinuz / `KORP_KERNEL` remain the fallback where no donor is declared). dwarves needs NO code change — `plan_build_steps` / `run_build_chain` / ⚒'s `BYGG <kant>` are typ-general already — but `build_steps::route_opts` (today only its own test's caller) does not drop an edge's on/off word, so a caller that ever builds a raw donor line from it would re-introduce the bug edda just fixed; migrate `edges_of` → `consumption_edges` when convenient. | — |
| 4 | `--build X`, DAG via runa | **the chaining landed 2026-08-27 on BOTH doors**: edda `BuildDag::derive` is the one ordering (donors first, deepest first, a ring refused by name); dwarves `plan_build_steps` is its consumer (`7881fd7`) and `run_build_chain` runs the plan as a runa pipeline, one stage per step; ⚒'s `BYGG <kant>` walks that same road through `run_build_chain_watched` (`0f8ea2b`) and draws the runner's own five states; the two doors' plans are compared verbatim by `robot_two_doors::the_chain_the_pane_plans_is_the_chain_the_prompt_plans`. The rule *"never builds the donor's kernel"* is RETIRED. | no chained build has been RUN end to end yet — blocker B1: `runner.rs:217` spawns cargo in the host's environment and oden has no musl std, so the cargo kind needs its toolchain container first (W2). No cold/cached times exist. | L |
| 5 | presets as `val` anywhere, pre-built in the ledger | four presets on `kernel.preset`; cache in `tunnr/.kernel-out` | root preset; pin lookup in the warehouse/ledger | S–M |
| 6 | kernel = node in the settings tree | **landed 2026-08-26**: the `kärna` node of the ⚒ build graph (`build_graph.rs`), the kconfig picture its unfolded subgraph; the leaf and the presence overlay are gone (facett's `NavState.absent` stays in facett as a generic capability, nothing in ⚒ drives it) | — | S |
| 7 | typ bears verbs | **landed 2026-08-26**: edda `verbs.rs` (`actions_for`, over wasm as `actions(kind)`), consumed by ⚒ through `Forge::actions`; the local half (`run.where` = container \| vm, surfaces, pane) in dwarves `actions.rs`; buildinfo always, verbs by typ, the RUN receipt beside the build receipt | `dwarves run <coordinate>` still unverified; the RUN receipt is on the pane, not yet in the ledger; an exe in a vm is offered and refused by name | S |
| 8 | korp lawful | toml passes the set laws | no `korp-build-thing` crate, no gate, no kernel edge | M |
| 9 | ⚒ reads the code, not the table | **landed 2026-08-26**: `dwarves::build_thing::code` — one loader, two doors (⚒ and `dwarves buildthing`); the recipe crate built to wasm32 INSIDE the toolchain container, cached by source digest, loaded through `PluginHandle`; the road on the answer (`kod: … (wasm)` \| `deklarationen (toml) — kodvägen VÄGRAD: …`); `options` \| `try_toggle` \| `actions` over wasm and the narrow `fetch(coordinate)` (declarations only) | no repo ships a cdylib recipe crate yet: korp has none, gunnar's is an rlib without `export_build_thing!` — both refused by name; `fetch` of sources/artifacts through a container | M |
| 10 | kernel column in the matrix (gunnar-kernel) | five columns | a constellation decision — Rickard's | — |

Order: **9 → 1 → 6 → 2 → 3 → 4**; 5, 7, 8 are free-standing.

## Tests (inject-assert, LAW 1)

- `nornir_build_thing::laws::assert_bijective` on every impl (holger, gunnar both mirrors, dwarves' `TomlBuildThing`, knut) — law C over paths once the tree lands.
- ⚒: `robot_five_leaf_body`, `robot_kernel_in_build` (four recipe shapes: nothing / library / donor / own kernel), `robot_build_scene` (kind → pane).
- Kernel: `clicking_a_preset_in_build_thing_moves_what_the_build_would_run`; the pin proof at the installer door (fragment-sha vs chosen merge).

## Source map

- the trait, the wasm ABI, the section → edda `crates/nornir-build-thing/src/plugin.rs`
- options, params, presets, decision fragment → edda `options.rs`, `params.rs`, `kernel.rs`, `kernel_features.rs`
- the donor edge and the ONE reader → edda `installer.rs` (`InstallerBuild.kernel_donor`), `kernel::resolve_built_kernel`
- ⚒ → `dwarves-gui/src/build_thing_scene.rs`, `build_scene.rs`, `build_forge.rs`
- the CLI door → `crates/dwarves` `buildthing` (`resolve_kernel_cli`, `installer_kernel`)
- the laws → edda `laws.rs`
- the requirement closure, the sources and the surfaces → edda `requires.rs`; the Kconfig surface, the contract and the why-index → edda `kernel_requires.rs`

See also: [buildthing-guide.md](buildthing-guide.md), [buildthing-installer.md](buildthing-installer.md), [build-pipeline-dag.md](build-pipeline-dag.md).

## Which recipe reader this repo uses, and why (lane AJ, 2026-08-30)

**Reader:** `nornir-build-thing` **0.1.12**, PATH (root, `xtask`, `dwarves-gui`,
`crates/nornir-warehouse-embed`).

**Why:** dwarves IS the forge — the reader that opens EVERY repo's recipe, and therefore
the one whose pin decides what the constellation can declare. It had been the
floating `"0.1"` (`^0.1.0`) in `xtask` and `dwarves-gui` and `0.1.0` in
`nornir-warehouse-embed`. See f5d above.

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`.