The build-thing — guide
Class:
guide· audience: a techie declaring or driving a build variant. The what is inbuild-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:
- Declare the goals as data — a
[build]table innornir.tomlor a standalone<repo>/.nornir/build-variants.toml. - See the space —
nornir build --list-variantsprints every buildable target this repo offers and the exact cargo line each expands to. - Build one —
nornir build --variant <name>validates the choice against the offered feature space, materializes the argv, and runscargo build. - 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:
- a feature the repo never
offers→feature '<f>' is not offered by this repo (offered: …); - two features from one
exclusivegroup →features [..] are mutually exclusive; - a
--binmissing itsrequiredfeatures →bin '<b>' requires feature '<f>' … (Cargo required-features).
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.
- 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 from1(failed or refused),2(a usage error) and124(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.
# 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:
- 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_socketbinding0.0.0.0:68. - declare it there, with the measured sentence as the reason: the option's own
kernel_requires, the group that owns the fact (target.*,data.*), orappliance_boot_requirements()when EVERY appliance needs it whatever it declares. If your pin cannot SEEkernel_requires(OptionSpecisdeny_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. - 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.
- 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_UNIXis 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:
head_class = "declared"— a fragment line (afeaturerequirement or apreset-onlyrow) 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.configalready carried. It is the donor preset's shape. Change the preset, not a declaration.head_class = "kconfig-default"— an unconditionaldefault yon a promptless symbol. Kconfig gives it to everybody; there is nothing to withdraw.head_class = "arch"— decided byarch =and nothing else.headslists EVERY head that can reach it. Onedeclared: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:
- the requirement type —
symbol/tool = value, because, with avalidate()that refuses a typo at declaration time (toolchain_requires::ToolReq); - the union type —
X → Vec<(value, Requirer)>withrequire,union,features,conflicts(requirer vs requirer),contradictions(requirer vs what the donor actually has) andsummary(ToolRequirements). TheRequirertype is SHARED: a requirer is a requirer whatever surface it speaks to; - the enum arms —
requires::Requirement::X(with itstyp()) andrequires::SurfaceClosure::X(withunion,conflicts,summaryand anas_x()accessor); - the source — a
RequirementSourcethat reads the consumer's declaration and places rows on the edge of that typ, added todefault_sources(requires::ToolchainTools); - the donor's exposure and refusal —
provides…()+apply_…_requirementson the donor's own type, refusing BY NAME with symbol/tool · consumer · feature · reason (ContainerBuild::apply_tool_requirements); - the inverted index —
…WhyIndexwith afeatureclass, an "asked-by-nobody" class and a…_is_partitionedlaw, 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=fatmoves the medium AND the donor kernel (the closure carriesCONFIG_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
targetwould read as "there is no such knob". If you add a group, either give it val nodes (tree::data_nodesis the pattern) or make--list-optionsname 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.