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

nordisk/dwarves

.nornir/build-thing-code-is-source-design.md

build thing — code is the source, toml is emitted — design

Class: design · Function axis: f0 code is the one source · f1 the emitter (code → toml, one-directional) · f2 the per-typ consequence engine · f3 bijection by construction (no parity test) · f4 one evaluator behind two doors (CLI ↔ ⚒) Strict: this file states only what the build-thing declaration model IS after the 2026-08-27 decision. It refines build-thing-model-design.md: where that file calls .nornir/build-variants.toml "the still part", this file corrects it — the toml is not a source, it is a projection. Written down 2026-08-27 from Rickard's decision, in his vocabulary: variant → features → settings / sub-settings → typ. Never "cell".

A build thing is the code that builds — the Rust impl of the trait — and that code is the single source of every variant, feature, setting/sub-setting, typ and BUILT edge.

THE RULE (Rickard, 2026-08-27): a .nornir/build-variants.toml must NEVER exist as a committed, read file — in any repo. The code (the trait impl, compiled to the wasm module) is the only source. TOML exists only as on-demand OUTPUT the code emits to describe a build (info --format toml and the like) — it is produced when someone asks to see it, and nothing is stored and nothing reads it back. Code → toml, one-directional, ephemeral. There is nothing to keep in parity, so the parity test dies and the bijection holds by construction. A committed toml that anything reads is the twin, and it goes.

The kernel is why this must be code and not a file: toggling a kernel setting does not change dependencies, it flips Kconfig modules in and out of the bzImage — a computation, not a table lookup. Only code can compute it, and the same code both describes it (emits toml) and performs it (builds).

The system as one — the function map (f0)

part lives in what it is
f1 the source each repo's <repo>-build-thing impl BuildThing the ONE declaration of variants/features/settings/typ/edges, in Rust. Never parsed from a file
f1 the emitter edda nornir-build-thing (code → toml) a pure projection of the impl to a toml that DESCRIBES the build. Output only; the committed toml, if kept at all, is its artifact
f2 cargo consequence edda variant.rs (closure / leaf_closure) for typ cargo: a toggled feature → the dependency/feature closure delta. wireguard off → boringtun/wg_appliance_core absent (measured 70 → 0, 57 → 0)
f2 kernel consequence edda kernel.rs (merge_kconfig / merged_fragment) for the kernel: a toggled setting → the Kconfig module delta. kernel.proc-fs, kernel.memfd-create, … flip CONFIG_* in/out; consequence is measured in modules and the bzImage blake3, not a Cargo.lock
f3 bijection edda laws.rs (assert_bijective) the choice → the receipt fragment sha, and its inverse names the choice from the result (law D). One writer, so toml_parity is unnecessary
f4 the two doors dwarves buildthing (CLI) · dwarves-gui (⚒ pane) thin doors. Neither holds declaration logic; both call nornir-build-thing. With --server the CLI delegates to the forge, so the SERVER's nornir-build-thing is the one evaluator
f4 the chain runner forwards the step dwarves build_steps.rs (run_build_chain) → edda kernel::set_for_kernel_step a kernel step is built from the declaration the plan pinned: the step's kernel.* line + the consumers' requires landed on the donor's row by the ENGINE's bridge (chosen_kernel_for); the pin check (resolve_built_kernel_for) and the consumer's kernel-edge reader (requirements_of_consumer_line) resolve WITH the closure. The forge never re-derives the closure or spells the merge. Measured 2026-08-29 before: kernel.why.toml feature = 0, preset_only = 69; after: every appliance-boot row names its requirer

Why code, not toml — the decision

The recipe used to live in both places: the Rust recipe.rs and a committed toml, held equal by gunnar/crates/gunnar-build-thing/tests/toml_parity.rs, with bijection_laws.rs reaching the toml half "transitively through toml_parity's equality". Two hand-maintained copies watched by a test is the twin LAW 5 forbids — the same shape as "cell", one artifact with two names.

The inversion: the impl is the source. Two pure projections fall out of it, and only out of it:

Because both projections read the same source, the receipt is bijective with the choice by construction. toml_parity guarded an equality that can no longer be violated, so it is deleted and its guarantee moves into the single writer. An edited toml has zero effect on a build — that is the load-bearing test.

The consequence is typed (f2)

Build thing does not list variants; it computes the consequence of a choice, and the consequence's type depends on typ:

The two do not share one closure function. The trait carries a per-typ consequence method, and that separation is the point, not an accident.

Place in the nordisk constellation

