Gunnar

nordisk/edda

.nornir/build-guide.md

# The build-thing — guide

> **Class:** `guide` · audience: a techie declaring or driving a build variant.
> The *what* is in [`build-design.md`](build-design.md). This file is the CLI + worked examples.

## f0 — declare a goal, build it, keep the receipt (the whole workflow)

Four moves take a product from "remembered cargo incantations" to a validated,
attested build:

1. **Declare the goals as data** — a `[build]` table in `nornir.toml` or a
   standalone `<repo>/.nornir/build-variants.toml`.
2. **See the space** — `nornir build --list-variants` prints every buildable
   target this repo offers and the exact cargo line each expands to.
3. **Build one** — `nornir build --variant <name>` validates the choice against
   the offered feature space, materializes the argv, and runs `cargo build`.
4. **Attest it** — add `--attest` (or `--seal`) to capture a content-addressed
   provenance record beside the build.

## f1 — declaring variants

The set is one `[build]` table: the feature space the repo `offers`, `exclusive`
groups, per-bin `required` features, and one `[[build.variant]]` per goal.
holger's real products, verbatim:

```toml
# nornir.toml  (or  <repo>/.nornir/build-variants.toml)
[build]
offers    = ["gui", "served", "testmatrix"]   # the feature universe this repo allows
exclusive = [["gui", "lean"]]                  # at most one of these per variant

[build.required]
"holger-ui" = ["gui"]                          # mirrors Cargo required-features

[[build.variant]]
name = "server"
package = "holger-server-cli"
bin = "holger-server"
profile = "release"

[[build.variant]]
name = "gui"
package = "holger-ui"
bin = "holger-ui"
features = ["gui"]
profile = "release"
# target = "x86_64-unknown-linux-musl"   # optional --target
# cfg = "holger_dist"                     # optional, threaded via RUSTFLAGS (not argv)
```

Adding a variant is adding a table — **no code change**. `profile` accepts
`dev`/`debug`/`""` (the flagless cargo default), `release`, or any custom profile
name. Invalid combinations are rejected up front with the reason.

## f1 — worked CLI examples

```bash
nornir build --list-variants          # each variant → its materialized cargo line
nornir build --variant server         # validate + materialize + run cargo build
nornir build --variant gui --dry-run  # validate + print the argv, build nothing
nornir build --json                   # the build-thing SURFACE as state_json (viz/MCP model)
nornir build --variant full --repo /path/to/holger   # drive another repo's variants
```

`--list-variants` on the holger set prints, e.g.:

```
declared build variants (/path/to/holger):
  server       cargo build -p holger-server-cli --bin holger-server --profile release
  gui          cargo build -p holger-ui --bin holger-ui --features gui --profile release
```

When a variant is refused, the message names why:

- a feature the repo never `offers` → `feature '<f>' is not offered by this repo (offered: …)`;
- two features from one `exclusive` group → `features [..] are mutually exclusive`;
- a `--bin` missing its `required` features → `bin '<b>' requires feature '<f>' … (Cargo required-features)`.

## f3 — building with provenance

```bash
# Build + capture an attestation (variant, inputs, toolchain, host, artifact hashes):
nornir build --variant full --attest

# …plus a znippy detached-CMS seal (needs a nornir built with --features build-provenance):
nornir build --variant full --seal

# Choose where the attestation JSON lands (default: <repo>/.nornir/build-provenance/<variant>-<ts>.json):
nornir build --variant full --attest --out ./full.attestation.json
```

`--attest` writes the attestation JSON, records it to the warehouse
`build_provenance` ledger first (best-effort — a missing/locked warehouse never
fails the build), and prints the record id + digest prefix. On success you get:

```
✓ build-provenance <uuid> → <path>.json (digest 3f2a…, znippy-sealed)
```

Without the `build-provenance` feature, `--seal` degrades to an **honest
UNSEALED** attestation with a warning — never a faked signature. When sealed, a
content-addressed `<artifact>.build-provenance.znippy` is also dumped **beside the
binary**, so the receipt travels with it.

## Where the surface shows up

