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

nordisk/edda

.nornir/build-design.md

The build-thing — design

Class: design · Function axis: f0 (the build-thing as one system) + f1 variants config · f2 generator backends · f3 provenance attestation · f4 the surface + CLI · f5 container/VM target kinds + provenance-over-produced-artifact · f6 TEST-BOOT (spin a built target up as a live job) · f7 the unified assemble() seam (ArtifactKind-dispatched build → one content-addressed receipt + build-info lineage). Strict: this file states only the shape that is built. Every why, every alternative, is out of scope here (the descriptive what only). Siblings: build-guide.md (how to drive it).

f0 — the build-thing (the whole)

nornir's build function turns building a product into declared data plus a content-addressed receipt. Two capabilities compose into one thing:

  1. A product declares its buildable goals — each a (package × bin × features × profile × target) point — as TOML data, not as remembered cargo incantations. nornir validates a chosen goal against the space the repo offers and materializes the exact cargo build argv.
  2. Every materialized build emits a build-provenance attestation: a portable, content-addressed record of what actually happened (the chosen variant, the resolved inputs, the toolchain, the host, and the SHA-256 of every produced artifact), folded to one canonical digest, optionally cryptographically sealed.

The engine is the dependency-light leaf crate nornir-build-thing — extracted so a leaf repo's xtask can pull it without nornir's iceberg / arrow / eframe stack (mirrors nornir-testmatrix). nornir re-exports it verbatim as nornir::build (src/build/mod.rs:24) and adds the heavy warehouse + seal ends behind features.

Reuse map (what the build subsystem uses). The leaf crate depends only on serde / toml / sha2 / uuid / chrono / cargo_metadata, and reads VCS state through the git CLI and host facts through std + /proc — no linked git/container library. nornir wraps it with the Iceberg BuildProvenanceSink (src/warehouse/build_provenance.rs) and the znippy seal (src/build/seal.rs, feature build-provenance). The generator backends (f2) are a sibling pluggable-backend family under src/warehouse/generator/.

part lives in what it is
f1 variants config variant.rs the descriptor + validator + cargo materializer
f2 generator backends src/warehouse/generator/ the pluggable local-generation backends behind one factory
f3 provenance attestation provenance.rs + seal.rs the record, digest, seal, sinks
f4 surface + CLI surface.rs + src/bin/nornir.rs the view-model + nornir build

f1 — variants config (variant.rs)

A BuildVariant (variant.rs:92) is ONE concrete build point; a BuildVariantSet (variant.rs:209) is the space of valid choices a repo offers. The set is data — the same [build] / [[build.variant]] schema parses either inline in nornir.toml (BuildConfig, variant.rs:230) or from a standalone .nornir/build-variants.toml (BuildVariantSet::discover, variant.rs:257).

field / symbol maps to cargo at
BuildVariant.package -p <package> variant.rs:97
BuildVariant.bin --bin <bin> variant.rs:99
BuildVariant.features --features a,b variant.rs:103
BuildVariant.no_default_features --no-default-features variant.rs:106
BuildVariant.profile (Profile) --profile (Dev emits nothing) variant.rs:42, 109
BuildVariant.target --target <triple> variant.rs:112
BuildVariant.cfg --cfg via RUSTFLAGS (not argv) variant.rs:115, 181

BuildVariant::cargo_args (variant.rs:130) renders the argv in a fixed, deterministic order (build -p --bin --features --no-default-features --profile --target); command_line (variant.rs:167) is its display form; build (variant.rs:176) validates then runs cargo in the repo dir, threading cfg into any inherited RUSTFLAGS.

BuildVariantSet::validate (variant.rs:296) is the space's contract — it rejects exactly three classes of "you can't build that here":

rejected rule at
a feature not in offers (when offers is non-empty) the offered feature universe variant.rs:298
two features from one exclusive group mutually-exclusive groups variant.rs:311
a --bin missing any of its required features Cargo required-features, mirrored variant.rs:322

require (variant.rs:277) resolves a name to a variant or a helpful error listing the known names; validate_all (variant.rs:341) is a config-time gate over every declared variant. An empty offers set means unconstrained.

f2 — generator backends (src/warehouse/generator/)

One Generator trait, three real implementations behind a factory, plus a test mock and an off-by-default ollama client — the pluggable, backend-agnostic way nornir produces text locally. Source: src/warehouse/generator/ (mod.rs + candle.rs + mistralrs.rs + onnx.rs + ollama.rs).

The trait is split so a caller can list/probe without generating: Backend (id, available) and Generator: Backend + Send + Sync (complete). GenRequest { prompt, system, max_tokens, temperature, stop } in, GenAnswer { text, tokens_in, tokens_out, tokens_per_s, latency_ms } out; GenAnswer::into_model_answer() slots straight into an agent_model_runs row.

The factory generator(spec) -> Box<dyn Generator> resolves a <backend>:<model> spec, each real arm #[cfg(feature = …)]-gated:

spec form backend feature gate
candle:<model> candle (pure Rust GGUF Qwen2) gen-candle
mistralrs:<model> mistral.rs (in-process) gen-mistralrs
onnx:<model> ONNX / ORT gen-onnx
ollama:<model> ollama HTTP (prior-art, off by default) gen-ollama
mock / mock:<id> mock always on

A feature-off arm returns a clear "not compiled in — rebuild with --features gen-X" error, never a silent stub; an unknown backend returns "unknown generator backend". available() runtime-probes (GGUF+tokenizer in cache; the ORT lib loadable + model files present; the ollama daemon answering /api/tags; mock always true), so a missing backend degrades gracefully. Selection priority is --gen <spec> > $NORNIR_GEN_BACKEND > --mock > the prior-art ollama caller.

f3 — provenance attestation (provenance.rs, seal.rs)

A BuildProvenance record (provenance.rs:182, schema tag PROVENANCE_SCHEMA = nornir.build-provenance/v1) captures one materialized build. BuildProvenance::capture (provenance.rs:212) records:

captured symbol at
the chosen variant, verbatim variant: BuildVariant provenance.rs:189
resolved inputs (argv, features, profile, target, cfg, VCS rev/branch/dirty, worktree digest) Inputs provenance.rs:155
toolchain (rustc -vV / cargo -vV) Toolchain::detect provenance.rs:56
host cores + total memory (available_parallelism + /proc/meminfo) HostInfo::detect provenance.rs:86
per-artifact SHA-256 + size + repo-relative path Artifact::hash_file provenance.rs:134
outcome (success / failed) Outcome provenance.rs:149

The headline call build_variant(set, name, repo_root) -> BuildOutcome (provenance.rs:379) validates, runs cargo build --message-format=json so each produced executable is captured from the compiler-artifact stream and hashed, then folds the record to an Attestation. BuildProvenance::attest (provenance.rs:276) takes SHA-256 over the canonical bytes (canonical_bytes, provenance.rs:268 — compact, recursively key-sorted JSON, so the same record folds to the same digest anywhere). Attestation (provenance.rs:306) carries the record, the digest (hex-serialized), and an optional Seal; digest_matches_record (provenance.rs:339) recomputes and confirms — tampering the record breaks the digest.

Sealing (feature build-provenance). ProvenanceSealer::seal(digest) -> Seal (provenance.rs:299) is the seam; the real sealer is ZnippySealer (src/build/seal.rs:27), a detached CMS SignedData (Ed25519 / P-256) over the 32-byte digest — content bytes never flow through the signer, so the seal travels separately. seal::verify (seal.rs:80) recomputes the digest, confirms it matches, then verifies the CMS against trusted roots. The sealed attestation is packed into a content-addressed znippy archive beside the artifact (dump_beside_artifact, seal.rs:110 → <artifact>.build-provenance.znippy).

Sinks. BuildProvenanceSink::record (sink.rs:16) is the persistence seam; dep-light built-ins NullSink and JsonFileSink (sink.rs:23, 34) append to a build_provenance.jsonl ledger, and nornir's heavy Iceberg implementation is impl BuildProvenanceSink for IcebergWarehouse (src/warehouse/build_provenance.rs:193). The chain: build → warehouse ledger → digest → seal → znippy beside artifact → verify.

f4 — the surface + CLI (surface.rs, src/bin/nornir.rs)

BuildSurface::from_set(repo, set) (surface.rs:105) projects the set into a flat, serde surface any presentation renders without knowing cargo: per VariantView (surface.rs:33) the option axes (features / profile / target), the materialized cargo_args + command_line, the up-front valid flag with an invalid_reason, and the BuildAction (surface.rs:22) the "Build" button invokes. state_json (surface.rs:121) is the shape a viz pane / MCP tool reads ("surface": "build", variant_count, valid_count, variants). The stateful BuildThing Elm component (elm.rs:1, pure Model/Msg/update core — the egui view is a facett-side follow-up) wraps this surface for an interactive UI, one VariantCard per variant with a live CardStatus (elm.rs:27).

The egui presentation is now a real facett Facet: the 🔨 Build tab is BuildTabState (src/viz/build_tab.rs:156), a facett-core Elm component (bridged via impl_facet_via_elm!) whose state_json (build_tab.rs:272) exposes the choosable rows and the container/VM builds and their test-boot jobs. It keeps the declared ContainerBuild/VmBuild descriptors verbatim (build_tab.rs:181, :183) so a test-boot builds the exact target, not a lossy projection. The surface itself now also projects those non-cargo targets: ContainerView (surface.rs:92) and VmView (surface.rs:155) are the bootable siblings of VariantView, and BuildSurface::from_set (surface.rs:237) fills containers / vms alongside variants, each counted in state_json (surface.rs:274).

f5 — container / VM target kinds + provenance over the PRODUCED artifact

Beyond a cargo BuildVariant, the leaf crate declares two image targets — a product you build once and boot, whose provenance must bind the built artifact's digest, not just the descriptor.

ContainerBuild (container.rs:162) is one declared image: a base, an ordered Vec<ImageLayer> (container.rs:97 — copy a file or stage a built from_variant binary), env/cmd/labels, tags, and an ImageFormat (container.rs:44 — OCI / Docker). It renders its own Containerfile (containerfile(), container.rs:216), the engine argv (build_args, container.rs:249), and a stable content_id (container.rs:309, SHA over canonical_bytes). VmBuild (vm.rs:116) is one declared appliance: kernel / rootfs / disk paths, mem_mb / cores / boot-params, and a VmFormat (vm.rs:47 — raw / qcow2 / …, each mapping to a QEMU machine, vm.rs:90); it renders the guest cmdline (vm.rs:183) and qemu_args (vm.rs:192). Both are re-exported from the crate root (lib.rs:60, :61).

The runner + sealer (image_build.rs). A TargetKind (image_build.rs:70) tags Container vs Vm. The two headline calls are the image-target peers of build_variant:

call at what it seals over
build_container(builder, image, repo, repo_root) image_build.rs:312 the built image's content digest (via the ImageBuilder seam), engine "bollard"
build_vm(builder, vm, repo, repo_root) image_build.rs:354 the produced kernel+rootfs/disk file hashes (via the VmImageBuilder seam), engine "prebuilt"