The evaluator is one crate, edda/crates/nornir-build-thing — the trait, the declaration reader, the per-typ consequence engine, and the emitter all live there. Every <repo>-build-thing crate implements the trait for its repo. dwarves buildthing (CLI) and the ⚒ pane in dwarves-gui are thin doors onto that crate — the logic is never copied into the GUI crate or the CLI binary. With --server <url> the CLI delegates the build to the forge over the same RPC the pane uses, so even at runtime there is a single evaluator: the server's nornir-build-thing. Acyclicity holds: repos depend on nornir-build-thing, never the reverse.

The build-things and their tillskott

Sixteen canonical repos declare a build space. Six already carry a Rust recipe (impl BuildThing) and need only the twin removed — the committed toml becomes emitted output, toml_parity deleted:

repo code recipe today
dwarves crates/dwarves/src/build_thing.rs + build_thing/code.rs
edda nornir-build-thing plugin.rs / laws.rs
gunnar gunnar-build-thing/src/recipe.rs
korp korp-build-thing/src/recipe.rs
knut knut-build/src/lib.rs
holger recipe lives in edda/crates/nornir-buildspec-holger/src/lib.rs

Ten declare only via toml and need the tillskott — a Rust impl BuildThing that IS the source, from which the toml is emitted: Skidbladnir · draupnir · korp-collectors · modgunn · nornir · ordning · skade · tunnr (the kernel) · znippy · znippy-zoomies. tunnr is the special case: its consequence is the Kconfig delta, not a dep closure.

Tests (inject-assert, LAW 2)

Discovery and provenance — search returns a proof, not a find (f5)

Today "search gunnar" is a filesystem scan: dwarves buildthing (CLI) and the ⚒ pane both go through dwarves/crates/dwarves/src/build_thing.rs — default_root() finds the constellation root, scans subdirectories for a checkout that declares a build space (has a .nornir/build-variants.toml), and matches the term. That scan is fragile: on 2026-08-27 default_root() picked /home (1 repo) over /home/rickard/git (34) and answered "ingen deklaration under /home nämner gunnar" (fixed: the root now counts members, richest wins). It is a find, and a find can be wrong.

The intended shape is a triangle, and it is the build-supply-chain application of a pattern Google standardised:

The standard is SLSA (Supply-chain Levels for Software Artifacts, Google 2021). BuildProvenance is a SLSA provenance predicate field-for-field: builder = the forge; buildType/invocation = the variant + --opt/--param; materials = the source + the kernel edge (tunnr/tunnr-kernel); subject = the artifact's fragment sha / kernel blake3; the signature over all of it = ProvenanceSealer. The Google root beneath SLSA is Certificate Transparency (RFC 6962) — a signed, append-only Merkle log with inclusion proofs.

What is live vs. what completes the triangle. Live today: the signed provenance (CMS seal, server-written) and one evaluator behind both the CLI and the pane — dwarves buildthing --server delegates the build to the forge over dwarf.v1.BuildThing, proven byte-identical to the in-process build (znippy pin + kernel blake3 + donor pin identical, cmp -l = exactly the 39 FAT-timestamp bytes), and buildthing search <term> / info <product> answer from the forge's declarations, never a local scan. Missing to close it: (1) a DSSE envelope if SLSA interop is wanted — the CMS seal does the same job over the same digest, only the envelope differs; (2) the Certificate-Transparency-style append-only log with inclusion proofs, so discovery returns a proof and not merely a record; today search reads the recipe, it does not yet demand an inclusion proof. That step is what turns "find the recipe" into "prove the recipe".

Signing — CMS today (airgap), DSSE for SLSA interop

The signing seam already exists; the interop envelope is what is being added (2026-08-27).

part lives in what it is
the primitive znippy/znippy-common/src/sign.rs detached CMS SignedData (RFC 5652), Ed25519 / P-256-ECDSA, airgap, no network (der/ed25519-dalek/p256/sha2). SigAlg, signer_from_pkcs8, dev::mint_ca
the sealer nornir/src/build/seal.rs (ZnippySealer) a ProvenanceSealer wrapping a znippy signer; today emits a CMS Seal, travels as airgap <artifact>.build-provenance.znippy
the seam edda/…/nornir-build-thing/src/provenance.rs ProvenanceSealer::seal(digest) -> Seal, Attestation = record + canonical bytes + digest + seal
the verifier modgunn/src/provenance.rs already knows SLSA levels 0–3, sigstore/cosign/rekor/slsa-verifier, and parses Rekor intoto/dsse incl. content.envelope.signatures[].keyid — the DSSE envelope shape is already modelled on the verify side