`nornir build --json` emits the same `BuildSurface` `state_json` the 🧬 viz build
tab and the MCP tool render: `"surface": "build"`, the option axes, and every
variant with its materialized command, `valid` flag + `invalid_reason`, and its
Build action (`nornir build --variant <name>`). Drive the button and you are back
at the top of this guide.

## f8 — per-site installer inputs, and the declared `wg-hub` fixture

```bash
# The cell declares its inputs (.nornir/build-variants.toml):
#   [[build.installer.params]]  name="hub_conf"  kind="text"  staged_file="hub.conf"
#   [build.installer.fixtures]  hub_conf = "wg-hub"
#
# Supply a pasted hub.conf …
dwarves buildthing gunnar --installer --variant gunnar-installer --param hub_conf=@site7-hub.conf
# … or let the forge MINT the hub (a userspace WireGuard hub: one UDP socket, no root):
dwarves buildthing gunnar --installer --variant gunnar-installer --param hub_conf=@fixture
```

`@fixture` is a word, not a file: it resolves through the fixture the cell declares
for that name (refused by name where none is declared). The hub's PUBLIC `hub.conf`
lands on the medium and on P3; its PRIVATE key is written 0600 under
`~/.local/state/nordisk-forge/wg-hub/` (`NORDISK_FORGE_STATE_DIR` overrides) and is
named in the build record's `fixture_note`. The endpoint is this box's outbound
address; `NORDISK_WG_HUB_ENDPOINT=10.0.2.2` for a QEMU slirp guest. The dwarves build
pane offers the same thing as an "använd fixtur (wg-hub)" toggle — only on parameters
whose recipe declares it.

## f9 — a medium built for ONE firmware, and what the installed disk gets

```bash
# The recipe declares the machine shape (Rust, emitted as [build.installer.target]):
#   target.firmware = "uefi"      # or "seabios"; undeclared = both
# The composer lays the medium for it, AND bakes the fact onto the installer cmdline:
#   … korp.appliance=lean korp.target.firmware=uefi …
# The installer PID 1 reads it back and lays the target disk accordingly — its serial says:
#   target.firmware = uefi (declared by the medium's recipe)
#   bios: legacy-BIOS path NOT laid — target.firmware = uefi (…): the disk boots UEFI only, by declaration
# A medium that never declared the group bakes no token and lays the BIOS path as before:
#   bios: legacy-BIOS path laid — MBR boot code → korpbios.bin at LBA … 
```

The token rides to the installed disk's `\korp\cmdline` too (`carry_site_tokens`), so
`cat /proc/cmdline` on the appliance says which firmware its disk was laid for.

When the medium's bytes ALSO ride a fixed disk (UpCloud: IDE cdrom + a virtio storage
imported from the same ISO, because the nuked kernel has no ATA), the installer refuses
that disk by name before it ranks anything — its serial says so, then picks the blank one:

```text
vdb: carries the installer medium (ISO-9660 volume "KORP_INSTALL" at sector 16) — the medium's twin — excluded
INSTALLER: disk=/dev/vda 10GiB · BIOS · GPU=none · …
```

A machine whose only fixed disk is the twin halts (`every fixed disk carries the installer
medium … the medium is never wiped`). Proof door: `gunnar-installtest installer --target
upcloud --twin target` pads the virtio face to the target's size (the failing shape).

## f11 — `/data` is xfs, and every equal disk is `/data`

`[build.installer.data] fs = "xfs"` declares it (default `fat` = byte-identical to
before): the donor kernel gets `CONFIG_XFS_FS` + CRC32C + md RAID0 under the feature
`data.fs` in `kernel.why.toml`, the medium bakes `korp.data.fs=xfs`, and the installer
lays `/data` with the pure-Rust `korp-mkfs-xfs` (no `mkfs.xfs` on the box). Rule: the
SMALLEST disk boots, the largest equal group of the rest is ONE striped xfs
(`korp-data-<i>` on each, md 1.2 superblocks), odd disks are named and left alone,
`--param`-less: `korp.data.disks=vdb,vdc` on the cmdline overrides. korp-start
assembles the set through md's sysfs door and dispatches its boot check on the
partition's magic, so a FAT `/data` from before still boots. Design + numbers:
`korp-installer/.nornir/data-filesystem.md`.