Each validates the descriptor, drives the build through a seam, then captures a TargetProvenance (image_build.rs:133, schema nornir.target-provenance/v1, TargetProvenance::capture image_build.rs:172) binding the produced Artifacts, and folds it to a TargetAttestation (image_build.rs:236) — canonical-bytes digest (image_build.rs:217), seal_with(&dyn ProvenanceSealer) (image_build.rs:253), digest_matches_record tamper check (image_build.rs:270). The result is a TargetBuildOutcome (image_build.rs:294). The dep-light default VmImageBuilder is PrebuiltVmBuilder (image_build.rs:390) — it hashes the kernel/rootfs/disk tunnr already built (the heavy Buildroot build runs elsewhere).

Zero-shell container builder. The real ImageBuilder is BollardImageBuilder (image_build.rs:426, feature image-build): it drives podman/Docker over the REST API via bollard — connect() (image_build.rs:444) over the unix socket, then BuildImageOptions streaming the build context — with no podman build subprocess. Tests inject a FakeImageBuilder so the whole capture→attest→seal chain runs with no daemon.

f6 — TEST-BOOT: spin a built target up as a live job (nornir/src/viz/test_boot.rs)

Every test_boot.rs:N below is a line in nornir's tree, not edda's — this document moved out of nornir in 2b19044 and the citations came with it. Line numbers verified against nornir main on 2026-08-27.

Pressing a target's test-boot button on the Build tab creates a nornir job ([crate::jobs]) and boots the built target in one of three modes (BootMode, test_boot.rs:51), selected purely from the target's type (BootTarget::mode, test_boot.rs:151). The lifecycle is bidirectional: killing the job kills the boot, and if the boot dies on its own the next TestBootState::poll (test_boot.rs:956) flips the job to BootedOk/Failed/ Killed (BootStatus, test_boot.rs:83).

mode target runner transport
Exe (test_boot.rs:51) a cargo BuildVariant binary ExeBootRunner (test_boot.rs:338) spawn the exe as a child process-group with an interactive PROMPT (stdin→child, stdout/stderr→log); kill = SIGKILL the group
Container (default) a ContainerBuild DraupnirContainerBootRunner (test_boot.rs:503) ZERO-SHELL against the podman/Docker API socket, routed THROUGH draupnir's ONE OCI engine (feature test-boot-container-bollard → draupnir/backend-oci) — create→logs→state→stop over the REST API
Container (fallback) a ContainerBuild ContainerBootRunner (test_boot.rs:425) podman subprocess, when the API socket is absent (default_container_runner, test_boot.rs:619)
Vm a VmBuild StubVmBootRunner (test_boot.rs:664) — always, in every buildable configuration BootSpec::from_vm (test_boot.rs:189) maps the descriptor, and then nothing boots it. TunnrVmBootRunner (test_boot.rs:683) is #[cfg(feature = "test-boot-vm")] on a feature that cannot be turned on — see below

Corrected 2026-08-27, and the correction is bigger than the line numbers. Until today this row read "TunnrVmBootRunner (test_boot.rs:818) … tunnr's tunnr_vm::boot_test(BootSpec) KVM primitive … StubVmBootRunner (test_boot.rs:799) until tunnr lands", and the container rows named a BollardBootRunner / BollardEngine pair. Three things had stopped being true:

  1. The bollard pair is gone — 679a6ff (2026-07-12, "repoint the test-boot container engine onto draupnir — no 3rd bollard copy") replaced it with DraupnirContainerBootRunner. No symbol named BollardBootRunner, BollardEngine, ContainerEngine or FakeContainerEngine exists in the file any more; the doc kept citing all four for six weeks.
  2. Every line number in this section was wrong, by 3 to 135 lines (BootMode 50→51, BootTarget::mode 147→151, ExeBootRunner 334→338, BootRunner 227→231, BootProc 207→211, DispatchBootRunner 890→767, FakeBootRunner 1196→1092, from_vm 185→189). A citation that is off by one is what teaches a reader to stop following citations at all.
  3. "until tunnr lands" is the stale one that matters. tunnr landed: it is ssh://git@git.gunnar.rs:2222/mikronova/tunnr, crate tunnr-vm, and boot_test(BootSpec) -> io::Result<BootHandle> is src/boot_primitive.rs:464. What did not land is nornir's edge to it — Cargo.toml:186 still carries # test-boot-vm = ["dep:tunnr-vm"] and :554 # tunnr-vm = { path = "../tunnr" }, both commented out with the reason "tunnr repo not yet created on Codeberg", which is itself no longer true. So nornir's Vm mode is the honest stub in every configuration a person can build, and the #[cfg]'d real runner is dead code no cargo build can reach.

The VM boot that DOES work is jera's, and it is a second copy of this file. edda/crates/jera/src/vm.rs carries the same seam repointed at draupnir: DraupnirVmBootRunner (vm.rs:246), TunnrVmBootRunner kept as a back-compat alias (vm.rs:255), default_vm_runner() (vm.rs:407) picking it under the declarable feature vm-tunnr = ["draupnir/backend-tunnr"], and a module doc that states the law in so many words: "jera does not call tunnr directly — instance-firing lives in exactly one place, draupnir." nornir's copy calls tunnr_vm directly and does not depend on jera at all. Two spellings of one seam, one of them buildable — the LAW 5 shape, recorded here rather than fixed, because collapsing them is a code change with an owner, not a doc edit.

The side-work is behind the BootRunner (test_boot.rs:231) / BootProc (test_boot.rs:211) seams, and DispatchBootRunner (test_boot.rs:767) routes by mode. Tests inject a FakeBootRunner (test_boot.rs:1092) / FakeProc (test_boot.rs:1051) and drive the WHOLE lifecycle (spawn → running → death / kill → terminal job) with no real process, container, or KVM in CI. TestBootState (test_boot.rs:846) owns the job list — boot (:897), poll (:956), kill (:982), send_stdin (:1000) — and exposes it all as state_json (:885).

CLI surface (Cmd::Build → run_build, src/bin/nornir.rs:3620):

invocation effect at
nornir build --list-variants print each variant → its materialized cargo line nornir.rs:3656
nornir build --json emit the BuildSurface state_json nornir.rs:3650
nornir build --variant <n> --dry-run validate + print argv, build nothing nornir.rs:3676
nornir build --variant <n> validate + materialize + run cargo build nornir.rs:3683
nornir build --variant <n> --attest build + capture provenance JSON + warehouse-record nornir.rs:3688
nornir build --variant <n> --seal --attest + znippy-seal (feature-gated; else honest UNSEALED) nornir.rs:3691, maybe_seal :3733

The repo whose variants are driven is an explicit --repo, else the sole configured repo, else the workspace root (nornir.rs:3625); the inline [build] table wins over the standalone file (nornir.rs:3637). The attestation is written to <repo>/.nornir/build-provenance/<variant>-<ts>.json by default (nornir.rs:3699), recorded to the warehouse first (best-effort), then dumped beside the artifact when sealed.

f7 — assemble(): the one seam that turns a declaration into a receipt

f1 declares what can be built; f5 adds what kind of thing it is; f3 records what happened; build-info streams the record about it. f7 is the single method that composes them — the polymorphic assemble that reads a chosen BuildVariant, dispatches on its ArtifactKind, drives the matching builder, and hands back ONE content-addressed receipt. It is the executable form of the declaration: the UI's "Build" button (f4) is assemble(). In the Java shape the model reaches for, assemble is the one method on BuildThing; the BuildVariantSet is variants(); the variant fields are the settings/getters.

The kind axis IS the dispatch (ArtifactKind, folded in)

BuildVariant.kind: ArtifactKind (variant.rs, {Exe,Lib,Wasm,Container,Vm,Service}) is orthogonal to shape{fat/thin} and target, and it is exactly the discriminant assemble matches on. The descriptor half already exists: output_of(set, variant, dir) -> ArtifactOutput (output.rs:124) is the pure, no-build projection of what a variant WILL produce (Service→ServiceSpec, Container/Vm→ImageRef, Exe/Lib→BinaryArtifact, Wasm→PagesBundle). f7 is its executable twin: same dispatch, but it builds and hashes.

kind assembler today (three separate free fns) f7 unifies to
Exe / Lib / Service build_variant(set, name, root) (provenance.rs:379) an assemble arm → BuildOutcome
Wasm build_variant w/ a wasm32-* target (SEAM: PagesBundle) an assemble arm
Container build_container(builder, img, repo, root) (image_build.rs:312) an assemble arm
Vm build_vm(builder, vm, repo, root) (image_build.rs:354) an assemble arm

The receipt: Assembled

assemble returns ONE value binding the three halves — the produced thing, the proof, and the lineage:

Assembled {
    output:      ArtifactOutput,        // WHAT   — reuse output.rs (kind-tagged)
    attestation: Attestation | TargetAttestation,  // PROOF — reuse f3 (canonical digest, sealable)
    lineage:     Vec<BuildInfoRow>,     // the build-info record ABOUT it (below)
}

f7 invents nothing — it is the composition seam over the existing builders + f3 + build-info. "What I built" and "proof + lineage of what I built" become one value.

build-info IS the lineage (the second half of every build)

