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

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

# 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

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:

f3 — building with provenance

# 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

# 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

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

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

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

# .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).

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

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

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

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

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:

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.