`[build.installer.data] io = "uring"` (default `safe` = byte-identical) declares the
store's WRITER ARM beside the filesystem: the donor kernel gets `CONFIG_IO_URING=y`
under the feature `data.io` in `kernel.why.toml` (tunnr's donor ships it off), the
medium bakes `korp.data.io=uring` (a site token, carried onto the installed disk like
`korp.data.fs`), and the app's PID 1 reads it off `/proc/cmdline`, probes
`io_uring_setup` once and appends through znippy-plugin-git's `UringWriter` (blob write
→ fsync → journal write → fsync as ONE linked `io_uring_enter`, the same durability
contract as `SafeWriter`'s four syscalls) — or falls back to `safe` BY NAME on a row
(`writer  safe (io_uring unavailable: ENOSYS)`). The installer itself never acts on
the token. Measured uring-vs-safe on the KVM rig: gunnar `.nornir/best-results-benchmarks.md`.

**Measured, and what ships (lane S, 2026-08-30).** One installed image, the token flipped on
its ESP, five runs per arm, 4 vCPU / 8 GB q35/KVM: the two arms are the same server at 1–8
clients (sequential push-create 172 ms vs 174, 8-parallel 1145 vs 1175, a 50 MB blob 1848 vs
1808, clone and fetch a tie, all inside a ±26 % noise floor measured on the three phases that
never touch the writer). The one row with a signal is the deepest queue tested — 200
push-creates from 64 clients — where the five run-medians do not overlap: **safe 5130–6149 ms,
uring 4433–4682 ms, 1.14× per accepted op at ±3 % run spread against safe's ±10 %.** The
power-cut door on the uring arm: **133 of 133 acked pushes present at the acked sha after
three `SIGKILL`s**, 34 sampled repositories `fsck`-clean. Kernel cost `CONFIG_IO_URING=y`:
bzImage 2 388 992 B against 2 343 936 B, **+45 056 B (+1.9 %)**, `CONFIG_IO_WQ=y` derived.

So **gunnar's two `[[build.installer]]` rows declare `io = "uring"`** from 2026-08-30 — the
same way lane N deployed `fs = "xfs"`, in the consumer's rows. `DataIo::Safe` stays the TYPE
default and an absent `[build.installer.data]` group still means `safe`: korp, holger and
salvage-korp declare installer variants without a `data` group, and moving the type default
would silently bake `korp.data.io=uring` into their media and pull `CONFIG_IO_URING=y` into
their donor kernels for a store they do not run. A default that changes other people's
mediums is not a default, it is a surprise; the shipping decision belongs in the row of the
repo that measured it.

## f10 — reinstalling: `/data` survives, `--param reinstall=wipe` is the opt-out

Re-running an installer medium on a disk it already laid is an **upgrade** by default
(Rickard, 2026-08-29: *data borde överleva*). The installer reads the target's GPT before
it writes a byte (`korp-installer::installed::classify` — the same partition names the
boot side mounts by, `korp-data` / `EFI System`, the ONE reader) and then:

| the target carries | default (`korp.reinstall` absent) | `--param reinstall=wipe` |
|---|---|---|
| nothing (blank) | laid fresh | laid fresh |
| a korp install | **upgraded**: `/data` (p3) kept byte for byte — same span, same GUID, no format, no fsck; GPT untouched; ESP contents + rootfs stream + MBR boot code rewritten | laid fresh, `/data WIPED` said by name |
| a foreign table with filesystems (ext4, xfs, ntfs, …) | **refused by name** (`FAILED target: … looks like someone's data; pass korp.reinstall=wipe`) | laid fresh |
| a foreign table with no filesystem | laid fresh | laid fresh |

The medium's serial says which and why:

```text
INSTALLER: disk=/dev/vda 225GiB (korp-installed (/data (p3, 225GiB, 3.6MiB used))) · BIOS · GPU=none · …
INSTALLER: target: korp-installed — /data (p3, 225GiB, 3.6MiB used) KEPT; GPT untouched; ESP + rootfs rewritten (mode upgrade; korp.reinstall=wipe lays it fresh)
INSTALLER: partition GPT kept as found: p1=ESP(64MiB) p2=rootfs(…) p3=korp-data(225GiB) — OK
INSTALLER: data /data (p3, …) kept as found — not formatted, not fsck'd, identity not re-minted; staged declarations NOT written (wg/hub.conf) — korp.reinstall=wipe re-stages them
INSTALLER: esp wrote vmlinuz + initrd + start-app + pinned app.blake3 + EFI boot entry (mode upgrade) — OK
```

The ESP keeps the record: `korp/install` (`mode=upgrade`, the kept span) and the previous
install's narration as `BOOTLOG.OLD`. To expose the opt-out a product declares the shared
param row `nornir_build_thing::installer::reinstall_param_spec()` (gunnar does; it lands
as `korp.reinstall=wipe` only when supplied — an undeclared or unsupplied medium keeps
`/data` by construction). `korp.reinstall` is plumbing: it never rides to the installed
disk's cmdline. Design + proof: `korp-installer/.nornir/data-survives-upgrade.md`.

## f9 — building a medium FOR a machine: `target` and the cloud presets

```toml
# .nornir/build-variants.toml — the EMITTED spelling (the impl BuildThing sets it in Rust)
[[build.installer]]
name = "gunnar-installer-upcloud"
# … kernel_donor, params, fixtures …
[build.installer.target]
preset = "upcloud"          # ONE writer: a preset OR the fields below, never both

# — or spell the shape yourself —
[build.installer.target]
firmware = "seabios"        # uefi | seabios | both   (default: both)
consoles = ["vga"]          # in console= order; the LAST is /dev/console  (default: [vga, serial])
uarts    = ["com2", "com3"] # the COM ports that REALLY exist; `serial` = the first of them  (default: [com1])
buses    = ["ide-cdrom", "virtio-blk"]   # default
nic      = "virtio"         # virtio | e1000
nics     = 2                # default 1
cpu      = "epyc"           # generic | epyc  → CPU_SUP_AMD
```

An undeclared `[build.installer.target]` is today's medium and emits nothing. Rules the
forge refuses by name: an empty `consoles` (a boot nobody can see), `serial` with no
`uarts` (the UpCloud dark boot: `console=ttyS0` on a host with nothing at 0x3f8), an
unknown preset (the roster is listed), a preset the group cannot spell yet (`aws-nitro`:
NVMe/ENA have no `target` word until the kernel recipe learns their Kconfig closure).

```bash
# The roster, as data — every preset, its derived status and its derived target group
dwarves buildthing gunnar --info --format toml      # …[[target-preset]] rows from presets_toml()
#   name = "upcloud"  status = "measured"  cloud = "UpCloud Cloud Servers"
#   [target-preset.target]  firmware = "seabios"  consoles = ["vga"]  uarts = ["com2","com3"] …
#   name = "aws-nitro"  status = "needs-real-cloud"  needs_real_cloud = "ENA has no QEMU model…"

# Prove the medium under a preset's QEMU shape (the door renders -machine/-cpu/-serial slots/NICs from it)
gunnar-installtest installer --firmware seabios --serial none --iso P     # today's spelling of upcloud
# emits appliance-boot/upcloud rows: install_ok (B1) · serves (B2) · ready (B3, after a power cut)
```

What a preset decides, in one line each: the El-Torito entries the composer lays
(`firmware`); the `console=` order on the installer AND the installed disk (`consoles`,
spelled against the real `uarts`); the qemu machine the proof boots (`qemu_shape()`); and
the kernel's Kconfig rows (`kconfig_needs()` — `SERIAL_8250` only with a serial console,
`VT`+`VGA_CONSOLE` with vga, `ATA` for an IDE cdrom, `VIRTIO_*` for virtio, `CPU_SUP_AMD`
for EPYC). The test matrix shows one `appliance-boot/<preset>` row per preset: a KVM hole
until the installtest fills it, or `— needs real cloud: <why>` for a shape QEMU cannot mimic.

