build thing model — design
Class:
design· Function axis:f0the build-thing model (the whole) ·f1the tree (feature ▸ setting ▸ sub-setting) ·f2the impl answers the tree (wasm ABI) ·f3typed consumption edges (--build X) ·f4presets asvalnodes ·f5kinds bear their verbs ·f6the laws over the tree Strict: this file states only what the build-thing model IS — its parts and why they are shaped this way. Sibling: buildthing-guide.md (the ⚒ pane and the CLI door); the trait lives in edda:nornir-build-thing. Written down 2026-08-26 from Rickard's rethink, in his vocabulary: variant → features on the variant → settings / sub-settings → typ. Never "cell".
A build thing is functionality, not a table (edda plugin.rs). Every repo that can be built ships a <repo>-build-thing crate, compiled both native and wasm32-unknown-unknown — one source, two link targets — and the forge (CLI and ⚒) loads the wasm and calls it. What ⚒ draws and what dwarves buildthing accepts are the same answers from the same module, so the two doors cannot drift. The .nornir/build-variants.toml is the still part (variants, kinds, edges, the choices made); the impl is the moving part (what can be chosen).
The system as one — the function map (f0)
| part | lives in | what it is |
f1 the tree |
edda options.rs / params.rs |
one recursive tree per variant: feature (on/off), setting (typed value), val (one-of). Paths are the ids: dhcp.static_ip, kernel.preset, kernel.unix |
f2 the impl answers |
edda plugin.rs (BuildThing) |
variants() (static), options(path) (dynamic subtree), try_toggle(path, on) (the judge, cascade included), actions(kind) (verbs). Same methods over the wasm ABI |
f3 consumption edge |
edda tree.rs (ConsumptionEdge, EdgeDecl, wrong_typ); installer.rs kernel_donor is its kernel-typed spelling |
a feature bound to a coordinate repo/variant of a required typ: kärna ↔ tunnr/tunnr-kernel : kernel. Types must match at declaration time — and the edge IS a feature, so the line that switches it off takes the donor out of the plan |
f4 presets |
edda kernel.rs (Preset) → general val |
a val node bundling defaults for the subtree under it — kernel.preset = full|normal|slim|nuked; the same node type at a variant's root is the "preconfig" (preset = airgap) |
f5 verbs |
dwarves build_scene.rs (kind → pane) → edda data |
the artifact's typ on the receipt decides RUN / BOOT / UNPACK; an impl may refine via actions(kind) |
f6 the laws |
edda laws.rs |
A declaration ↔ struct · B tree node ↔ declaration row · C path ↔ flag · D decision ↔ fragment · E receipt → recipe. Every impl calls assert_bijective |
The tree (f1)
gunnar-installer (variant · typ installer)
├─ wireguard (feature) └─ wireguard.hub_conf (setting)
├─ dhcp (feature) └─ dhcp.static_ip, dhcp.gateway (settings, when off)
└─ kärna (feature, edge → tunnr/tunnr-kernel : kernel)
├─ kernel.preset (val: full | normal | slim | nuked)
└─ kernel.<symbol> (features, from the impl's model; cascade computed)
Three node kinds and recursion — nothing else. The kernel is not a special leaf; it is the first feature whose subtree is deep and delegated. Today's OptionSpec is flat (params_when_on/off, bakes); the tree adds children and the val kind, and keeps the flat spellings as unambiguous aliases (--param static_ip= ≡ --param dhcp.static_ip=).
The two doors are one call (f2)
| click in ⚒ | the same on the prompt |
| gunnar ▸ gunnar-installer | dwarves buildthing gunnar --variant gunnar-installer |
| ☑ wireguard ▸ hub_conf = @fixture | --opt wireguard=on --param wireguard.hub_conf=@fixture |
| ☐ dhcp ▸ static_ip, gateway | --opt dhcp=off --param dhcp.static_ip=10.0.2.15/24 --param dhcp.gateway=10.0.2.2 |
| ☑ kärna ▸ preset ▸ vetoes | --opt kernel.preset=nuked --opt kernel.unix=off |
| [BYGG kärnan] | --build kernel |
The path prefix is the brace: --build tunnr { --opt unix=off } and --opt kernel.unix=off are the same statement, because the feature path ↔ the coordinate through the declaration.
What the wasm cannot do, and must not: I/O. The module has no imports (no WASI — by design, see plugin.rs). An impl that needs the kernel source to answer options("kernel") asks the host through one narrow call, fetch(coordinate) → bytes; dwarves fulfils it (skidbladnir / draupnir, a container if that is what it takes). Capability is granted explicitly, never as a filesystem right.
Consumption edges and --build (f3)
Two kinds of dependency a variant has:
| kind | flag | meaning |
| ready | --opt <feature>=on|off |
libs/features already in the recipe — pointed at |
| built | --build <X> |
another build thing whose artifact is consumed — built as a step, with its own --opt |
X is a coordinate repo/variant; the short form (--build tunnr, --build kernel) resolves through the declaration when unambiguous. Without --build, the consumed artifact must exist and match its pin (fragment-sha proves preset + vetoes at once) — otherwise a refusal that spells out the --build line. The order of a chained build falls out of the edges: a DAG over build things, run by runa.
Presets, pre-built (f4)
full · normal · slim · nuked are the four kernel presets — a val, not a flag of their own (kernel.rs: "a preset is not a separate mechanism from the per-symbol rows: it is the bundle of defaults the rows start from"). A preset with no vetoes always yields the same fragment-sha → the same pin → the bzImage can sit built in the ledger, so picking it costs nothing; only a veto forces a build. The pin lookup belongs in the suite warehouse / ledger, not a local .kernel-out/ directory.
Every consumer declares its surface requirements (f5)
A feature of a consumer declares what it needs of the sub build-thing behind an edge, next to the feature, with a reason. The engine collects, unions recursively, refuses by name and INVERTS. In the inverted index a symbol that only a fragment set is preset-only — and preset-only means UNDECLARED, not unused.
That last sentence is the rule this section exists for, and it is the one that was missing. The inverted index (kernel.why.toml) sorts every symbol of a built kernel into three classes:
| class | what it means | what to do about it |
feature |
a consumer's feature asked, and the row says who and why | nothing — this is the model working |
preset-only |
a fragment set it and no feature asked | find its owner. It is a candidate to nuke only AFTER somebody has tried and failed to own it |
kconfig-derived |
in the built .config, named by nobody (the base + select/depends) |
out of scope: a solver's problem, not a declaration gap |
The trap is reading preset-only as unused. It is not evidence about the product at all until the consumers have spoken; with three features declaring, sixty symbols are "preset-only" and fifty-nine of them are load-bearing. So the index cannot answer "what can we remove?" until it can answer "what does everything need?", and the second question is the one the consumers answer.
The law: every POSITIVE line of a shipped fragment has a requirer, and each line that has none is named IN CODE with the reason nobody can own it. It is a test, not a habit — kernel_requires::every_positive_fragment_line_has_an_owner_except_the_ones_named_here asserts the exception list literally, so a new unowned line fails the build with its own symbol in the message.
The bijection, both ways. A declaration that cannot fail is not a declaration, so each one is proved in both directions: turning the feature OFF must REMOVE the requirement, and turning it ON must bring the symbol back BY CLOSURE rather than by a fragment line. The worked example is CONFIG_TUN (measured 2026-08-30, gunnar's gunnar-installer-open over tunnr's nuked donor): --opt wireguard=off produces a kernel in whose merged fragment and built .config the symbol does not appear at all; --opt wireguard=on puts it back, class feature, requirer gunnar/gunnar-installer-open : wireguard, +12 288 B of bzImage. A fragment line could only ever have been half of that.
Where a row lives. Next to the feature: an option's own kernel_requires, the group that owns the fact (target.*, data.*), or the PID-1 boot contract for what every appliance needs whatever it declares. The same symbol may have several requirers and that is the union, not a conflict — CONFIG_INET is required by gunnar's dhcp option AND by the boot contract, because PID 1 writes an AF_INET address before any option exists. Two requirers who DISAGREE, or a requirement over a donor's veto, is a refusal naming symbol · consumer · feature · reason.
A negative is a declaration too. =n over a donor =y is a NARROWING and it lands (target.consoles = [vga] takes SERIAL_8250 out); =y over a donor's veto is refused. And an =n beside a donor veto that already says the same thing is the strongest shape there is: the donor's fragment states the absence, the consumer's row states why it is safe, and a feature that later asks for the opposite is refused by name instead of quietly regrowing the subsystem (CONFIG_IPV6, CONFIG_WIREGUARD, CONFIG_MULTIUSER).
Values, not just booleans. The surface carries y · m · n, a string (CONFIG_FAT_DEFAULT_IOCHARSET="cp437") and a decimal int (CONFIG_NR_CPUS=64). The int variant is what made the SMP ceiling declarable at all: as a string it would have merged CONFIG_NR_CPUS="64", which kconfig drops on olddefconfig and the canary then refuses. A hex int option is still not expressible, and the type says so where it is defined rather than failing quietly.
Measured, 2026-08-30 (the kernel gitgunnar.cloud runs, tunnr-kernel over nuked):
| feature | preset-only | kconfig-derived | bzImage | |
| three features declaring | 49 | 36 | 764 | 2 388 992 B |
the surface declared, wireguard=off |
65 | 19 | 764 | 2 364 416 B |
the surface declared, wireguard=on |
66 | 19 | 764 | 2 376 704 B |
The 19 that remain are 18 # CONFIG_X is not set vetoes of the donor's own preset — sound, USB, wireless, DRM/FB, legacy storage, the IPv6 family: a product decision of the DONOR, which no consumer can honestly claim, each with its reason where the line is — plus CONFIG_UNIX, the one positive line left, kept preset-only on purpose because no PID-1 path is measured to open an AF_UNIX socket and a requirement is a measured need, never a habit.
Where a group lives, and how it is typed (f5b, lane AF 2026-08-30). A declared setting GROUP — [build.installer.data]'s fs/io — is a set of val nodes at reserved paths on the consumer's tree (tree::DATA_FS_PATH, DATA_IO_PATH), exactly the road the root preset val already took: ignored by the option-row loop, read back BY PATH, compared as a row by law A. Three things follow, and each one closes a measured defect:
- the group is spellable —
--opt data.fs=fat,--opt data.io=safe— and moving it moves the DONOR KERNEL too, because the closure reads the group off the resolved set (ConsumerView::declared_data). Before this, lane N flippedfs = "fat"in a scratch worktree to build a FAT medium; - the group is listed —
flags_ofrenders every choice, so--list-optionsneeds no listing code of its own, and the summary line says which arm the recipe is on; - a group that CANNOT be a val says so.
[build.installer.target]is a set of lists, not a one-of, so--list-optionsprints it asgroup target RECIPE-ONLY: …with its effective shape. An omission reads as "there is no such knob", which is the one thing a listing must never read as.
And the --opt line is split by the TREE, never by a name → bool parser: SettingsTree::split_opts returns this variant's features, this variant's vals (each checked against its roster) and the pairs behind_an_edge that the DONOR's door judges. options::parse_cli_options could only ever answer "an option is on or off", and that sentence WAS the answer --opt kernel.preset=nuked got on the ISO-compose path — a defect a consumer's guide documented as a rule ("do NOT type this flag") for as long as it stood.
The kernel is ONE sub build-thing — the second surface (f5c, lane AF 2026-08-30)
The model has said "the kernel is not a special leaf" since f1. It became true on 2026-08-30, when a SECOND surface went through the same closure: a typ = container donor exposes the TOOLS its recipe carries, and the consumer that declares against it is the kernel row itself — a consumer as well as a donor, which is the sentence the generic engine exists to make sayable.
gunnar/gunnar-installer → tunnr/tunnr-kernel → tunnr/nordisk-kernel-toolchain
(Kconfig closure) (tool closure)
tunnr-kernel places one tool requirement per STEP it actually runs (kernel.compile, kernel.lto, kernel.minify, kernel.probe-boot), so toolchain.why.toml answers "why is clang in this image?" with the row that asked — and image-only (a package the recipe installs that no feature wants) is the exact twin of preset-only, read the same way: a question, never a verdict. MEASURED on the shipped rows: five of the image's thirteen tools are image-only.
What was secretly kernel-shaped, and is not any more — the finding this lane was asked for:
| where | what it was | what it is |
BuildStep::requires |
Option<KernelRequirements> — visit computed a generic closure and then called .as_kconfig(), so a non-Kconfig closure was collected, unioned, refused correctly and dropped on the floor |
Option<SurfaceClosure>, with kernel_requires() / tool_requires() as typed accessors |
| the step's pin | computed inside visit behind if edge.typ == Kernel, twice |
step_pin_for, one arm per surface |
SurfaceClosure::union |
could not fail — with one variant every pair matched | a named refusal for a mixed pair |
Everything else was already generic: Requirement, EdgeRequirement, Closure, RequirementSource, closure_of_consumer, the edge/typ check, the cycle refusal, the reached-twice union. Those three are where "generic" had been asserted rather than exercised, and a second surface is what exercised it.
The forge is a SECOND reader, and the model has to survive that (f5d, lane AJ 2026-08-30)
The model consequence, stated first: in this model the CODE is the source and the toml is emitted — but the toml is not written for one reader. The forge reads every repo's recipe with the nornir-build-thing pin dwarves was built against; the repo's own <repo>-build-thing crate, and its toml_parity test, read the same bytes with the pin that repo was built against. Those two are different programs at different versions, updated on different afternoons, and until 2026-08-30 nothing compared them.
That is not an accident to be tidied away — it is what "the forge holds only the artifact and can still ask the recipe that built it" MEANS. A model with one writer and many readers has a version axis whether or not it admits to one.
What it cost while unadmitted. Measured, published 0.1.10 against gunnar's committed recipe: the whole file unloadable on one additive key (unknown field kernel_requires, because OptionSpec is deny_unknown_fields), and — with that key removed so the load finishes — FOUR paths dropped in silence (build.installer.{0,1}.{target,data}: the machine each medium is built for, and what /data is laid with) with gunnar's own parity test GREEN over them for four pin generations. The parity test was green because it compares the file against the same loader that dropped the rows.
The three consequences for this model:
- A version is part of a recipe, not part of a toolchain. A recipe now carries
[nornir-build-thing] schema = Nat ROOT level, computed by the emitter from what it declares.code → tomlis unchanged; the emission simply says which vocabulary it used. See edda.nornir/build-design.md, "THE COMPATIBILITY RULE". - A reader may refuse, but may never DISCARD. "Ignore what you do not understand" is wrong for this format specifically, because every group in a recipe changes the artifact: a dropped
[build.installer.target]is a medium built for a different machine, not a missing nicety. The loader refuses with the paths instead. - The refusal names the fix, not the field. "this recipe is schema 2, this reader understands 1 — upgrade nornir-build-thing to >= 0.1.12" is actionable by the operator who hit it;
unknown field kernel_requires, expected one of name, label, default, …names a key that operator never wrote, in a file the code emitted.
For the forge specifically: dwarves is a reader like any other, and its pin is now an exact floor (0.1.12, path) rather than the floating "0.1" it carried — ^0.1.0, which accepts every reader that ever existed and moves with no commit. When the forge is too old for a repo's recipe it says so by name and names the version to install, instead of reporting a parse error in somebody else's file.
Fifteen repos declare in code, and one cannot — what the sweep taught (f5e, lane AG 2026-08-30)
The model consequence, stated first: the CODE is the source is a statement about a WRITER, and it is only true where a writer exists. Sixteen repos had a .nornir/build-variants.toml; six had a recipe crate; and of those six, TWO (gunnar and korp) kept the file as a hand-maintained mirror with a parity test comparing the two copies. A mirror is not a smaller version of an emission — it is a different thing, and it fails in ways an emission cannot.
The emitter is ONE writer, in the engine. nornir_build_thing::emit (recipe_toml · emitted_toml · write_emitted · first_difference). A repo's emit.rs is its own EMITTED_HEADER and three calls. The reason it is not a seventy-line file per repo is not tidiness: the emission is three lines of substance and the third one is the round trip, and an emission that does not read back is not a description of anything. Ten copies is ten places to forget it. recipe_toml refuses bytes that do not read back equal through BuildVariantSet::from_toml_str — the one loader, carrying f5d's R1/R2/R3 — so an emitted file is, by the time it is written, known to be readable at a schema it declares with nothing silently dropped.
What a mirror costs, measured. gunnar's, on one file: two whole groups ([build.installer.target], [build.installer.data]) present in the toml and absent from recipe.rs for four pin generations with toml_parity GREEN, because the pinned loader dropped them before the comparison ran; and a schema header that had to be written by hand, which is what a file with no writer looks like. Both are impossible against an emission: there is one declaration, the file is its projection, and a group at a path the reader would drop can no longer be emitted OR read.
What toml_parity becomes. Not a parity test — three properties: the committed bytes ARE the emission (byte for byte, so it fails for ANY reason they differ); the file is INERT (an edit is visible to a reader and invisible to the recipe, with the perturbation asserted visible first); and a key no reader has a field for is REFUSED BY NAME rather than dropped. The third is lane AF's defect put back and seen red.
What writing nine recipes taught about the model
- The model fits everything it was pointed at. Nine repos, and not one resisted
variant → features → settings/sub-settings → typ. A pure library with a backend axis (draupnir), a repo with ONE atom and four fictions (modgunn), a repo whose seven rows are compile-only surfaces that exist so a test-gating feature does not rot (Skidbladnir), a graph root that must embed nothing (skade) — all four are the same four levels with different answers. - The honest declaration is usually SMALLER than the toml it replaces, and the difference is prose. gunnar 2680 → 546 lines, modgunn 268 → 67, korp (still to do) 1330 → ~148. Prose is not data and cannot be emitted; it moved to doc comments and to each repo's
.nornir/build-thing-design.md, verbatim. A comment typed into an emitted file is deleted by the nextemitwithout anybody noticing, which is the one thing every emitted file's header must say. allow_unbuiltis where a hand-written recipe hides its debt, and prose is not a check. Every repo but two had features nobody built: modgunn six, Skidbladnir twelve, korp-collectors eleven, skade six, ordning two, draupnir four. Most were already documented — in comments, which no gate can read.FeatureAxis::gate()over the declaration turns each into a check, and the measurement that says why it matters is modgunn's: compiled one at a time on 2026-08-01, two of six were actually broken. 33 %, not a theoretical risk.- An
offersspace that is absent makes the gate VACUOUS, and edda's own doc already said so from znippy-zoomies (nine features, zero built,gate()→Ok(())). Declaringoffersis what gives the gate a question with an answer. Where a repo'soffersdeliberately covers less than its manifest — skade's and znippy's do — the recipe now says so in words, because an omission that looks like an oversight is the thing a recipe must never leave behind. - The emission finds binaries nobody declared. Three, in three repos: korp-collectors'
korp-chain-init(the KVM guest's PID 1 for the a–e chain drill), Skidbladnir'sskidbladnir-scenario, and znippy-zoomies' five bench bins. All are instruments, all now carry a reason inallow_unbuilt_bins. - A group that equals its default is not written down, and that is right.
InstallerBuild::targetisskip_serializing_if = "Target::is_default", and gunnar's declared machine ISTarget::default()exactly — so the emission contains no[build.installer.target]. The group is still DECLARED; it simply changes nothing today. The recipe asserts both halves, because the omission looks exactly like the loss it replaces.
The one repo that resists, and it is the CONVENTION, not the model
nornir. build_thing::code::recipe_crate finds a recipe by PACKAGE NAME — exactly <repo>-build-thing — and code::take builds it with cargo build -p <package> --lib --release --target wasm32-unknown-unknown. The repo is nornir, so its recipe crate would have to be named nornir-build-thing: the engine's own crate, which nornir already depends on twice. MEASURED in nornir's checkout with no recipe crate present:
$ cargo pkgid nornir-build-thing
error: specification `nornir-build-thing` is ambiguous
nornir-build-thing@0.1.11 # registry, via published transitives
nornir-build-thing@0.1.12 # path, ../edda/crates/nornir-build-thing
The forge's own build spec is already ambiguous there. Renaming the engine dep inside the recipe crate compiles and leaves the spec ambiguous; naming the crate something else wins the whole repo-local half and is invisible to the forge; [lib] name hides the .wasm instead, because the cdylib's file name comes from the package name.
This is a finding about the DISCOVERY CONVENTION: it keys on a name, and one repo's name is the engine's. Two ways out, and it is the forge's call —
- accept
<repo>-recipeas a second name (recipe_cratealready reads each candidate's manifest; a few lines, still a name, but a general rule and not a table); or - find the recipe by what it IS — the cdylib in the repo whose manifest depends on
nornir-build-thingand is notnornir-build-thingitself. A fact rather than a spelling, so it survives the next collision too; it needs-p <name>@<version>at the build, which the manifest scan already knows.
Until one lands, nornir/.nornir/build-thing-design.md is nornir's declaration of record and its build-variants.toml is the last hand-written one in the constellation.
The chain, at depth 2, with the closure in it (f5f, lane AK 2026-08-30)
Wave 3's product goal was reached on 2026-08-27 with a chain of depth 1 (kernel → iso). With lane AF's SECOND surface declared (tunnr-kernel consumes tunnr/nordisk-kernel-toolchain : container) the chain is depth 2 and the recursion is exercised rather than asserted. MEASURED, from the CLI door, on the real declarations:
step 1/2 · kernel.toolchain → tunnr/nordisk-kernel-toolchain : container
step 2/2 · kernel → tunnr/tunnr-kernel : kernel · --opt kernel.preset=slim
· after tunnr/nordisk-kernel-toolchain
and run: chain · passed · 3/3 runa step(s) — toolchain container 0.3 s, kernel 78.5 s cold / 0.1 s BY PIN, installer 232.3 s. The planner needed no change: it has been a consumer of edda's BuildDag::derive since 7881fd7, and the donor's donor falls out of that recursion.
What the chain is FOR, stated plainly. A donor built standalone has no consumer, so it has no closure, so the symbols the consumer's features require are simply not in it. The same kernel row through the two roads:
| road | requirer rows | CONFIG_XFS_FS |
bzImage |
dwarves buildthing tunnr --variant tunnr-kernel --opt kernel.preset=slim |
0 | absent | 2 208 768 B |
dwarves buildthing gunnar --variant gunnar-installer-open --build kernel --opt kernel.preset=slim |
66 symbols · 5 features | =y |
2 413 568 B |
That is why a preset can only be boot-proven THROUGH a consumer, and why the repair a closure refusal names is the chained line and never the donor's own.
What the kernel graph reflects today — honesty note
The canvas draws KernelFeatureModel::derive (edda kernel_features.rs): edda's committed fragments (re-counted 2026-08-30: kconfig/base.config 33, deps-tinyconfig 15, hardened 25, nuked 21, profile 4, minimal 1 — 99 lines, 94 distinct symbols), with edges from the name hierarchy (P parents C when C starts with P_), not from Kconfig depends on/select. The choices are true (the fragment is what the kernel is built from); the map is curated and its edges are guessed. Until options("kernel") parses real Kconfig from fetched source, the pane says so. It does (2026-08-26): kernel_scene::REFLECTS, on the graph leaf whenever the kärna subgraph is unfolded, in the kärna node's menu, and in the canvas state as reflects.
Gaps against this model (2026-08-26)
| # | model says | exists | missing | size |
| 1 | tree, three node kinds, paths | flat OptionSpec; val only as kernel.preset |
children, val, paths; law C as path ↔ flag |
M |
| 2 | impl answers the tree, dynamically | KernelFeatureModel::derive from bundled fragments |
options(path) / try_toggle / actions on the trait and the wasm ABI; fetch host call; real Kconfig |
M–L |
| 3 | typed consumption edge | CLOSED 2026-09-18 (edda). The edge is one value on ANY kind: tree::ConsumptionEdge { feature, repo, variant, typ } — the feature BESIDE the coordinate, because a requirement is a property of the feature that needs it. [build.<kind>.edges.<feature>] is its toml spelling and […kernel_donor] is the kernel-typed one, converted in declared_edges so no reader learns which was written (proved verbatim on disk and in the emission: the_kernel_donor_spelling_survives_the_general_edge_verbatim). ONE typ refusal (tree::wrong_typ) serves both the load (validate_edges_with) and the plan (BuildDag::derive), naming consumer · feature · coordinate · both typs. BuildDag::derive walks general edges of any typ, donors first, deepest first, a ring refused by name, depth ≥ 2 live over tunnr's toolchain container edge. The bijection: an edge is consumed only while its feature is ON — --opt kernel.toolchain=off takes tunnr/nordisk-kernel-toolchain out of gunnar's plan, =on puts it back with the same step, line and pin, and the toggle is answered AT the edge instead of being handed to the donor's door (which judged kernel.toolchain as a kconfig symbol until it was). --build X of an edge the same line switched off is refused by name. |
the iso/installer half of the old row stands: a live iso still has no kernel edge of its own on every repo (host vmlinuz / KORP_KERNEL remain the fallback where no donor is declared). dwarves needs NO code change — plan_build_steps / run_build_chain / ⚒'s BYGG <kant> are typ-general already — but build_steps::route_opts (today only its own test's caller) does not drop an edge's on/off word, so a caller that ever builds a raw donor line from it would re-introduce the bug edda just fixed; migrate edges_of → consumption_edges when convenient. |
— |
| 4 | --build X, DAG via runa |
the chaining landed 2026-08-27 on BOTH doors: edda BuildDag::derive is the one ordering (donors first, deepest first, a ring refused by name); dwarves plan_build_steps is its consumer (7881fd7) and run_build_chain runs the plan as a runa pipeline, one stage per step; ⚒'s BYGG <kant> walks that same road through run_build_chain_watched (0f8ea2b) and draws the runner's own five states; the two doors' plans are compared verbatim by robot_two_doors::the_chain_the_pane_plans_is_the_chain_the_prompt_plans. The rule "never builds the donor's kernel" is RETIRED. |
no chained build has been RUN end to end yet — blocker B1: runner.rs:217 spawns cargo in the host's environment and oden has no musl std, so the cargo kind needs its toolchain container first (W2). No cold/cached times exist. |
L |
| 5 | presets as val anywhere, pre-built in the ledger |
four presets on kernel.preset; cache in tunnr/.kernel-out |
root preset; pin lookup in the warehouse/ledger | S–M |
| 6 | kernel = node in the settings tree | landed 2026-08-26: the kärna node of the ⚒ build graph (build_graph.rs), the kconfig picture its unfolded subgraph; the leaf and the presence overlay are gone (facett's NavState.absent stays in facett as a generic capability, nothing in ⚒ drives it) |
— | S |
| 7 | typ bears verbs | landed 2026-08-26: edda verbs.rs (actions_for, over wasm as actions(kind)), consumed by ⚒ through Forge::actions; the local half (run.where = container | vm, surfaces, pane) in dwarves actions.rs; buildinfo always, verbs by typ, the RUN receipt beside the build receipt |
dwarves run <coordinate> still unverified; the RUN receipt is on the pane, not yet in the ledger; an exe in a vm is offered and refused by name |
S |
| 8 | korp lawful | toml passes the set laws | no korp-build-thing crate, no gate, no kernel edge |
M |
| 9 | ⚒ reads the code, not the table | landed 2026-08-26: dwarves::build_thing::code — one loader, two doors (⚒ and dwarves buildthing); the recipe crate built to wasm32 INSIDE the toolchain container, cached by source digest, loaded through PluginHandle; the road on the answer (kod: … (wasm) | deklarationen (toml) — kodvägen VÄGRAD: …); options | try_toggle | actions over wasm and the narrow fetch(coordinate) (declarations only) |
no repo ships a cdylib recipe crate yet: korp has none, gunnar's is an rlib without export_build_thing! — both refused by name; fetch of sources/artifacts through a container |
M |
| 10 | kernel column in the matrix (gunnar-kernel) | five columns | a constellation decision — Rickard's | — |
Order: 9 → 1 → 6 → 2 → 3 → 4; 5, 7, 8 are free-standing.
Tests (inject-assert, LAW 1)
nornir_build_thing::laws::assert_bijectiveon every impl (holger, gunnar both mirrors, dwarves'TomlBuildThing, knut) — law C over paths once the tree lands.- ⚒:
robot_five_leaf_body,robot_kernel_in_build(four recipe shapes: nothing / library / donor / own kernel),robot_build_scene(kind → pane). - Kernel:
clicking_a_preset_in_build_thing_moves_what_the_build_would_run; the pin proof at the installer door (fragment-sha vs chosen merge).
Source map
- the trait, the wasm ABI, the section → edda
crates/nornir-build-thing/src/plugin.rs - options, params, presets, decision fragment → edda
options.rs,params.rs,kernel.rs,kernel_features.rs - the donor edge and the ONE reader → edda
installer.rs(InstallerBuild.kernel_donor),kernel::resolve_built_kernel - ⚒ →
dwarves-gui/src/build_thing_scene.rs,build_scene.rs,build_forge.rs - the CLI door →
crates/dwarvesbuildthing(resolve_kernel_cli,installer_kernel) - the laws → edda
laws.rs - the requirement closure, the sources and the surfaces → edda
requires.rs; the Kconfig surface, the contract and the why-index → eddakernel_requires.rs
See also: buildthing-guide.md, buildthing-installer.md, build-pipeline-dag.md.
Which recipe reader this repo uses, and why (lane AJ, 2026-08-30)
Reader: nornir-build-thing 0.1.12, PATH (root, xtask, dwarves-gui, crates/nornir-warehouse-embed).
Why: dwarves IS the forge — the reader that opens EVERY repo's recipe, and therefore the one whose pin decides what the constellation can declare. It had been the floating "0.1" (^0.1.0) in xtask and dwarves-gui and 0.1.0 in nornir-warehouse-embed. See f5d above.
The rule itself — [nornir-build-thing] schema = N, a refusal that names the version to upgrade to, and a loader that refuses to DROP a key it cannot read — lives once, in edda .nornir/build-design.md, "THE COMPATIBILITY RULE". The cross-version guard that would have caught the 2026-08-30 skew is edda/nornir-build-thing-skew.