So half the code exists: a producer (CMS) and a verifier that already understands DSSE/SLSA. The gap is a DSSE envelope signing mode in znippy-common::sign, beside CMS, not replacing it — DSSE per spec (PAE "DSSEv1" SP len(payloadType) SP payloadType SP len(payload) SP payload, payloadType = application/vnd.in-toto+json, {payload, payloadType, signatures:[{sig, keyid}]}) signed with the SAME znippy keys, carrying the SLSA-predicate payload. CMS stays the airgap default; DSSE is the interop option (--provenance dsse|cms / a declared setting on the seal seam). External SLSA tooling (cosign / slsa-verifier) verifies the DSSE form; modgunn verifying our envelope is the internal producer↔verifier agreement test. What only an online cosign run can confirm is left named, not claimed.

Two ways to name a build thing — coordinate or search

Discovery has two inputs, the same two a package manager gives you:

Today both go through the local checkout scan (ConstellationBuilds); the registry-backed form — the coordinate resolving a published wasm crate @version out of the registry, search becoming an index query — is the same staged step as the read-path inversion.

Can SLSA/DSSE be verified ON the build thing itself? Yes — and it MUST be

The recipe is a published crate whose wasm cdylib the forge loads (gunnar-build-thing/korp-build-thing are crate-type = ["cdylib","rlib"]; a build thing that is not a cdylib is refused by name). So the recipe is itself an artifact that was built — and therefore it can carry its OWN SLSA build provenance, exactly like the media it produces. There are two levels of provenance:

  1. the recipe's own — the <repo>-build-thing wasm module was built from source X by the forge; that build has a SLSA provenance and a DSSE/CMS seal.
  2. the artifact's — the medium the recipe builds carries the provenance described above.

And verifying the first is not optional, because plugin_host.rs already treats a loaded module as hostile: "the host builds a wasmtime::Linker and defines nothing on it, so an import from a registry is untrusted input, and it is treated as such." Verifying the recipe's DSSE/SLSA attestation is precisely how that untrusted import earns trust. So the load path becomes: resolve the coordinate → fetch the wasm crate @version → verify its DSSE envelope / SLSA provenance (ideally with a Certificate-Transparency inclusion proof) → only THEN instantiate and run it. A recipe whose provenance does not verify is refused before it can declare or build anything.

This is the supply-chain answer to "search gunnar returns a proof, not a find": the coordinate resolves a signed, attested recipe, the forge verifies it on load (reusing modgunn's DSSE/in-toto verifier), and only a verified recipe gets to run — and then the medium it builds carries its own verified provenance in turn. Trust flows from the recipe down to the artifact, both sealed, both checkable.

Build isolation — an attested build goes in a container, and the Rust starts it via draupnir

The dividing line is not kernel vs Rust — it is dev vs attested:

The evidence that containerising only the kernel is not enough is Part A itself: the app's musl binary, built on the host, baked its absolute build path (/home/dwarves/.nornir/workspaces) into itself, and the app pin became non-reproducible — two identical-source builds produced different pins. That is host-built Rust breaking reproducibility exactly as an uncontainerised kernel would. A container fixes the toolchain digest → same compiler every time; --network none → hermetic, no supply-chain injection mid-build; a fixed path inside the container → the pin reproduces; and it is the isolation boundary SLSA Build L3 asks for. The containers already exist for both, so this is a policy, not new infrastructure: the forge builds everything it attests in its containers; the host is for dev only.

Who starts the container: the Rust, via draupnir — never the operator or an agent by hand. The layering is strict:

operator / agent  →  dwarves buildthing …          (drives the forge CLI, nothing lower)
                  →  the forge's Rust               (nornir-build-thing)
                  →  DraupnirKernelRunner           (KernelContainerRunner, feature `kernel-build`)
                  →  draupnir                        (backend-oci / youki)
                  →  the OCI runtime                 →  make bzImage / cargo build

DraupnirKernelRunner is the production KernelContainerRunner, running over draupnir's backend-oci engine; kernel.rs sets KBUILD_BUILD_USER=nordisk KBUILD_BUILD_HOST=draupnir inside the container, which is why a real container-built kernel's strings reads version 6.12.104 (nordisk@draupnir) — that string IS the proof the build went through draupnir, unforgeable on the host. This is the same law as "build through Build Thing, not by hand", one layer down — and it is what makes a --build kernel that resolves the pin cache, or a hand-staged bzImage, a defect rather than a shortcut.

THE RULE (Rickard, 2026-08-27): NO SHELLING. An operator or an agent NEVER shells a build — no podman run, no youki, no hand-run make, no cargo build staged into an artifact, no xorriso. The ONLY thing you invoke is the forge CLI (dwarves buildthing …); the forge's Rust drives draupnir, draupnir drives the container. Anything that reaches around the forge to produce an attested artifact by hand is void — it has no provenance, no reproducibility, and no trust. If the forge cannot do it, the fix is to make the forge do it, never to do it yourself in the shell.

Source map

See also: build-thing-model-design.md (the tree, the ABI, the laws A–E — this file refines its "still part" line), the-two-dags.md, buildthing-guide.md.