## Reading a boot door's row: PASS · FAIL · REFUSED · TIMEOUT (lane AQ, 2026-08-30)

```
  [PASS]    B1 install       6138 ms  INSTALL-OK on the UART, then the installer rebooted; …
  [FAIL]    B2 serve         5611 ms  nothing answered on :2222 within the budget
  [REFUSED] B1 install       3753 ms  the installer ended the boot by name: INSTALLER: FAILED
                                      hwdetect: no usable target disk — every fixed disk carries
                                      the installer medium (vda: … the medium's twin — excluded)
  [TIMEOUT] B1 install     420235 ms  NO VERDICT: the budget ran out with the guest still running
                                      and nothing terminal on the console (0 B of serial). Nothing
                                      was measured — this is not a failed install, it is an
                                      unmeasured one. ZERO serial bytes: the medium never handed
                                      over — firmware, not appliance.
```

* **REFUSED** = the GUEST ended it and said why. The row is its sentence, verbatim.
  Read the words, not the row: a refusal is very often the appliance being right.
* **TIMEOUT** = nothing was measured. Not a slow failure — an absence of evidence.
  Its own exit code, `3`, distinct from `1` (failed or refused), `2` (a usage
  error) and `124` (`timeout(1)` killing the door from outside).
* A door's summary says which happened: `ALL PASS` / `NOT PROVEN — TIMED OUT` /
  `NOT PROVEN — REFUSED BY THE GUEST` / `NOT PROVEN`.

