The build-thing — design
Class:
design· Function axis:f0(the build-thing as one system) +f1variants config ·f2generator backends ·f3provenance attestation ·f4the surface + CLI ·f5container/VM target kinds + provenance-over-produced-artifact ·f6TEST-BOOT (spin a built target up as a live job) ·f7the unifiedassemble()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:
- A product declares its buildable goals — each a
(package × bin × features × profile × target)point — as TOML data, not as rememberedcargoincantations. nornir validates a chosen goal against the space the repooffersand materializes the exactcargo buildargv. - 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:
- The bollard pair is gone —
679a6ff(2026-07-12, "repoint the test-boot container engine onto draupnir — no 3rd bollard copy") replaced it withDraupnirContainerBootRunner. No symbol namedBollardBootRunner,BollardEngine,ContainerEngineorFakeContainerEngineexists in the file any more; the doc kept citing all four for six weeks. - Every line number in this section was wrong, by 3 to 135 lines (
BootMode50→51,BootTarget::mode147→151,ExeBootRunner334→338,BootRunner227→231,BootProc207→211,DispatchBootRunner890→767,FakeBootRunner1196→1092,from_vm185→189). A citation that is off by one is what teaches a reader to stop following citations at all. - "until tunnr lands" is the stale one that matters. tunnr landed: it is
ssh://git@git.gunnar.rs:2222/mikronova/tunnr, cratetunnr-vm, andboot_test(BootSpec) -> io::Result<BootHandle>issrc/boot_primitive.rs:464. What did not land is nornir's edge to it —Cargo.toml:186still 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'sVmmode is the honest stub in every configuration a person can build, and the#[cfg]'d real runner is dead code nocargo buildcan 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
precheck(variant) -> Ready | Missing(reasons)— the runtime gate before building: toolchain present,--targetinstalled, container socket / KVM up. It feeds f4'svalidflag with reality, wherevalidate(f1) only proves config-validity. Reuses the existing probes (generatoravailable(), the test-boot engine probes).- composite ordering (the fat/thin payoff) —
embeds/reaches(service.rs) and containerfrom_variantlayers declare a sub-build DAG;assemble(fat)must topo-assemble its parts (the embedded lib first) and thread each sub-artifact'scontent_idinto the parent's lineage. This is the fat/thin axis becoming executable, not just declarative. - the receipt as the return (
Assembled, above).
from_variantis 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, whatkernel_donordoes 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 foredges) is filed in cross-repo-from-variant-design.md.
- idempotence —
assembleis a no-op when the content-addressed output already exists (skip the rebuild; still emit asteprow 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:
BuildThingis already the f4 Elm UI model (elm.rs). f7's seam is the verb — a free fnassemble(set, variant, ctx) -> Assembledplus a thinAssembletrait for the injected container/VM builders — not a secondBuildThingtype.
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
- Declared at the CELL level, keyed by parameter name — deliberately not a
ParamSpecfield.ParamSpecisdeny_unknown_fields; a new row key would make the recipe UNLOADABLE for every consumer on a published 0.1.x reader (gunnar's parity mirror reads its own recipe through the registry 0.1.7, and it must stay green), while an unknown cell table is ignored by those readers and read by this one.validate_fixturesruns at declaration: the name must be a declared parameter, the parameter may not name a secret, and the kind must fit the landing (wg-hubrenders a hub.conf FILE ⇒kind = "text"+staged_file). - Supply:
--param hub_conf=@fixture(params::FIXTURE_VALUE). Exactly one of value /@path/@fixtureapplies;@fixtureon a parameter with no declared fixture is refused by name and says what to declare. - Minting is injected, not a feature.
params::FixtureSourceis the seam;InstallerComposer::fixtures()(default:NoFixtures, which refuses by name) is how the composition hands it in, andkorp_installer::isocompose::ForgeComposer— the one composition every door reaches — answers withkorp_installer::wgfixture::DEFAULT. nornir-build-thing stays dep-light (nowg-appliance-core); the mint lives once, in edda. - What
wg-hubmints.HubFixture::reserve()(wg-appliance-core): a keypair + a free UDP port, BEFORE any appliance exists. The PUBLIC half is rendered through the ONEhub.confrenderer (render_hub_conf, round-tripped against the ceremony parser): hub public key,Endpoint = <this box's reachable address>:<port>(discover_endpoint_ip, pure std;NORDISK_WG_HUB_ENDPOINToverrides — a slirp guest reaches its host at 10.0.2.2),AllowedIPs = 10.77.0.0/16,PersistentKeepalive = 5,[Interface] Address = 10.77.0.2/16(the ceremony refuses a hub.conf without one). The PRIVATE half never travels as a parameter: it is written 0600,create_new, under the forge's own state dir ($NORDISK_FORGE_STATE_DIR|$XDG_STATE_HOME/nordisk-forge|~/.local/state/nordisk-forge, sub-dirwg-hub/, filewg-hub-<pubkey-hex16>.key) — no/etc, no root. - The record says so, never silently.
AppliedParam { fixture: Some(WgHub), fixture_note: "wg-hub pub=… endpoint=… key=<path>" }rides the attested descriptor; the dwarves pane prints it beside the receipt. - The proof re-opens the hub from the conf alone.
HubState::for_hub_conf(state_root, text)parses the staged hub.conf through the ceremony parser, finds the key file by public key, andspawn_open()s the hub on the reserved port.HubFixture::spawn_openis the two-phase shape: the appliance's key does not exist when the ISO is baked, so the hub LEARNS it from the first initiation that decrypts under its private key and NAMES it inHubVerdict::learned_peer— a proof asserts it equals the identity line the appliance printed, so an open hub still cannot green-chip the wrong box. The verdict stays three-valued (initiations / responses / sealed_packets) plusecho_replies.
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:
kernel::chosen_kernel_for(donor, name, opts, requires)is the ONE chosen declaration: the step'skernel.*line throughresolve_kernel_cli+apply_decision, then the consumers' requirements landed AFTER it (apply_requirements— judged against the decided preset, refused by name over a veto). The pin (kernel_step_pin_for), the reader (resolve_kernel_for) and the build start here, so they cannot disagree.kernel::kernel_build_of_step(donor, &BuildStep)/set_for_kernel_stepis the bridge a chain runner calls: theKernelBuild(or the donor's set with that row replaced) thatKernelBuilder::buildis handed. dwarves'run_build_chaindoes exactly that and nothing else (donor.set = set_for_kernel_step(..)).kernel_requires::requirements_of_consumer_line(set, repo, variant, opts)is the same closure read from a DOOR's--optline, for the consumer's kernel-edge reader (dwarvesedge_kernel→resolve_built_kernel_for) — one closure, two readers, so the pin the consumer verifies is the merge the chained build pinned.
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:
terminal: Option<Terminal>— the guest's sentence, VERBATIM, so a row quotes the appliance instead of paraphrasing it.timed_out: bool— becauseexited: Noneused to mean BOTH "the gate formed and the door killed the guest" (the shape of every pass) and "nothing ever happened". One field, two opposite meanings, and only the caller's other assertions to tell them apart.
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 becausekorp_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:
BuildStep::requireswasOption<KernelRequirements>.visitcomputed a genericSurfaceClosureand 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 isOption<SurfaceClosure>now, withkernel_requires()/tool_requires()as the typed accessors (one model, two readers — never a twin).- The pin was computed inside
visit, behindif edge.typ == Kernel, twice (once on the first visit, once on the reached-twice join). It isstep_pin_fornow: one dispatcher, one arm per surface, and adding a surface is adding an arm rather than anotherifin the recursion. A container step pins the sha ofToolRequirements::canonical;StepPin::ledger_keyis empty for it, because the toolchain surface has no ledger and saying so beats inventing one. SurfaceClosure::unioncould 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 image is here → use it,
already-present - an archive at
<cache>/images/<pin>.tarwhose id is the pin → inspect (no daemon), load, run by id,staged-archive - an archive holding something else →
NORDISK-TOOLCHAIN-ARCHIVE-MISMATCH, and the file is never handed to an engine - neither, with a pin declared →
NORDISK-TOOLCHAIN-MISSING, naming the path - neither, with NO pin → proceed. An unpinned image is an ONLINE declaration and this module does not invent a policy the recipe never stated, but it records
unpinnedso the receipt cannot be read as an airgap claim.
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
- "make additive fields ignorable" — drop
deny_unknown_fields— converts the loud failure into four more quiet ones. The four paths above were ALREADY ignorable; that is exactly how they were lost. It reproduces the silent-drop bug in a new costume. - "an explicit schema version" alone is
deny_unknown_fieldswith a nicer message, unless the refusal is CONTENT-driven: if every emission bumped the schema, every old reader would refuse every file and nothing would be gained.
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
crates/nornir-build-thing/tests/recipe_faithful.rs— every repo's REAL recipe read in full by this reader, plus R1 over the same roster. Seen red on gunnar before its header landed, naming all seven declarations that ask for schema 2.nornir-build-thing-skew/— a self-rooting package (outsidecrates/, so edda's own test run never fetches a published copy of edda's own crate) that deliberately links the OLDEST supported reader (=0.1.10, published) and the newest in ONE process. It fails when they disagree in a way the recipe's declared schema does not explain — which is the exact defect above — and carries a permanent red probe that strips gunnar's header from the bytes and asserts the four swallowed paths come back. Run it deliberately:cargo test --manifest-path nornir-build-thing-skew/Cargo.toml.
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:
- In-constellation (path dep) — gunnar, korp, holger, nornir, tunnr, znippy, znippy-zoomies, modgunn, ordning, facett, dwarves, knut, Skidbladnir, edda's own leaves. The floor is what the path actually resolves to (
0.1.12today), because with a path dep that IS the crate being compiled, and any lower number is a claim no build ever tests — which is how korp said0.1.10while compiling 0.1.11, and holger said0.1.0while compiling 0.1.8. The publish cascade this implies is correct and is what dwarves' release doctor already models. - Published-consumed (registry dep) — skade (dev-only, deliberately no path: a hardcoded
../nornir/...broke any worktree whose dir name was notnornir) andmodgunn's own optional edge, which reaches holger · nornir · znippy · knut as a TRANSITIVE registry dependency. Their floor must be a version that exists on crates.io —0.1.11— or every downstream resolution fails. Raising modgunn's"0.1"to0.1.11is what collapsed znippy's second copy from 0.1.3 to 0.1.11: eight generations of skew inside one graph, gone. - Never a bare
pathwith no version at all (Skidbladnir's shape until 2026-08-30): that is the float with nowhere left to read it. - korp's double pin is resolved by a
[workspace.dependencies]entry that all four sites inherit with{ workspace = true }. One writer.
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
- The storage leg must be the real device, or the preset is UNPROVABLE HERE, said in a sentence. A substitute disk proves nothing about a kernel composed for the real one — it carries the real driver, not the stand-in's.
- A substitute NIC is NO NIC. ENA and gVNIC were first given an
e1000e"for the same leg", and the kernel composed foraws-nitro— which carriesenaand note1000e— booted with/sys/class/net = [lo]. A stand-in only means anything when the kernel under test carries the stand-in's driver. So a NIC facet that is notExactrenders-nic noneand leg (b) is reported UNPROVEN, by name, never failed and never green.
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:
- (a) causal — every symbol whose ONLY head is H must be GONE. A solver that claims nothing passes (b) alone.
- (b) complete — every symbol that DID disappear must be reachable from H. A solver that claims everything passes (a) alone.
- and a floor on the size of the sole-head set (112 measured), because "claims everything" is exactly what destroys soleness and would otherwise make (a) vacuous.
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
- A withdrawal another declaration undoes proves nothing. Take
CONFIG_CRYPTO_CRC32C=yout of the fragment and it comes straight back, becauseLIBCRC32Cselects 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. - 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_LIBis caused bySMP(SCHED_MC_PRIO→X86_AMD_PSTATE) and gated byACPI— so withdrawingACPIremoves a symbolACPInever selected. The causal closure alone reached 459/570 (80.5 %); addingdepends ontook it to 560; adding the condition of a conditionalselect(X86 select GENERIC_IRQ_MIGRATION if SMPnames two symbols and only one of them is thefrom) took it to 569/570.kernel.why.tomlpublishes the CAUSAL heads — "who wanted this" is the question being asked — andSolver::gate_closureis what the test scores against. - The one that is left is a NEGATED gate.
TREE_SRCUisdefault y if !TINY_RCU, andTINY_RCUisdef_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.