Every assemble emits build-info (build_info.rs) to a BuildInfoSink: manifest_rows(&prov, ts) (build_info.rs:178) lowers the BuildProvenance into the lineage triple — provenance (the build's identity / toolchain / host) → dep (each resolved input) → artifact (each produced file + its content_id) — and build_feed_rows (build_info.rs:226) streams the live step / log / metric feed. That row stream, keyed by build_id and carrying each artifact's content_id, is the lineage: this build, from these deps, produced these content-addressed artifacts.

The lineage becomes a graph across composite builds via content_id: a fat server that embeds a thin lib's binary emits a dep row whose content_id equals the lib build's artifact row content_id — so "which artifact fed which build" is a join, not a guess. (The Flight / at-rest / jera-DAG slice is workspace_nordisk/.nornir/build-info-flight-stream-design.md; this is the build-thing-local half — build-info-design.md.)

What assemble() needs beyond the dispatch

  1. precheck(variant) -> Ready | Missing(reasons) — the runtime gate before building: toolchain present, --target installed, container socket / KVM up. It feeds f4's valid flag with reality, where validate (f1) only proves config-validity. Reuses the existing probes (generator available(), the test-boot engine probes).
  2. composite ordering (the fat/thin payoff) — embeds / reaches (service.rs) and container from_variant layers declare a sub-build DAG; assemble(fat) must topo-assemble its parts (the embedded lib first) and thread each sub-artifact's content_id into the parent's lineage. This is the fat/thin axis becoming executable, not just declarative.
  3. the receipt as the return (Assembled, above).

from_variant is single-repo, and that is a DECISION, not an oversight. A chained variant is resolved against THIS set (image_build.rs:419) and built in-process, so the handle has no repo half. Why it cannot cross a checkout, what kernel_donor does and does not generalise to, and what a cross-repo edge would need (a published .build-out/<variant>/ + pin, a receipt check, a schema arm for edges) is filed in cross-repo-from-variant-design.md.

  1. idempotence — assemble is a no-op when the content-addressed output already exists (skip the rebuild; still emit a step row noting the cache hit).

Reuse map (f7 is a seam, not a rewrite)

f7 needs reuses
dispatch discriminant ArtifactKind (variant.rs) + output_of (output.rs)
the actual build build_variant / build_container / build_vm
proof BuildProvenance / Attestation (provenance.rs, f3)
lineage manifest_rows / build_feed_rows / BuildInfoSink (build_info.rs)
the UI trigger BuildThing Elm + BuildSurface (elm.rs, surface.rs, f4)

Naming: BuildThing is already the f4 Elm UI model (elm.rs). f7's seam is the verb — a free fn assemble(set, variant, ctx) -> Assembled plus a thin Assemble trait for the injected container/VM builders — not a second BuildThing type.

f8 — declared parameters and DECLARED FIXTURES (params.rs, korp-installer/wgfixture.rs)

A [[build.iso]] / [[build.installer]] cell declares WHICH install-time inputs it takes and WHERE each lands ([[build.installer.params]] — boot_param = a kernel-cmdline token, or staged_file = a file on the medium / P3); the CLI and the dwarves build pane supply values through ONE grammar, --param name=value (@path reads a file). Secrets are refused BY NAME at declaration and supply. The applied (name, value) record rides the cell's own descriptor into the attestation, so the build record answers "which ISO holds which hub key".

Fixtures (2026-08-22). A cell may declare that a parameter can be filled by a fixture the forge mints ITSELF — the thing that made the WireGuard hub a manual /etc/wireguard step is gone:

[[build.installer.params]]
name = "hub_conf"
kind = "text"
staged_file = "hub.conf"

[build.installer.fixtures]
hub_conf = "wg-hub"          # FixtureKind::WgHub

Reds proven by hand on 2026-08-22 (params::tests, wgfixture::tests, hubfixture::tests): @fixture with no declared fixture → "declares no fixture"; @fixture through NoFixtures → "no fixture source"; a fixture declared on a cmdline / non-text parameter → "renders a hub.conf FILE"; a conf whose hub this state dir never minted → "never minted"; a foreign-keyed initiation at an open hub → refused, nothing learned.

The settings tree — one model, its rows, its laws (2026-08-26)

Rickard's ruling, "kaka på kaka": the tree is the model of a variant's settings; the toml rows are its spelling. Vocabulary: variant → features on the variant → settings / sub-settings → typ.

part where what
the tree nornir-build-thing::tree — SettingsTree { variant, typ, roots }, SettingsNode { path, label, kind, when, edge, children } three node kinds only: FeatureNode (on/off, default; a cargo feature or a CONFIG_ symbol), SettingNode (ParamKind, required, boot_param / staged_file, @fixture), ValNode (choices + default; kernel.preset is one). Paths are the ids (dhcp.static_ip, kernel.unix); an Edge { coordinate: "repo/variant", typ } binds a feature to a consumed artifact — InstallerBuild.kernel_donor projected as the kernel node's edge
the rows OptionSpec / ParamSpec / fixtures / kernel_donor — unchanged on disk derived to the tree by SettingsTree::of_set / of_installer / of_iso / of_kernel / of_variant / of_rows; rendered back by SettingsTree::rows(), kernel_pick(), open_features() — law A proves rows → tree → rows is the identity
the impl answers BuildThing::options(variant, path), try_toggle(&ToggleRequest) -> ToggleDecision, actions(kind) -> Vec<Action>; wasm ABI ops 11/12/13; PluginHandle::{options,try_toggle,actions} defaults derive the tree from variants(); the kernel subtree is judged by KernelFeatureModel (contract refusal, veto refusal, cascade) — the answer a donor's build thing must give for kernel.*
path ↔ flag nornir-build-thing::flags::Flag — --opt <path>=on|off, --opt <val>=<choice>, --param <path>=<value>, --build <path|coordinate> parse(render(f)) == f; aliases (static_ip ≡ dhcp.static_ip) resolve through the tree, an ambiguous short name is a named refusal
the laws laws::check_bijective A declaration ↔ struct and rows ↔ tree · B menu ↔ declaration and tree node ↔ declaration row · C path ↔ flag (kernel kebab ↔ CONFIG_ folded in) · D decision ↔ fragment over the kernel subtree (tree cascade == model cascade, fragment parses back to click + cascade) · E receipt → recipe. No law walks the flat lists
fetch nordisk::fetch(coordinate) — the fourth and last host import reference fulfilment = a container over the declared toolchain image (plugin_host::ContainerFetcher + the ContainerFetchRunner seam dwarves fills); host-side only as a NAMED exception (HostPathFetcher::named(reason), or a test fixture) tagged FETCH_OK_HOST_EXCEPTION on the wire and logged; unbound ⇒ refused with the reason. host::FetchRefused is the named error

Routed through the tree (one answer): options::resolve_options (supplied --opt spellings resolve as paths/aliases, features/defaults/active/refused params read off nodes), ResolvedOptions::effective_specs (sharpened kinds off setting nodes), ResolvedOptions::render_bakes (bakes off feature nodes), options::parse_cli_options (the one flag grammar), kernel_features::resolve_kernel_cli (every kernel.* pair through SettingsTree::try_toggle, the fragment through SettingsTree::kernel_decision).

Still reading the flat rows — declaration-time validation of the spelling, not decisions the tree also answers: validate_options / OptionSpec::validate / validate_against_variant, validate_specs, validate_fixtures, and params::apply_params_with (values landed against the effective ParamSpec list the tree produced). BuildMenu carries no option surface. Gunnar's real declaration passes every law (check_bijective decl … problems: 0).

Measured (cargo run --release -p nornir-build-thing --example bench_tree; -p holger-build-thing --example bench_wasm_abi <wasm> in holger (moved from edda's nornir-buildspec-holger 2026-09-15); t14s, 2026-08-26, release, quiet box — a loaded box reads ~2× slower):

subject number note
derive forest, gunnar's real declaration 11 variants, 52 nodes — 24 µs / derivation SettingsTree::forest
derive gunnar-installer 25 nodes — 10 µs
derive 20 000-node installer (2 000 features × 9 settings) 10.8 ms was 338 ms before the by-name index in the derivation
get(path) / resolve(full path) on 20k 1.8 µs / 2.2 µs per call linear over roots, then children
resolve(alias) on 20k 422 µs per call one allocation-free visit per alias
SettingsIndex::of + index.resolve(alias) on 20k build 4.6 ms, then 208 ns per call the laws and any pane loop use the index
try_toggle native, plain features, 20k tree 2.5 µs per call
flags_of + Flag::parse(render) over 20k tree 22 000 flags: render 2.2 ms, 114 ns per flag round trip
check_bijective over the 20k installer 374 ms — A toml 142 ms · A rows↔tree 60 ms · B tree 116 ms · C paths 42 ms was 12.5 s (n² alias scan and n² row dedupe)
check_bijective over gunnar's real declaration 1.2 ms, 0 problems
wasm ABI, holger-installer (2 nodes): options() native 158 µs · wasm 389 µs per call (×1000) native cost is variants() re-parsing holger's toml each call, not the tree
wasm ABI: try_toggle() native 175 µs · wasm 429 µs per call (×1000) wasm ≈ 2.5× native per call; plugin load + manifest 1.3 s (1.49 MB module)

The target group reaches PID 1 — korp.target.firmware= (2026-08-29)

The machine shape a medium is built FOR is declared once, on InstallerBuild.target (nornir-build-thing::target, design note dwarves/.nornir/build-thing-target-facts.md). The composer consumes it at build time (El-Torito entries, the console= order); the installer PID 1 consumes it at install time, and it gets there the way every other korp.* fact does — as a kernel-cmdline token it reads back off /proc/cmdline:

part where what
the baker installer.rs FIRMWARE_BOOT_PARAM / firmware_boot_token, inside effective_boot_params korp.target.firmware=uefi|seabios beside korp.appliance= / korp.args= / korp.env.*. Baked ONLY when the group is declared AND the firmware is not both: an absent token already means both (the pre-group medium), so one fact has one spelling and an undeclared medium stays byte-identical
the reader korp-installer::cmdline TARGET_FIRMWARE_KEY, TargetFirmware, KorpCmdline::target_firmware() the typed fact; absent OR malformed ⇒ Both (a typo in a build fact must never un-boot a disk — the installer names the malformed value on its serial line instead)
the consequence korp-installer::install InstallPlan.firmware → run_install_inner the legacy-BIOS path on the INSTALLED disk (MBR boot code in the protective MBR + korpbios.bin on the ESP) is laid iff firmware.lays_bios(). uefi lays neither and says so by name; seabios and the default lay exactly what every install laid before
the carry install::carry_site_tokens it is a korp.* token that is not plumbing, so it rides to the installed disk's own korp/cmdline — the disk says what it was laid for; bios_cmdline keeps it on the SeaBIOS spelling of the line

Proof (inject-assert): installer.rs target_firmware_is_baked_as_a_korp_token_only_when_declared (undeclared → no token; uefi/seabios → one token; consoles-only → no token; a hand-spelled token is not doubled); cmdline.rs target_firmware_parses_and_absent_or_garbage_reads_as_both; install.rs target_firmware_gates_the_mbr_code_and_the_bios_stage_on_the_installed_disk — measured over the disk bytes (MBR code area, the ESP file), red without the gate.

f9 — the target group and the cloud preset roster (target.rs, target/presets.rs) (2026-08-29)

An installer medium is built FOR a machine shape, and the shape is a setting group on the installer variant — [build.installer.target] on InstallerBuild.target — never folklore in a shell script. Three UpCloud facts (SeaBIOS only, no UART at 0x3f8, the ISO as an IDE cdrom) were declared nowhere on 2026-08-28 and each cost a blind lane; the group is where such facts live now. Design note: dwarves/.nornir/build-thing-target-facts.md.

part where what
the group target::Target { preset, firmware, consoles, uarts, buses, nic, nics, cpu } firmware uefi|seabios|both · consoles in console= order (last = /dev/console) · uarts the COM ports that REALLY exist (serial means the first of them: ttyS1 on UpCloud, never a ttyS0 that is not there) · buses ide-cdrom|virtio-blk|virtio-scsi · nic virtio|e1000 · nics count · cpu generic|epyc. The default is byte-for-byte what every medium did before the group existed and emits no row
the roster — ONE target::presets::PRESETS — one TargetPreset const per machine: qemu-local, qemu-local-bios, upcloud, scaleway-instance, scaleway-elastic-metal, aws-xen-hvm, aws-nitro, gce-virtio, gce-gvnic, azure-gen1, azure-gen2, hetzner-cloud, ovh-public-cloud the rich cloud matrix: firmware, secure boot, consoles, Uarts::Known(&[..])|Unknown, console reach, boot-disk bus, how a medium is brought, NIC model, NIC count, CPU model, chipset, ACPI, metadata endpoint, IPv6 default, Mimic::Qemu|NeedsRealCloud(why), measured: Option<&str> (the evidence), notes. Tri::Unknown where the vendor is silent — never a guess dressed as a fact
the derivation TargetPreset::target() -> Result<Target, String> THE one place a preset becomes a group: buses = boot-disk bus + ide-cdrom first when the medium is an ISO cdrom; uarts = exactly the declared ports (Unknown = none); cpu = Epyc iff the CPU model is EPYC* (kernel CPU_SUP_AMD), every other model/passthrough/qemu-default = Generic; nics ≥ 1. Err(device) for a shape the group cannot spell yet (NVMe, ENA, gVNIC, Hyper-V, Xen, metal) — the group grows when the kernel recipe learns that device's Kconfig closure, not before
the reference preset = "upcloud" on the row; Target::effective() = the derived group; Target::validate preset XOR explicit fields (two writers of one fact refused by name); an unknown name is refused with the roster listed; an unspellable preset is refused with the device named; the derived group is validated, not trusted (a serial console with no port, an empty bus list, nics = 0 are refusals)
the status TargetPreset::status() — DERIVED Measured iff measured.is_some() · Stub iff mimicable and unmeasured · NeedsRealCloud iff Mimic::NeedsRealCloud. Never declared beside the facts it summarises, so the roster cannot say "measured" about a shape QEMU cannot render. Today: measured = qemu-local, qemu-local-bios, upcloud
emitted target::presets_toml() → [[target-preset]] rows (PresetRow::of) name, cloud, status, measured, needs_real_cloud, unspellable, notes, [target-preset.target] when spellable. Code → toml, one-directional; the round-trip test proves the toml IS the consts
the proof door TargetPreset::qemu_shape() -> Option<QemuShape> -machine from the chipset, -cpu the exact model (EPYC-Rome; qemu-local renders none = the historical argv), disk/NIC device models, NIC count, uarts: [bool; 4] = the -serial slots (kvmproof::Machine::uarts; UpCloud [false, true, true, false]), serial (a console= names a UART — gate on the log) vs vga (gate on applied output / screendump), firmware, the medium as -cdrom. None = Mimic::NeedsRealCloud, with gap() naming why
the Kconfig consequence Target::kconfig_needs() -> Vec<(CONFIG_*, y|n, why)> SERIAL_8250(_CONSOLE) y iff consoles ∋ serial, else SERIAL_8250 n by name · VT VT_CONSOLE VGA_CONSOLE PRINTK y iff consoles ∋ vga, else VT/VGA_CONSOLE n · ATA ATA_PIIX BLK_DEV_SR ISO9660_FS iff ide-cdrom · VIRTIO_BLK VIRTIO_PCI VIRTIO_PCI_LEGACY iff virtio-blk · SCSI BLK_DEV_SD SCSI_VIRTIO iff virtio-scsi · VIRTIO_NET/E1000 per nic · CPU_SUP_AMD iff epyc. Rows the requirement closure (requires.rs / kernel_requires.rs) places on the consumer's kernel edge; the per-preset expectation is pinned in presets::tests::kconfig_needs_per_preset
the test matrix nornir-testmatrix::appliance::{ApplianceBootGrid, BootPreset, BootReach}; rows appliance-boot/<preset> × install_ok · serves · ready (B1/B2/B3 of gunnar-installtest installer) the roster is handed in FROM the code (presets::boot_roster() behind --features testmatrix; tests/appliance_boot.rs is the binding) — this crate spells no preset name. BootReach::Kvm = a hole the installtest fills; RealCloud(why) = Absent (needs real cloud: why) until a real deploy emits under that name. kvm_all_green() scores KVM rows only; a real-cloud gap is shown, never scored

UpCloud, as data (the row that hurt): SeaBIOS · consoles [vga] (VNC is the only console anyone reads — so console=tty0 alone and SERIAL_8250 = n) · uarts Known(&[com2, com3]) (ttyS1/ttyS2 exist; a row that DOES name serial on this machine spells ttyS1) · IDE cdrom + virtio-blk · 2 virtio NICs · EPYC-Rome → CPU_SUP_AMD · -machine pc · mimicable · measured 2026-08-28/29 on gunnar-appliance se-sto1.

Invariant, pinned by test: Target::default() == presets::QEMU_LOCAL.target() — an undeclared group means "this box" by construction, not by a second table.

The target facts as kernel requirements — requires::TargetFacts (2026-08-29)

The kernel side of the same group rides the requirement closure, one writer: requires::TargetFacts is a RequirementSource in default_sources() that places Target::kconfig_needs() (target.rs — the ONLY place a fact's symbols are spelled: SERIAL_8250(_CONSOLE) iff serial · VT VT_CONSOLE VGA_CONSOLE PRINTK iff vga · ATA ATA_PIIX BLK_DEV_SR ISO9660_FS iff ide-cdrom · VIRTIO_BLK VIRTIO_PCI(_LEGACY) iff virtio-blk · SCSI BLK_DEV_SD SCSI_VIRTIO iff virtio-scsi · VIRTIO_NET/E1000 per nic · CPU_SUP_AMD iff epyc) on the consumer's kernel edge, each row under the feature its own reason names (target.consoles / target.buses / target.nic / target.cpu), so kernel.why.toml says which fact asked. Only a DECLARED group speaks (ConsumerView::declared_target): undeclared, the closure is byte-identical to one computed without the source. Once declared, ApplianceBootContract yields every symbol the group can name (derived from the default shape's rows plus the declared shape's — no hand list), so a no-UART machine lands SERIAL_8250=n without contradicting the contract and without a stray SERIAL_8250_CONSOLE=y. A declared fact the donor's preset vetoes (ide-cdrom → ATA=y on nuked, which says # CONFIG_ATA is not set) is refused BY NAME by KernelBuild::apply_requirements — the UpCloud IDE-cdrom lane as a build-time sentence; normal grants it. A NEGATIVE row (SERIAL_8250=n on a no-COM1 machine) over a preset =y is a NARROWING, not a veto met: it lands and merges after the preset layers (later wins), flipping the module OUT of the bzImage — only a positive row over a preset veto is refused.

The closure reaches the kernel build — the BuildStep → kernel build bridge (2026-08-29, lane K)

The planner put requires on the kernel step and validated it (build_dag.rs lands it on a CLONE of the donor's row); the forge then built the kernel from the BARE row, so the artifact's kernel.why.toml said feature = 0, preset_only = 69 while gunnar's installer had required CONFIG_SMP / VT / PROC_FS under appliance-boot — every "who wanted it" blank at the one place an operator reads it (measured on tunnr's .kernel-out/tunnr-kernel/kernel.why.toml). The mechanism is the ENGINE's; a door forwards the step:

The pin and the index are two different derivatives. The pin is the merged fragment's sha; a requirement the preset already carries changes no byte of it (the boot contract is in the nuked fragment set — "the pin must not move", the bzImage IS the same kernel), while a requirement the preset lacks moves the sha and is a rebuild (kernel.rs test: CONFIG_LANE_K_NEW_SYMBOL moves it, CONFIG_SMP does not). The inverted index is keyed on the REQUIREMENTS, so a kernel honestly served BY PIN to a consumer with a closure gets its kernel.why.toml refreshed beside the .config (kernel::refresh_why_index, called by resolve_kernel_for on the local .kernel-out road, rewritten only when the text differs); a reader with no closure (a bare dwarves buildthing tunnr --variant tunnr-kernel) leaves the artifact's own index alone. Rows: feature (requirers say consumer · feature · reason), preset-only (a fragment set it, nobody asked), kconfig-derived (in the built .config, named by nobody).

The canary names WHO wanted the symbol (2026-08-30, lane AK)

The in-container canary greps every line of the merged fragment out of the built .config and exits KERNEL_CONFIG_REFUSED_EXIT (90) when one did not hold. It is the right gate in the right place — and until this lane it was read by the generic red-exit arm, so the whole answer was three lines of shell output. MEASURED on the first real chained build of --opt kernel.preset=normal through gunnar's closure:

build container exited 90 (expected 0); stderr tail:
KERNEL-CONFIG-REFUSED: # CONFIG_FS_POSIX_ACL is not set crept back in
KERNEL-CONFIG-REFUSED: # CONFIG_MULTIUSER is not set crept back in

True, and it names neither the consumer that required the symbol nor the fact that a PRESET is what turned it back on — the two things that decide what to do next. The shell cannot know either; the orchestrator holds k.requirements and can say both. It now reads:

kernel 'tunnr-kernel': the merged fragment did NOT survive `olddefconfig` — 2 option(s)
the build asked for are not in the built .config. This is a PRESET/CLOSURE conflict, not
a compile error: preset 'normal' carries something whose Kconfig turns them back:
  # CONFIG_FS_POSIX_ACL is not set crept back in
      ← CONFIG_FS_POSIX_ACL=n is REQUIRED by gunnar/gunnar-installer-open:data.fs
        (data.fs = xfs, one user, no access control: not exercised on /data)
  # CONFIG_MULTIUSER is not set crept back in
      ← CONFIG_MULTIUSER=n is REQUIRED by gunnar/gunnar-installer-open:appliance-boot (…)
Either the consumer stops requiring those symbols, or the preset stops carrying what
turns them back on — a kernel that DID build lists every requirer in `kernel.why.toml`
beside its .config. The whole closure here: 66 symbol(s) required by 5 feature(s) …

A line nobody required says so (nobody's requirement: the preset/profile fragment asked for it) rather than inventing a requirer. The exit code and the marker are consts now (KERNEL_CONFIG_REFUSED_EXIT / KERNEL_CONFIG_REFUSED), written by the shell generator and read by the arm, so the two cannot drift.

The verdict this produced, measured: normal and full are defconfig-based and carry filesystems whose Kconfig selects POSIX ACL straight back, so gunnar's one-user data.fs = xfs narrowing cannot hold on them. nuked and slim are tinyconfig-based and it does. That is a recipe decision to take, not an engine bug — and before this lane the question could not even be asked, because a standalone preset build carries no closure.

ONE spelling for a kernel option, at both doors (2026-08-30, lane AK)

--list-options renders flags::flags_of(tree) verbatim — the tree IS the listing (law C) — and the tree of a [[build.kernel]] row spelt its vals at the ROOT while every door accepted only the kernel. scope. So the listing printed

--opt preset=slim

and typing it answered

no preset 'slim' on 'tunnr-kernel'; declared bundles: full · normal · slim · nuked

— a refusal that lists slim while saying there is no slim, on the one flag whose whole job is to prevent exactly that. It cost lane AH a wrong turn.

SettingsTree::of_kernel now builds its nodes under KERNEL_PATH, which is what the CONSUMER side already did (of_installer gives the kernel edge node exactly those children) — so the donor's own door and a consumer's door render the same paths rather than two spellings of the same thing, and build_steps::route_opts passing kernel.* through unchanged is now a tautology rather than a convention. of_kernel_pick stays BARE on purpose: it is the internal judge of the kernel grammar and its names are the ones split_kernel_opts hands it, already stripped of the scope. Keeping the judge bare and the door scoped is what lets one string work at both doors.

The refusal names the accepted spelling for anyone who typed the old one from memory (SettingsTree::kernel_preset_hint): "…but 'tunnr-kernel' has a KERNEL preset, which is a val and not a bundle: spell it --opt kernel.preset=slim (declared: full · normal · slim · nuked). --list-options prints that spelling." Law A/D read the kernel row's tree at KERNEL_PATH to match.

The CLOSURE is verified, not just the fragment (2026-08-30, lane AK)

The composer already ran a check on a donor kernel — fragment equality: the pin's fragment_sha256 against the merge the consumer's choice produces. Lane AH composed slim and normal media that PASSED it, installed (B1 PASS, INSTALL-OK on the UART) and then died at B2 with the installer's own words:

FAILED data: mount /dev/vda3 on /data as xfs: ENODEV: No such device
             — is CONFIG_XFS_FS in this kernel (data.fs = xfs on the recipe)?

Fragment equality cannot see this, twice over. It compares a merge against a merge, so it is blind to what olddefconfig did to the merge on the way into the built .config; and when it DOES fire, what it says is the wrong sentence — the measured refusal for lane AH's exact case was

the built kernel (…/.kernel-out/don-kernel) is not the CHOSEN kernel — the pin was
built from preset 'slim' (fragment f190ed5a…), the choice here merges to preset 'slim'
(fragment 5f80df33…). Build the chosen one first: `dwarves buildthing donor-repo --variant don-kernel`

— the same preset name twice, two hashes, no word about /data, and a repair command that cannot repair it: a standalone donor build carries no requirer closure at all, so typing that line produces the same kernel again, forever.

THE RULE: a consumer's closure is checked against the BUILT .config, and it is checked first. verify_built_kernel_for asks KernelRequirements::check_config (the existing forward index — missing_in_config — not a second checker) BEFORE the pin comparison, so the operator gets the actionable sentence and the pin refusal is left for what it alone can see (a veto file, an operator's decision, another preset). The refusal names symbol · consumer · feature · reason · what the .config actually has, one row each, under a count (3 of 64 required symbol(s) are wrong), and it ends with KernelRequirements::chained_build_cmds — dwarves buildthing <consumer-repo> --variant <consumer-variant> --build kernel, one per distinct requirer — plus the donor's own line marked as what will NOT carry the requirements.

The escape hatch stopped being silent too. KORP_KERNEL / TUNNR_KERNEL override a declared edge on purpose and used to go round every check with it. They still win — an explicit file in the environment is a deliberate act, and it is the only road for a kernel no declaration can spell — but dwarves' assemble_cell::override_kernel_checked (one writer, both env arms, installer and iso) now reads the kernel.config beside the file when there is one and refuses by symbol, and says UNVERIFIED against N required symbols out loud when there is not.

The surface every consumer declares against (2026-08-30, lane AE)

preset-only was read as "the nuke candidates by name", and that reading is the trap: it means UNDECLARED, not unused. With three features speaking the index cannot tell them apart, so the mechanism below is what makes the class mean anything — the rule itself lives in dwarves .nornir/build-thing-model-design.md §f5.

The value type. KconfigValue is Y · M · N · Int(i64) · Str(String). Int is the decimal int option, written UNQUOTED (CONFIG_NR_CPUS=64); Str is the string option, written quoted (CONFIG_FAT_DEFAULT_IOCHARSET="cp437"). The split matters because directive() is what the merge keys on: as a Str the SMP ceiling would have merged CONFIG_NR_CPUS="64", which kconfig drops on olddefconfig and the canary then refuses — so an int option was not expressible at all and had to stay a fragment line nobody owned. Both sides of a comparison go through KconfigValue::of_value, so a declared value = "64" and a built CONFIG_NR_CPUS=64 are the same value. Named gap: a HEX int option (CONFIG_PHYSICAL_START=0x1000000) still parses to Str; nothing declares one, and the type says so where it is defined.

Constructors: KconfigReq::y / n / int / text.

What the appliance-boot contract now carries beyond the PID-1 syscall family it always had — the platform the PID 1 actually stands on, every row a line that used to be preset-only:

group symbols who really needs it
firmware EFI ACPI EFI_STUB's parents; on x86 EFI depends on ACPI
buses PCI BLOCK VIRTIO_MENU every device hangs off PCI; the installer lays a GPT and every fs rides the block layer; virtio lives behind a menu symbol
console SERIAL_EARLYCON the only printk between the firmware handing over and the 8250 driver registering
NIC VIRTIO_NET an initramfs has no modules, so a distro's E1000=m guest is dead metal
network NET INET NET_CORE NETDEVICES PID 1 configures its own interface over AF_NETLINK/NETLINK_ROUTE (CONFIG_IP_PNP is set on no kernel we ship) and ships its boot narration from a UDP socket
ESP FAT_FS NLS FAT_DEFAULT_IOCHARSET="cp437" VFAT_FS's parents and the measured 2026-08-13 charset (FAT-fs (vda1): IO charset iso8859-1 not found)
SMP NR_CPUS=64 SMP=y alone gives the range FLOOR, measured NR_CPUS=2 on the nuked base
one user MULTIUSER=n PID 1 is root and execs the payload as root; NAMESPACES and SECURITY were already off, so the veto takes nothing derived with it

The invariant the contract keeps: every contract row is ALREADY in the donor's fragment set, so landing the contract moves no byte of the merged fragment and no byte of the pin (kernel_requires::directives_round_trip_and_ key_the_same_bytes_as_the_fragments). A row the donor lacks is a rebuild, and that is the honest signal — it is why a NARROWING belongs on the FEATURE that narrows (target.consoles takes SERIAL_8250 out) while a contract =n is only ever written beside a donor veto that already says the same thing (IPV6, WIREGUARD, MULTIUSER).

Target::kconfig_needs grew the same rows on the machine side — PCI once per bus set, VIRTIO_MENU when any declared fact is a virtio one, EFI + ACPI under target.firmware, SERIAL_EARLYCON under target.consoles with the honest =n when there is no serial — so a DECLARED group owns them and the contract steps aside (ApplianceBootContract's owned filter, the mechanism that was already there for SERIAL_8250 and VGA_CONSOLE).

THE DOORS THAT HANG — the guest's own refusal, and a timeout that says so (lane AQ, 2026-08-30)

A boot proof that HANGS is worse than one that fails: it burns the whole budget, it blames the consequences instead of the cause, and to anything holding only an exit code a hang and a pass look the same. Two of the six mimicable presets did exactly that on 2026-08-30, 420.10 s and 420.29 s of nothing each, and the mechanism was one line: kvmproof::boot_hold watched the console for Kernel panic and for nothing else.

serial::terminal_verdict is the ONE reader of a captured console, and it knows every producer of a terminal line in this tree — a list of exactly one is how the next hang gets missed:

speaker what it prints why it is terminal
installer INSTALLER: FAILED <step>: <why> — 15 steps (hwdetect · mounts · partition · payload · raid · open-disk · data · boot · archive · pin · rollback · selftest · argv · exec · hub), 38 call sites across korp-installer.rs, korp-start.rs, install.rs, datahome.rs every one is followed by halt_forever() or a returned Err that reaches one. Checked against the source by every_failed_line_in_this_crate_is_terminal, so the premise cannot rot
first-stage-uefi KORP-BOOT: FAILED <step>: efi_status=0x… and KORP-BOOT: halted (first stage will not fake a boot) fail_halt ends in an hlt loop. boot/korp-boot-efi is a separate no_std crate, so its words are guarded against its source rather than shared as a const
first-stage-bios FAIL: <one of 5 reasons> after the korp-boot-bios: banner (unpatched table · not a bzImage · initramfs too high · INT 13h · INT 15h) the 16-bit stage halts. The strings come from korp_boot_bios::CONSOLE_FAIL / CONSOLE_FAIL_REASONS — the crate that assembles the image, not a copy
kernel Kernel panic (which is also how Attempted to kill init! arrives) the kernel is done

boot_hold stops on it itself, so no door can forget, and BootRun grew two fields that were previously one absence:

poll_serial_until (the boot_iso_until / boot_disk_until family) was left alone on purpose: its callers assert on the captured text and several of them are deliberate negative doors. Teaching it the same trick is a separate, reviewable step, not a side effect of this one.

MediumBring answers a question the device closure never asked

gce-virtio hung because it BOOTED and then refused correctly: its boot disk rides virtio-scsi, gunnar's medium declares buses = [ide-cdrom, virtio-blk], so the appliance kernel has no SCSI and the target disk is not there. Nothing was wrong with the preset — GCE gen1/gen2 Persistent Disk really is virtio-scsi. The contradiction is between two DECLARATIONS and can be read off both without booting anything.

ovh-public-cloud hung for a different reason and left zero bytes of serial: MediumBring::ImportQcow2 means no cdrom is attached, the firmware is SeaBIOS, which boots an MBR, and the medium is a pure El Torito ISO whose LBA 0 is all zeros (measured on the 38 680 576 B ISO: [510..512] = 00 00, not 55 AA). Nothing on that machine could hand over. gce-virtio has the same no-ISO-attach property and boots anyway, because OVMF reads El Torito off any block device — the firmware is the difference, which is why the check names it instead of refusing every cdrom-less preset.

MediumBring::describe() / is_iso_attach() put that word where the variants are (QemuShape::medium_as_cdrom now reads the same method), so a door refusing a preset can SAY how this platform does take a medium.

THE MEDIUM'S OWN BOOT SHAPE — medium_boot, and the clouds that import a disk (lane AS, 2026-08-30)

[[build.installer]] medium_boot = "el-torito" | "hybrid" (installer::MediumBoot, default el-torito so an undeclared row is what it always was).

The fact

An .iso is an El Torito medium: the boot information lives in a catalog a firmware finds by reading the volume as optical media. Attach it as a cdrom and every firmware boots it. Hand the identical bytes to a machine as a disk and a legacy BIOS reads LBA 0, looks for 55 AA, finds nothing, and parks.

MEASURED on gunnar's real medium (621808cb…, 38 680 576 B, 2026-08-30):

LBA 0[510..512] = 00 00        (not 55 AA; the whole first sector zero)
--target ovh-public-cloud  →   420.29 s, serial.log = 0 bytes

Lane AQ's preflight named that correctly and in 0.03 s (THE ATTACHMENT). What it could not do was make the door pass, because the medium genuinely had no boot path on that machine.

This is not one cloud's quirk. Of the 13 presets in the roster, 9 say MediumBring ≠ IsoCdrom — OVH and Scaleway import a glance qcow2, GCE a disk.raw in a tar.gz, AWS via VM Import, Azure a VHD. Every platform that imports a disk image rather than attaching a cd was unreachable by this product. A USB stick is the same shape.

The mechanism, and why nothing new was assembled

hybrid lays two things, both inside the ISO-9660 system area — the first 16 sectors (32 KiB) the standard reserves and this writer left all zeros:

  512-byte LBA 0        the boot sector: 440 B of code + a partition table + 55 AA
  512-byte LBA 1..=33   (a GptHybrid medium's primary GPT header + array)
  512-byte LBA 34       HYBRID_STAGE_LBA — a SECOND copy of the korp-boot-bios stage

so the medium does not grow by a byte, and every byte from the PVD (sector 16) on is identical to the plain medium. Asserted, not hoped: a_hybrid_medium_has_a_boot_sector_and_a_disk_flavoured_stage_and_costs_no_bytes compares the two images' lengths and their tails.

The boot code is korp_boot_bios::emit_mbr — which already existed: it is exactly what korp-installer writes onto the target disk it lays, and it has since the legacy-BIOS path landed. The medium now gets what the installed disk always had. bios_patch::MBR_CODE_LEN (440) moved into the contract crate so the number is spelled once.

Why a SECOND copy of the stage and not one that guesses

The stage carries a bios_patch table of LBAs, and an LBA is only a number until you say how big a sector is. Off a cdrom the firmware's INT 13h counts 2048-byte sectors; off a disk it counts 512-byte ones. So the same file needs two tables: the El-Torito copy keeps sector_shift = 11, the hybrid copy gets sector_shift = 9 and every LBA × 4. bios_patch already had both words (SECTOR_SHIFT_CD / SECTOR_SHIFT_DISK) for exactly this reason.

A stage that asked the firmware (INT 13h AH=48h) how big a sector is would be a third opinion about a fact the writer knows with certainty — and the writer is already the one party that knows where every file landed. Two patched copies of one image, from one emitter, is the trick set_bios_stage plays already.

The stage-sector count is div_ceil, never floor: an MBR that reads len/512 sectors leaves the stage's STRINGS unloaded and the loader then prints garbage. emit_stage pads to a whole sector, and this is what makes that a property rather than a coincidence.

Refused by name

A 55 AA that chainloads nothing is worse than no boot sector, because firmware stops looking the moment it finds one. So hybrid without a real legacy-BIOS stage is refused in two places: the composer (target.firmware = uefi lays no stage) and the writer (set_bios_stage was never called).

Where the word lives, and why not in target

On the medium (InstallerBuild), not on the machine (Target). Every field of target is a kernel-config consequence — Target::kconfig_needs is its one writer — and this one changes 512 bytes of an image and no kernel symbol at all. There is a second, sharper reason: requires::ConsumerView::declared_target returns Some only when !Target::is_default(), so putting a new word there would make gunnar's whole group speak at once, including ide-cdrom → CONFIG_ATA, which its donor's nuked preset vetoes by name. A medium-shape word on the machine group would have broken the appliance build.

Measured, both doors, same medium

door before after
--target ovh-public-cloud [TIMEOUT] B1 420 008 ms, 0 B of serial, exit 3 B1 1250 · B2 3799 · B3 2019 ms — ALL PASS, 7 s
the four doors the deploy gate runs ALL PASS ALL PASS, red=0 (a hybrid medium is byte-identical past the system area)

The guest's own words on the OVH shape, off a hard disk: korp-boot-bios: legacy BIOS (El-Torito) stage → INSTALLER: vda: carries the installer medium … excluded → INSTALLER: INSTALL-OK.

⚠ The stage's banner still says (El-Torito) on both paths. It is now literally wrong on the hybrid one; it was left alone because korp_installer::serial's first-stage drift guard keys off that word (lane AQ), and changing a console string is a separate, reviewable step.

The SECOND surface: a toolchain image (lane AF, 2026-08-30)

requires.rs has said "the kernel is just ONE sub build-thing" since 2026-08-29 and named three surfaces that did not exist: a toolchain image, a container base, a firmware. The toolchain image is the first of them, and it was picked because the requirement was already written — as a flat const nobody owned:

pub const KERNEL_TOOLCHAIN_TOOLS: &[(&str, &str)] = &[
    ("gcc", "compile"), … ("clang", "LTO/DCE"), … ("kmax", "minify"),
    ("qemu-system-x86", "probe-boot"), …
];

Every line is a requirement with a reason. Being a const, it was unconditional — a toolchain = "gcc" row that runs no LTO pass still demanded clang and lld — and owned by nobody: nothing could say which step wanted a tool. That is the preset-only condition, one surface over.

What the surface is

Kconfig toolchain
KconfigReq symbol = value, because ToolReq tool present|absent, because
KernelRequirements ToolRequirements
Contradiction ToolContradiction
WhyIndex → kernel.why.toml ToolWhyIndex → toolchain.why.toml
preset-only — a fragment line nobody asked for image-only — a package the recipe installs that nobody asked for
KernelBuild::apply_requirements ContainerBuild::apply_tool_requirements

Who declares against it — and the sentence that makes the engine generic

The kernel row, which is a CONSUMER as well as a donor. tunnr-kernel declares [build.kernel.edges.toolchain] repo = "tunnr", variant = "nordisk-kernel-toolchain", typ = "container", and requires::ToolchainTools places one requirement per STEP the row actually runs, under that step's own feature name (kernel.compile, kernel.lto, kernel.minify, kernel.probe-boot). A three-level chain falls out of the recursion that was already there:

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

MEASURED on the shipped rows, 2026-08-30: five of the thirteen tools in the nordisk toolchain image are wanted by NOBODY on the row that image exists for — clang/lld (the row is toolchain = "gcc", lto = false) and python3/python3-dev/kmax (solver = ""). That is image-only, and it is read exactly as preset-only is read: a question, never a verdict.

What turned out to be secretly kernel-shaped

Three things, and they are the finding this lane was asked for:

  1. BuildStep::requires was Option<KernelRequirements>. visit computed a generic SurfaceClosure and then called .as_kconfig() on it — so a non-Kconfig closure was collected, unioned, refused correctly and then silently dropped on the floor at the last step of the recursion. It is Option<SurfaceClosure> now, with kernel_requires() / tool_requires() as the typed accessors (one model, two readers — never a twin).
  2. The pin was computed inside visit, behind if edge.typ == Kernel, twice (once on the first visit, once on the reached-twice join). It is step_pin_for now: one dispatcher, one arm per surface, and adding a surface is adding an arm rather than another if in the recursion. A container step pins the sha of ToolRequirements::canonical; StepPin::ledger_key is empty for it, because the toolchain surface has no ledger and saying so beats inventing one.
  3. SurfaceClosure::union could not fail, because with one variant every pair matched. It returns a named refusal for a mixed pair now — one edge cannot carry two surfaces.

Everything else was already generic and needed no change: Requirement, EdgeRequirement, Closure, RequirementSource, closure_of_consumer, the edge/typ check, the cycle refusal, the reached-twice union. That is the engine having been built right; the three above are where "generic" had been asserted rather than exercised.

What the donor's exposure is, honestly

ContainerBuild::provides_tools(recipe, asked) answers by asking whether the image's own recipe NAMES the tool — the same question the hand-written the_toolchain_image_carries_every_step_s_tool asked, promoted from a test's assertion to the donor's exposure. It does not run the image and does not read a package database: a recipe that names clang in a comment counts, and a base image that carries gcc without naming it does not. That asymmetry is the honest one for a declaration-time check — the alternative is running a container to answer a question about a build plan — and the live half is the build itself failing, loudly, on a missing binary.

The toolchain travels as BYTES — the offline courier (lane AO, 2026-08-30 · #14)

toolchain_archive.rs. The surface above says WHICH toolchain a build wants; this says how it gets onto a box that cannot ask a registry.

The asymmetry that was the bug. Two of a kernel build's three fetches were already couriered: the source tarball and the kclause formula both live in a content-addressed cache, verified by hash before use, so a human with a USB stick can fill them. The third — the toolchain IMAGE — had no address a courier could deliver to. An image absent from the local store leaves the engine exactly one move, and a build that quietly makes that move is indistinguishable from an airgapped one, which is the same as having no airgap.

The rule. One preflight, resolve_toolchain, between the merged fragment and the run request. It never fetches and never falls back:

The last row is the one that makes ToolchainSource a four-valued thing rather than a boolean. unchecked (a runner with no image store) and staged-archive (verified, by content address, offline) used to be the same absence on a receipt.

Why the identity is the IMAGE ID and not the manifest digest. Measured, and it overturns what .nornir/kernel-guide.md §"An OFFLINE kernel build" asserted: a podman save+load round trip preserves the config digest (ce66a0100cb8… on both sides) and rewrites the manifest digest (f573ca0a… → 5e628ed9…), because podman re-synthesises the manifest on load. An oci-archive rewrites both. So the declared name@sha256:<manifest digest> names nothing on the machine the archive was carried to, while sha256:<id> resolves — and the id is therefore the only 64 hex characters that can mean the same image on two boxes without a registry. The mismatch refusal spells that out, with the value to re-pin to and both files it goes in, because every [[build.kernel]] in the constellation is still on a manifest digest and will meet this refusal the first time it is handed an archive.

No new format vocabulary. This adds no key to the recipe and does not move RECIPE_SCHEMA: the archive is addressed by the pin the format already declares, and the change is to what that pin should CONTAIN. A schema bump would have been the easier engineering and the worse answer — it would have left the old per-box pin in place beside a new one, with nothing saying which of the two a receipt meant.

No second way to move an image. The live store is four calls into draupnir verbs that already existed and had no caller on this path — image_present, export_image ("the missing half of the airgap story", its own doc comment), import_image, inspect_image_archive. LAW 5: a wiring, not a twin.

Proven by denial, not by absence of evidence (tests/kernel_offline_live.rs): the same build twice against a podman that is itself inside a network namespace with no route and serving an EMPTY store — RED without the archive (NORDISK-TOOLCHAIN-MISSING, no bzImage), GREEN with it (1.16 GB loaded from the file, bzImage 2 057 216 bytes, toolchain_source = "staged-archive"), 123.95 s — reproduced on a second, independently created empty store in 99.46 s with an identical bzImage. The same two arms through dwarves buildthing … --variant <kernel row> against a scratch fixture: the refusal and exit 1, then BUILT … in 90.2s — 4 artifact(s).

Re-proven 2026-08-30 (lane AV) against a REPRODUCIBLE toolchain image. Same denial, fresh empty store, podman pull alpine refused for DNS before anything was trusted — RED NORDISK-TOOLCHAIN-MISSING naming nordisk-kernel-toolchain@sha256:3f632569f198… and no bzImage, GREEN bzImage 2 057 216 bytes (the same size the 08-27 image produced) with toolchain_source = "staged-archive" and the id 3f632569f198…, 117.3 s. So the image that reproduces is the image that builds: nothing about pinning the base by digest, moving apt to a snapshot, or normalising timestamps changed what comes out of the compiler. AO's refusal still fails by name and there is still no fallback.

The pin is reproducible now — what that changes here

This section's central claim — "the id is the only 64 hex characters that can mean the same image on two boxes without a registry" — is still true about a COURIER, and is no longer the whole story. Lane AV made edda's kernel-toolchain.Containerfile reproducible (2026-08-30): four mints into four fresh empty stores gave ONE image id 3f632569f1980e96744eef4ab258341fc77c55cb17c2dabe79b243e00d6e0825 and ONE manifest digest 78eaa55a651d5bd76aae36db0f3307e267a92d2a547ee338a17822a103e0320e. So two boxes that both MINT the image now agree on both numbers; it is only the save/load courier that still destroys the manifest digest. Concretely:

the other box manifest-digest pin image-id pin
mints the recipe itself (has a route) works now works
receives a docker-archive (no route) still names nothing works

Which is why an image ID remains the right thing to pin — it is the spelling that works in both rows — but "pin the id" has stopped being the only way to make a pin true anywhere but on oden. See .nornir/kernel-guide.md, "The image is REPRODUCIBLE".

The --opt line is split by the TREE, not by a name → bool parser

options::parse_cli_options returns BTreeMap<String, bool>, so it can only ever answer one question, and it answers it about every --opt:

--opt kernel.preset=nuked: an option is on or off (on|off, true|false, yes|no, 1|0)

Two measured defects were that one sentence. suite_cli::run_installer_selected — the door the forge's assemble_cell::compose_installer_row hands the UNSPLIT line to, correctly, because the kernel choice is judged upstream against the DONOR's declaration — refused --opt kernel.preset= outright, and gunnar's BUILD-KEYED-INSTALLER.md carried a "⚠ do NOT type this flag" warning instead of a fix. And a declared setting GROUP ([build.installer.data], fs/io) was on no door at all: lane N flipped fs = "fat" in a scratch worktree to build a FAT medium, because --list-options showed dhcp, wireguard and kernel.* and nothing else.

SettingsTree::split_opts is the one parser a door needs, because the tree already knows what each path IS. Three buckets, which are the three things a door does with them:

bucket what it holds who reads it
features this variant's Feature nodes, as on/off options::resolve_options
choices this variant's Val nodes, each checked against its roster InstallerBuild::apply_group_choices
behind_an_edge path=value for anything under a node carrying an Edge the DONOR's door (kernel_features::resolve_kernel_cli)

Every refusal is the tree's and names the path: an unknown spelling lists what IS declared, a val lists its roster, a feature says what an on/off value looks like, and a --param path typed as --opt is told which flag it is.

A GROUP is a set of vals at reserved paths. data.fs and data.io are the first (tree::DATA_FS_PATH / DATA_IO_PATH), and they follow the road the root preset val already took: root val nodes, ignored by the option-row loop in rows(), read back BY PATH, and compared as a row by law A. An installer's tree carries them whether the group is declared or not — an undeclared group is at its type default and an operator must be able to SEE that it is, and move it. flags_of then renders every choice, so --list-options shows the group with no listing code of its own.

One writer lands the choice: InstallerBuild::apply_group_choices, called by edda's own compose door and by the forge's resolve_installer_options. The forge lands it on the row INSIDE the resolved set, and that is load-bearing rather than tidy — the requirement closure reads the group off set.installers (ConsumerView::declared_data), so a data.fs that stopped at a cell clone would move the medium and NOT the donor kernel: a FAT /data on a kernel built for xfs. A val the tree offers and no door applies is REFUSED, never dropped.

A group that cannot be a val says so. [build.installer.target] is a set of LISTS (consoles, buses, uarts), not a one-of, so it has no --opt spelling — and --list-options prints it as group target RECIPE-ONLY: … with its effective shape. An omission would read as "there is no such knob", which is the one thing a listing must never read as.

The rows live ON the option row, and the day they did not is worth keeping. OptionSpec.kernel_requires is where a feature's kernel needs are declared. For one day (lane AE → AF, 2026-08-30) gunnar could not spell it: OptionSpec is #[serde(deny_unknown_fields)], and gunnar's own crate read its own recipe through the PUBLISHED nornir-build-thing 0.1.10, for which the key makes the whole recipe unloadable (MEASURED: unknown field kernel_requires, expected one of name, label, default, feature, params_when_on, params_when_off, param_kinds, forbid_symbols, bakes). InstallerBuild is not deny_unknown_fields, so a [[build.installer.option_requires]] table beside the options was dropped benignly there instead, and the declarations were visible only to the composing forge — the repository that OWNED them could not see them.

That table is gone (lane AF): gunnar moved onto a path dep on the sibling edda checkout, the rows moved into the option rows, and OptionRequire went with them. Two of the three refusals it needed went with it too, and the way they went is the point — a row addressed to an option nobody declares, and a symbol spelled in both places for one option, are both IMPOSSIBLE inside the option row rather than merely checked (LAW 5, by construction). The third survives as a declaration-time check on the one list that can now hold a duplicate: one option, one writer per symbol.

The lesson that outlives it: a cell-level table beside a deny_unknown_fields struct is the escape hatch for a consumer stuck on an old loader ([build.installer.fixtures], [build.installer.target] and [build.installer.data] each took that road), and it is a TEMPORARY one — while it is in place the declaration is not readable by the repo that wrote it, which is exactly the two-files-deep condition this crate exists to refuse. Fix the pin instead, and delete the table in the same breath.

The law, as a test. every_positive_fragment_line_has_an_owner_except_the_ ones_named_here asserts the exception list literally (today: ["CONFIG_UNIX"]), so a new unowned =y fails with its own symbol in the message. Seen red by construction — drop any contract row and its symbol appears in unowned.

Law A skips the open axis on a kind = kernel variant: its tree IS the kernel model's symbol rows (judged against the pick), not a cargo feature set — measured on tunnr's tunnr-kernel row, whose clicked-off symbols read as an "open" set nobody declared.

THE COMPATIBILITY RULE — one format, five readers, four pin generations (lane AJ, 2026-08-30)

schema.rs, and the reason it exists rather than a preference about strictness.

The measurement

.nornir/build-variants.toml has more than one reader at more than one version by construction. The FORGE — dwarves CLI and the ⚒ pane — reads every repo's recipe with ITS nornir-build-thing pin; the repo's own <repo>-build-thing crate, and that crate's toml_parity test, read the same bytes with THEIRS. Nothing compared them. Inventory, 2026-08-30:

repo pin as written what the LOCKFILE actually resolved
gunnar 0.1.11 + path 0.1.11
korp 0.1 ×3 and 0.1.10 0.1.10
holger 0.1.0 ×3 + path 0.1.8 path and 0.1.8 registry
skade 0.1.4, no path (dev-only, from crates.io)
nornir 0.1.10 + path 0.1.11 path and 0.1.11 registry
znippy 0.1 ×2 + path 0.1.11 path and 0.1.3 registry
knut 0.1 + path 0.1.9 path and 0.1.8 registry
tunnr · znippy-zoomies · modgunn · ordning · facett · dwarves floating "0.1" 0.1.4 … 0.1.11

"0.1" is ^0.1.0: it accepts every reader that ever existed and moves with no commit. And there is a FIFTH generation that no pin can spell — the published 0.1.11 and edda's 0.1.11 are different crates: the published copy has neither InstallerBuild::{target,data} nor OptionSpec::kernel_requires, because lanes AE/AF added that vocabulary without a bump. nornir's lockfile holds both, under one version number, with nothing able to tell them apart.

Both ways a key can go wrong had already happened, to ONE file

Published nornir-build-thing 0.1.10 against gunnar's committed recipe:

REFUSED  unknown field `kernel_requires`, expected one of `name`, `label`,
         `default`, `feature`, `params_when_on`, `params_when_off`,
         `param_kinds`, `forbid_symbols`, `bakes`
         …at [[build.iso.options.kernel_requires]] — the WHOLE recipe unloadable
         for one additive key, because `OptionSpec` is `deny_unknown_fields`.
         (Lane AE had to invent `[[build.installer.option_requires]]` for this.)

with that key removed so the load can finish, the SAME reader:
DROPS    build.installer.0.data   build.installer.0.target
         build.installer.1.data   build.installer.1.target
         …in SILENCE. `InstallerBuild` is not `deny_unknown_fields`, so two whole
         groups — the MACHINE each medium is built for, and what `/data` is laid
         with — went four generations unread while gunnar's `tests/toml_parity.rs`
         was GREEN over them.

That green is the point. A parity mirror compares the file against the same loader that dropped the rows, so both sides agree about nothing, loudly.

Why neither half of the choice works alone

So the rule is three parts, and each owns exactly one of the three ways a key can go wrong.

THE RULE

R1 — the recipe declares the schema it needs, and the WRITER computes it. A root-level [nornir-build-thing] schema = N, OUTSIDE [build]. Root level is not taste: BuildVariantSet is deny_unknown_fields and BuildConfig is not, so the root is the one place in this format that every published generation ignores instead of refusing (0.1.3 · 0.1.4 · 0.1.7 · 0.1.8 · 0.1.10 · 0.1.11, measured). A key inside [build] would have made the version marker itself the next outage. N is schema::required_by(&set) — derived from what the set populates, never hand-set — and BuildConfig::emitting is the only constructor an emitter can reach, so R1 cannot be forgotten. A recipe that declares nothing new emits no header and stays readable by every reader that ever existed.

R2 — a reader that is too old refuses BY NAME, before serde. schema::check_readable: "this recipe is schema 2; this nornir-build-thing (0.1.11) understands up to schema 1 — upgrade nornir-build-thing to >= 0.1.12." Never a serde error naming a field the operator never wrote, never a line number in a file the code emitted.

R3 — at a schema it DOES understand, a reader that drops a key FAILS. schema::dropped_keys reports every ignored build.* path (via serde_ignored, in the SAME deserialize that builds the set — no second parser to drift from the first), and BuildVariantSet::from_toml_str refuses with the paths. This is the half deny_unknown_fields stood in for on the closed types and nothing stood in for on the open ones (InstallerBuild, IsoBuild, ContainerBuild) — which is precisely where the four lost paths lived.

deny_unknown_fields stays where it is. Under R1+R2 a reader only reaches serde with a file at a schema it understands, and there an unknown key is a typo or a hand-edit of an emitted file — both of which must be loud.

the file says this reader what happens
schema > mine too old R2: named refusal + the version to upgrade to
schema ≤ mine, unknown key on a CLOSED type any serde: named refusal (a typo)
schema ≤ mine, unknown key on an OPEN type any R3: named refusal, every dropped path listed
schema ≤ mine, nothing unknown any loads, having read every declared byte

None of the four is silence. That is the whole property.

The honest limit

A reader that is already published cannot be taught R2: 0.1.10 will keep answering unknown field kernel_requires until nothing runs it. What R1 buys immediately is that the file RECORDS what it needs; what R3 buys immediately is that the drop is detectable from the new side TODAY. The readers behind us are covered by the guard instead.

The guards, both seen red

Everywhere else in the constellation, two copies of nornir-build-thing in one graph is a bug — it is how dwarves b81545c earned E0609: no field dsse. Here it is the instrument.

PIN DISCIPLINE

The version requirement on nornir-build-thing is the floor of the reader the repo needs, and it is one entry per repo:

The remaining honest gap: holger · nornir · znippy · knut still carry a SECOND, registry-sourced copy in the same graph, pulled by published transitives (modgunn, nornir, nornir-config). A [patch.crates-io] would collapse it, and korp and dwarves do exactly that — but znippy removed its patch table on purpose (a [patch] fork is a structural publish blocker) and nornir removed all eight entries on 2026-08-23. So that one is a RELEASE-CASCADE item, not a manifest edit: it closes when the published transitives are republished against the floor.

f10 — CLOSING a cloud preset: the three legs, on the machine the closure was composed for (lane AN, 2026-08-30; ported 2026-10-03)

Target::kconfig_needs can derive CONFIG_BLK_DEV_NVME from boot_disk = nvme, and presets.rs can say AWS Nitro's root device is NVMe. Neither of them has ever seen an NVMe controller. Until a kernel composed from that closure boots on a machine with one and reports /dev/nvme0n1, "the preset is closed" is a claim about a data structure.

Lane AN built that door on 2026-08-30 on its own branch, and it never merged; release-3 grew the cloud words independently (d48e77c, 624e636, 82af100) and judges a machine per facet (MimicAxes, 4cc2e6a/e2baaea, 2026-09-05). What is ported is the DOOR, reading release-3's axes; the lane's own target model (LegProof, Paravirt, the e1000e/metal NIC words) is not.

The three legs

A closure is closed when a kernel composed for the preset boots on that preset's own machine and shows, as applied output read off the guest by one static PID 1 (kernel::PROBE_INIT_C):

leg evidence read from
(a) storage the declared boot disk's node exists after devtmpfs stat("/dev/nvme0n1") etc., one row per bus, present OR ABSENT
(b) network an interface other than lo registered /sys/class/net — only a BUILT-IN driver that bound its device puts a name there
(c) console the kernel registered a console /proc/consoles

KernelProbe::closes(who, machine) judges the transcript away from the guest and names the leg that failed with what the guest reported. kernel::probe_script_for(k, Some(&machine)) renders the preset's machine; None is the historical qemu-local argv, byte for byte.

The machine: ProbeMachine::for_preset, and the two rules the boots taught

What lane AN measured on its own model (kernels composed over slim, 48–57 s each; the RED is the stock appliance bzImage on the SAME argv):

preset machine RED GREEN
aws-nitro q35 + -device nvme nvme ABSENT /dev/nvme0n1 present · console ttyS0 · ENA leg not exercised
gce-gvnic pc + -device nvme nvme ABSENT /dev/nvme0n1 present · console ttyS0 · gVNIC leg not exercised
azure-gen1 pc + -device ide-hd + Hyper-V enlightenments scsi-sd ABSENT /dev/sda present (ATA_PIIX + BLK_DEV_SD)

These are NOT yet measurements of release-3's closures. The door is here; the rows have to be re-run (tests/cloud_preset_closure_live.rs, #[ignore]d, its RED the_undeclared_kernel_fails_every_cloud_machine first) before measured may say so. Two differences are already visible from the model: release-3 renders azure-gen1 on a q35 stand-in (its chipset is NotQemu), where ide-hd rides AHCI and not PIIX, and DiskBus::Ide has no kernel bus word, so its closure asks for no IDE driver at all.

f11 — the kconfig SOLVER: the third class stops being a shrug (lane AP, 2026-08-30)

kernel.why.toml sorts every symbol of a composed kernel into three classes. Two of them answer a question. feature names the consumer that asked and why; preset-only names a fragment line nobody has claimed yet (f10's §"preset-only means UNDECLARED"). The third, kconfig-derived, was the rest — and the rest is nearly all of it:

measured on the shipped tunnr-kernel artifact (nuked over tinyconfig, linux 6.12.104)
rows in kernel.why.toml 849
feature 49
preset-only 36
kconfig-derived 764 — 90.0 % of the rows

Recomposed locally from the CURRENT fragment (which carries lane AN's MULTIUSER=n / WIREGUARD=n narrowings and so differs from the 13:36 artifact by two symbols), the same number is 765 of 820 live symbols = 93.3 %. Rickard's "~764" was exact.

Nothing could DECLARE those symbols, so nothing could EXPLAIN them: the engine could not tell "this is here because something needs it" from "this is here because Kconfig dragged it in". kconfig_graph.rs closes that.

What it reads

The donor's own Kconfig tree — the same 1 606 files olddefconfig reads, still extracted in the kernel workdir. 17 704 symbols, 14 584 select edges, 441 imply edges, parsed in 120 ms. It models select / imply / depends on (including the implicit gate an enclosing if / menu / choice puts on everything inside it) / default / def_bool / def_tristate, the source graph with its $(SRCARCH) expansion and its one glob shape, and a choice block's own default MEMBER line.

What it produces — the CHAIN

CONFIG_VT [declared] --select--> CONFIG_INPUT
CONFIG_SERIAL_8250 [declared] --select--> CONFIG_SERIAL_CORE
CONFIG_BLK_DEV_INITRD [declared] --default--> CONFIG_RD_ZSTD --select--> CONFIG_DECOMPRESS_ZSTD
    --select--> CONFIG_ZSTD_DECOMPRESS --select--> CONFIG_ZSTD_COMMON --select--> CONFIG_XXHASH
CONFIG_SMP [declared] --default--> CONFIG_SCHED_MC --default--> CONFIG_SCHED_MC_PRIO
    --select--> CONFIG_X86_AMD_PSTATE --select--> CONFIG_ACPI_CPPC_LIB --select--> CONFIG_PCC

Chain lengths over the 765: 96 zero-hop · 509 one-hop · 70 two · 23 three · 19 four · 5 five.

A chain ends at a head, and there are four kinds, because they are four different actions for whoever reads them:

head means what to do about it
declared a feature requirement or a preset fragment line pulled it in this is the knob; withdrawing it removes the symbol
base-config a PROMPT symbol the starting .config carried and nothing moved it is the donor preset's shape, not anybody's need
kconfig-default an unconditional default y on a promptless symbol Kconfig turns it on for everyone; no declaration can reach it
arch a default decided purely by the composer's environment ($(ARCH)) it follows arch =, nothing else

The prompt is load-bearing and was the single largest correctness fix in the lane. olddefconfig re-derives every promptless symbol from scratch and keeps nothing of the incoming .config for it; only a symbol a human could have typed carries its old value forward. Calling a promptless symbol "base residue" produced 307 chains that the inverse test would have refuted on the first try. With the rule in place base-config heads fall to 11 and declared heads rise.

THE VERIFICATION — the inverse, through the kernel's own program

A solver is both claimant and witness, so every test that reads its own output would pass just as happily on invented chains. The door that matters is tests/kconfig_solver_live.rs: for each declared head, withdraw the declaration, recompose the .config exactly as build_script does (base → merge → olddefconfig) with the kernel's own scripts/kconfig/conf, and see what really left.

Two arms, because neither alone is a test:

Measured over the shipped fragment, 55 declared heads, 55 real recomposes:

(a) sole-head symbols that really disappeared 112 / 112 = 100 %
(b) vanished symbols the closure reaches 569 / 570 = 99.8 %
withdrawals that proved nothing (the head came back) 9 of 55
declarations that fell WITH another declaration 49

Three things the inverse test taught, that reading the output never would

  1. A withdrawal another declaration undoes proves nothing. Take CONFIG_CRYPTO_CRC32C=y out of the fragment and it comes straight back, because LIBCRC32C selects it. Nine of 55 heads are like this. Scoring them either way is a lie; they are counted vacuous, by name, and the first scoring pass that did not do this reported 11 false "decorative chains" that were nothing of the kind.
  2. Causes and GATES are different questions. A symbol is present because something selected it AND because its dependencies hold, and withdrawing either removes it. ACPI_CPPC_LIB is caused by SMP (SCHED_MC_PRIO → X86_AMD_PSTATE) and gated by ACPI — so withdrawing ACPI removes a symbol ACPI never selected. The causal closure alone reached 459/570 (80.5 %); adding depends on took it to 560; adding the condition of a conditional select (X86 select GENERIC_IRQ_MIGRATION if SMP names two symbols and only one of them is the from) took it to 569/570. kernel.why.toml publishes the CAUSAL heads — "who wanted this" is the question being asked — and Solver::gate_closure is what the test scores against.
  3. The one that is left is a NEGATED gate. TREE_SRCU is default y if !TINY_RCU, and TINY_RCU is def_bool y if !SMP: it is on because another symbol is OFF. The solver follows live parents and a symbol held up by an absence has none. Named, not papered over.

What remains genuinely unexplained — 43 of 765 (5.6 %)

kind count what they are
toolchain probe 41 CC_HAS_*, AS_*, CC_IS_GCC, CLANG_VERSION, CC_CAN_LINK … — default $(cc-option,…) / $(as-instr,…) / $(success,…). These are decided by RUNNING the compiler, not by anything in the Kconfig text, so no declaration can ever reach them. They are why the same fragment yields CONFIG_GCC_VERSION=150200 on oden and 140200 in the toolchain container.
choice with no declared default 1 CPU_FREQ_DEFAULT_GOV_SCHEDUTIL — a choice whose block declares no default, so the pick is the first VISIBLE member and visibility is a prompt question this parser does not evaluate. Guessing it would be inventing.
string option with a prompt and no default 1 CONFIG_LOCALVERSION="" — kconfig writes the empty string. It is in the file and it is on nobody's account.

An unexplained row says so in its own unexplained field, with the mechanism spelled out; it never goes quiet. An empty chain with an empty unexplained means one thing only: the solver did not run here (WhyClass::derived_unsolved, the fragment-only index the planner builds).

The bug class this makes impossible instead of merely detectable

Lane AN composed a kernel for a machine with no UART. The target legally narrowed CONFIG_SERIAL_8250=n; kconfig/base.config's own CONFIG_SERIAL_8250_CONSOLE=y stayed; olddefconfig dropped the child because it depends on SERIAL_8250=y; the canary refused by name — after a fetched tarball, a toolchain container and 48 seconds. UpCloud is such a machine.

KconfigGraph::dangling_declarations reads the merged fragment as an open world (a symbol it does not mention is unknown, not off — 84 of 17 704 symbols are named, and reading the silence as n would invent contradictions by the hundred) and reports only what the fragment says about ITSELF. On lane AN's exact fragment, against the real 6.12 tree:

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. `olddefconfig` will DROP it and the
build's canary will then refuse by name, after the container has run. Narrow the child with
its parent, or stop declaring it

and silent on the shipped fragment — zero false positives over its 84 declarations. It is wired into build_kernel_with as a refusal BEFORE the container starts, and honestly limited: it needs the Kconfig tree, which lives inside the tarball, so it bites on every re-build and every ISO compose and the canary stays the backstop for a cold first build.

One cp, and why

build_script now copies the starting .config out as kernel.base.config. Without it the whole base-config head class is a guess. It is not fatal to be missing — a kernel built by an older script has none and the rows keep their unsolved shape, because a feature that DESCRIBES an artifact must never be able to veto it.