```bash
# A machine this medium cannot install on is refused from the DECLARATIONS, in 0.03 s:
gunnar-installtest installer --target gce-virtio --iso P
#   installer door refused: --target gce-virtio cannot be installed by this medium …
#     · THE BUS: `gce-virtio` boots its disk on `virtio-scsi`, and this medium's recipe
#       declares buses = [ide-cdrom, virtio-blk] …
gunnar-installtest installer --target ovh-public-cloud --iso P
#     · THE ATTACHMENT: takes an imported qcow2 (no ISO attach) … pure El Torito ISO
#       (00 00 at LBA 0[510..512], not 55 AA), which SeaBIOS cannot boot …

# The red switch: boot it anyway and watch the guest refuse (this is how the console
# gate is SEEN firing — 420.10 s before it existed, 3.81 s after).
GUNNAR_INSTALLTEST_BOOT_ANYWAY=1 gunnar-installtest installer --target gce-virtio --iso P
```

## Making a medium bootable off a DISK — `medium_boot = "hybrid"` (lane AS, 2026-08-30)

Both refusals above are about a machine the medium cannot reach. `THE ATTACHMENT`
has a cure, and it is one word in the recipe:

```rust
// <repo>-build-thing/src/recipe.rs, on the [[build.installer]] row
medium_boot: MediumBoot::Hybrid,     // needs target.firmware = seabios | both
```

then re-emit (`cargo run -p <repo>-build-thing -- emit` — **never** hand-edit the
toml) and compose. The composer says what it laid:

```
[korp-installer] gunnar-installer-open: medium_boot = hybrid — 440 B of MBR boot code
  at LBA 0 chainloading 6 sectors of the stage from 512-byte LBA 34; this .iso is
  ALSO a bootable disk
 · iso '…': … HYBRID: 55 AA boot sector at LBA 0 chainloading a disk-flavoured copy
   of the stage at 512-byte LBA 34 (6 sectors, sector_shift 9, kernel LBA 65712,
   initrd LBA 70332)
```

Check the artifact without booting anything:

```bash
# 55 aa = there is a boot sector; 00 00 = a pure El Torito ISO, dead on any disk
dd if=medium.iso bs=1 skip=510 count=2 2>/dev/null | od -An -tx1
```

It costs the medium **nothing** (both the sector and the second stage copy live in
the ISO's reserved 32 KiB system area) and it changes no other boot path: the
cdrom entry and the EFI section are byte-identical.

**When it is refused:** `medium_boot = "hybrid"` on a `target.firmware = "uefi"`
row. A `55 AA` that chainloads nothing is worse than no boot sector, because
firmware stops looking the moment it finds one.

**What it unlocks:** every platform that IMPORTS a disk image instead of
attaching a cd — 9 of the roster's 13 presets — plus USB sticks. Measured:
`--target ovh-public-cloud` went from `[TIMEOUT] 420 008 ms` with a zero-byte
serial log to `B1 1250 · B2 3799 · B3 2019 ms — ALL PASS`.

## Reading `kernel.why.toml` — who wanted each symbol (2026-08-29)

Beside every built kernel's `.config` (`<donor>/.kernel-out/<kernel>/kernel.why.toml`)
sits the inverted index. It is populated by a CHAINED build — the consumer's closure
rides the kernel step (`--build kernel`) and lands on the donor's row before the
build; a kernel served by pin to a consumer gets the index refreshed from that
consumer's closure. A bare donor build has no consumer and says `preset-only` for
every fragment row — that is not a defect, nobody asked yet.

```
$ dwarves buildthing gunnar --variant gunnar-installer-open --opt dhcp=on --opt wireguard=off --build kernel
   pin check: the chosen kernel exists (preset nuked) — building nothing
[dwarves] installer 'gunnar-installer-open': requires of the kernel edge — 34 symbol(s) required by 1 feature(s) (gunnar/gunnar-installer-open: appliance-boot)
$ sed -n '/\[summary\]/,/^$/p' ~/git/tunnr/.kernel-out/tunnr-kernel/kernel.why.toml
[summary]
feature = 34            # a consumer's feature asked — requirers say who and why
preset_only = 35        # a fragment set it, no feature asked → a candidate to nuke
kconfig_derived = 751   # in the built .config, named by nobody (base + select/depends)
```

`class = "feature"` rows carry `requirers = [{ consumer, feature, reason }]`
(`CONFIG_SMP` ← `gunnar/gunnar-installer-open` · `appliance-boot`). A requirement
the built `.config` does not honour is `missing = [...]` under `[summary]` and the
reader refuses the kernel by symbol.

### `preset-only` means UNDECLARED, not unused (2026-08-30, lane AE)

The numbers above are from the day only three features spoke, and the sentence
"`CONFIG_TUN=y` with `wireguard=off` is a nuke candidate" is only HALF right: TUN
was wanted, by the WireGuard data plane, and nobody had written it down. Read a
`preset-only` row as **a question**, never as an answer:

1. **who opens this?** Grep for the syscall, the device node, the address family —
   not for the symbol. `CONFIG_TUN` → `/dev/net/tun` → `wg_appliance_core`.
   `CONFIG_INET` → `client_socket` binding `0.0.0.0:68`.
2. **declare it there**, with the measured sentence as the reason: the option's own
   `kernel_requires`, the group that owns the fact (`target.*`, `data.*`), or
   `appliance_boot_requirements()` when EVERY appliance needs it whatever it
   declares. If your pin cannot SEE `kernel_requires` (`OptionSpec` is
   `deny_unknown_fields`, so an old loader refuses the whole recipe on the key),
   fix the pin — a path dep on the sibling edda checkout with the version kept
   is the road gunnar took on 2026-08-30. Parking the rows in a cell-level table
   beside the options works for the forge and leaves them invisible to the repo
   that owns them, which is not a place to stay.
3. **prove it both ways.** Turn the feature off — the requirer must disappear from
   the index. Turn it on — the symbol must come back BY CLOSURE, not by a fragment
   line. A declaration that cannot fail is not a declaration.
4. **only then** consider nuking it — and if nobody can own it, write down WHERE THE
   LINE IS why nobody can. That is a legitimate third answer, and `CONFIG_UNIX` is
   its worked example.

### The third class now has a CHAIN (2026-08-30, lane AP)

`kconfig_derived` used to be the shrug at the bottom of the file — **764 of 849 rows on the
shipped `tunnr-kernel`, 93 % of the live symbols**, none of them attributable to anything.
Those rows now carry four fields:

```toml
[[symbol]]
name = "CONFIG_INPUT"
value = "y"
class = "kconfig-derived"
head = "CONFIG_VT"
head_class = "declared"
chain = "CONFIG_VT [declared] --select--> CONFIG_INPUT"
heads = ["declared:CONFIG_VT", "kconfig-default:CONFIG_INPUT"]
```

Read them like this:

* **`head_class = "declared"`** — a fragment line (a `feature` requirement or a
  `preset-only` row) pulled it in. That is the knob. If the symbol costs bytes you do not
  want, the conversation is about the head, not about the symbol.
* **`head_class = "base-config"`** — a PROMPT symbol the starting `.config` already carried.
  It is the donor preset's shape. Change the preset, not a declaration.
* **`head_class = "kconfig-default"`** — an unconditional `default y` on a promptless
  symbol. Kconfig gives it to everybody; there is nothing to withdraw.
* **`head_class = "arch"`** — decided by `arch =` and nothing else.
* **`heads`** lists EVERY head that can reach it. **One `declared:` head and nothing else is
  the strong case**: withdraw that line and the symbol is provably gone.
* **`unexplained = "…"`** — no chain, and the sentence says why. 43 of 765 on the shipped
  kernel, and 41 of those are toolchain probes (`default $(cc-option,…)`): decided by
  running the compiler, so no declaration can ever reach them.

An empty `chain` with no `unexplained` is neither of those: it means the solver did not run
here (it needs the extracted kernel source and `kernel.base.config`, so a fragment-only
index or a kernel built before 2026-08-30 has none).

### Proving a chain rather than admiring it

```
NORNIR_KCONFIG_SRC=~/git/tunnr/.kernel-out/tunnr-kernel/work/src/linux-6.12.104 NORNIR_KCONFIG_FRAGMENT=~/git/tunnr/.kernel-out/tunnr-kernel/work/fragment.config NORNIR_KCONFIG_HEADS="VT·SERIAL_8250"   cargo test -p nornir-build-thing --test kconfig_solver_live -- --ignored --nocapture
```

It withdraws each named declaration, recomposes with the kernel's own
`scripts/kconfig/conf`, and prints per head:

```
SERIAL_8250   sole=  1 gone=  1   vanished= 18 linked= 18   declarations-that-fell={"SERIAL_8250_CONSOLE", "SERIAL_EARLYCON"}
VT            sole=  1 gone=  1   vanished=  9 linked=  9   declarations-that-fell={"VGA_CONSOLE", "VT_CONSOLE"}
```

`sole` is what the solver claims is that head's alone; `gone` is what really left. **They
must be equal.** `declarations-that-fell` is the other half of the story and is worth
reading on its own: those are OTHER declarations that could not survive this one going
away — lane AN's canary bug, seen before a container starts.

Drop the `NORNIR_KCONFIG_HEADS` filter and it runs all 55 heads (~35 min, 55 recomposes).
Measured 2026-08-30: **(a) 112/112 sole-head claims held · (b) 569/570 reachable · 9 of 55
withdrawals were VACUOUS** (another declaration selects the head straight back — those are
counted apart, because scoring them either way would be a lie).

### The fragment that contradicts itself is refused before the container starts

A `=n` that legally narrows a parent can leave a `=y` on its child dangling. Since
2026-08-30 the kernel build asks the Kconfig graph first, and refuses by name:

```
kernel 'tunnr-kernel': the merged fragment contradicts itself in 1 place(s) — refused BEFORE
the container runs:
  - CONFIG_SERIAL_8250_CONSOLE=y cannot hold: it `depends on SERIAL_8250 = y`, and this same
    merged fragment says # CONFIG_SERIAL_8250 is not set. … Narrow the child with its parent,
    or stop declaring it
```

It needs the donor's Kconfig tree, which arrives inside the tarball — so it bites on every
RE-build and every ISO compose, and `KERNEL-CONFIG-REFUSED` stays the backstop for a cold
first build.

### Adding a SURFACE (2026-08-30, lane AF — the toolchain image is the worked example)

A surface is what a donor EXPOSES to be required of. There are two today: a
`typ = kernel` donor exposes Kconfig symbols, a `typ = container` donor exposes
the TOOLS its recipe carries. Adding a third is six named things and no engine
change:

1. **the requirement type** — `symbol/tool = value, because`, with a
   `validate()` that refuses a typo at declaration time
   (`toolchain_requires::ToolReq`);
2. **the union type** — `X → Vec<(value, Requirer)>` with `require`, `union`,
   `features`, `conflicts` (requirer vs requirer), `contradictions` (requirer vs
   what the donor actually has) and `summary` (`ToolRequirements`). The
   `Requirer` type is SHARED: a requirer is a requirer whatever surface it
   speaks to;
3. **the enum arms** — `requires::Requirement::X` (with its `typ()`) and
   `requires::SurfaceClosure::X` (with `union`, `conflicts`, `summary` and an
   `as_x()` accessor);
4. **the source** — a `RequirementSource` that reads the consumer's declaration
   and places rows on the edge of that typ, added to `default_sources`
   (`requires::ToolchainTools`);
5. **the donor's exposure and refusal** — `provides…()` + `apply_…_requirements`
   on the donor's own type, refusing BY NAME with symbol/tool · consumer ·
   feature · reason (`ContainerBuild::apply_tool_requirements`);
6. **the inverted index** — `…WhyIndex` with a `feature` class, an
   "asked-by-nobody" class and a `…_is_partitioned` law, emitted beside the
   artifact (`ToolWhyIndex` → `toolchain.why.toml`).

Then add the arm to `build_dag::step_pin_for` if the surface has a pin, and the
recursion, the union, the cycle refusal and the reached-twice join are already
yours. **If you find yourself writing `if typ == Kernel` anywhere else, that is
the bug** — three such places existed before this lane and are named in
`build-design.md`.

### Typing a group, and reading the listing (2026-08-30, lane AF)

`--list-options` prints the variant's whole flag surface, and a declared GROUP is
part of it:

```
  --opt data.fs=fat
  --opt data.fs=xfs
  --opt data.io=safe
  --opt data.io=uring
  group  data   fs=xfs io=uring  — spellable: --opt data.fs=fat|xfs · --opt data.io=safe|uring
  group  target RECIPE-ONLY: firmware=both consoles=[vga serial] … — the machine's shape is a
         set of lists, not a one-of, so it has no --opt spelling; change it in
         [build.installer.target]
```

Two rules follow from that block:

* **a group you can type is typed** — `--opt data.fs=fat` moves the medium AND
  the donor kernel (the closure carries `CONFIG_XFS_FS`, `CONFIG_IO_URING`), so
  there is never a reason to edit a row in a scratch worktree to change one;
* **a group you cannot type says so** — a listing that merely omitted `target`
  would read as "there is no such knob". If you add a group, either give it val
  nodes (`tree::data_nodes` is the pattern) or make `--list-options` name it as
  recipe-only. Omitting it is the one option that is not available.

And `--opt kernel.preset=` works on every door now, including the ISO-compose
one. It used to answer `an option is on or off` there, because the door handed
the whole line to `parse_cli_options`; the line is split by the tree instead
(`SettingsTree::split_opts`), which knows a val from a toggle from a donor's
pair.

Today, with the surface declared:

```
$ dwarves buildthing gunnar --variant gunnar-installer-open --opt dhcp=on --opt wireguard=off --build kernel
   requires: 65 symbol(s) required by 4 feature(s) (gunnar/gunnar-installer-open: appliance-boot · data.fs · data.io · dhcp)
[summary]
feature = 65
preset_only = 19        # 18 donor vetoes (`is not set`) + CONFIG_UNIX, each with its reason
kconfig_derived = 764
```

and with `--opt wireguard=on` the count is 66, `CONFIG_TUN` is `class = "feature"`
with `feature = "wireguard"`, and the bzImage is 2 376 704 B against 2 364 416 B —
the symbol's own cost, paid only by a medium that asked for it.