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

nordisk/edda

.nornir/cross-repo-from-variant-design.md

Cross-repo from_variant — the design, and why it is not a small edge

Status: FILED AS A DECISION, not implemented. 2026-09-06. Scope: crates/nornir-build-thing (edda), read against dwarves, gunnar, tunnr/tunnr-lockfix, korp.

Three seats derived the same missing capability independently on 2026-09-05/06: tunnr stages /app from a PATH because a variant reference cannot cross repos; korp landed a per-binary variant witness because a path is a string and nothing opened the file at the end of it; the seven korp media had to MOVE REPOS (korp 86bf1a9) because a medium is composed from the artifact its from_variant names and that name resolves inside one repo's set only.

This document answers the four questions and then says why the row is a decision rather than an edge.


1. WHY it cannot cross repos today — and which KIND of limit it is

Not one limit. Three, at three different layers, and only the third is hard.

L1 — the FIELD has no repo half (a vocabulary limit; trivially fixable)

declaration field file:line
iso pub from_variant: Option<String> src/iso.rs:163
vm pub from_variant: Option<String> src/vm.rs:144
container layer pub from_variant: Option<String> src/container.rs:149
package pub from_variant: Vec<String> src/package.rs:237

A bare handle. Compare KernelDonor { repo, kernel } (src/installer.rs:153-158) and EdgeDecl { repo, variant, typ } (src/tree.rs:257-264), both of which DO carry the repo half.

L2 — the ROSTER the handle is resolved against is this repo's, at four sites

(a scope limit; mechanically fixable, with one real consequence)

variant.rs:2684  validate_package   self.get(dep).is_none()  -> refuse
variant.rs:2760  validate_container (via variant_deps)       -> refuse
variant.rs:2802  validate_vm        self.get(dep).is_none()  -> refuse
variant.rs:2826  validate_iso       self.get(dep).is_none()  -> refuse

e.g. variant.rs:2830 — "iso '{}': masters cargo variant '{}' which is not declared (known variants: {})".

These are SHAPE-ONLY validators: no disk, no resolver, called on every load. That is why the refusal is loud and early today. A coordinate-shaped from_variant cannot be judged here at all — the donor's declaration is not in hand — so the check necessarily moves to a _in-style validator that takes a resolver (validate_edges_with, variant.rs:3756). Fail-fast degrades unless every door that loads a recipe also calls the resolving validator. That is a real cost, and it is exactly the failure mode gunnar hit in gunnars_todo §11.

L3 — the HAND-OFF has no cross-process form (the hard limit)

stage_variant_artifacts (src/image_build.rs:408) is where "the declaration names a cargo variant" becomes "the image build finds its payload". It takes ONE set and ONE repo_root, resolves the handle at image_build.rs:419:

let v = set.require(vname).with_context(|| {
    format!("{label}: chains cargo variant '{vname}', which this repo does not declare")
})?;

and then builds it in-process (image_build.rs:445-449, provenance::build_variant_with_info / chained_variant_in_container) and takes the artifact path off BuildOutcome::artifacts (provenance.rs:509-510) — what cargo actually wrote, never guessed.

That last sentence is the blocker. The path exists only inside the process that ran the build. There is no published location, and no pin, at which a cargo variant's artifact can be found by anyone else. The two kinds that already have one are precisely the two that already cross a boundary:

kind published at pinned by
kernel <repo>/.kernel-out/<name>/{bzImage,kernel.config} (kernel.rs:3156) <name>.kernel-pin.json — KernelPin (kernel.rs:3064)
iso <repo>/.iso-out/<name>.iso (image_build.rs:752-762) the compose's own attestation
exe / lib nothing nothing

Verdict on the kind of limit: it is a PUBLICATION limit, not a path-resolution limit, not a trust limit, and not merely unimplemented. Path resolution is already solved (dwarves::buildthing::resolve_build_thing_from, buildthing.rs:494; BuildVariantSet::validate_edges_in, variant.rs:3815). Trust is already solvable (§3). What is missing is that a cargo variant's built artifact is not an addressable, verifiable thing outside the run that made it.


2. Does kernel_donor generalise? — YES in three of four parts, NO in the fourth

KernelDonor (installer.rs:129-158) is documented as "the rule every cross-repo seam here uses". Its four parts:

part generalised already? evidence
the declaration — a NAME PAIR, not a path YES, and it is already done EdgeDecl { repo, variant, typ } (tree.rs:257) is the explicit generalisation, live on six kinds (variant.rs:596, iso.rs:229, vm.rs:150, container.rs:285, installer.rs:387, kernel.rs:810). Its doc says so: "The general form of an installer's / iso's kernel_donor" (variant.rs:592-594)
resolution — donor checkout beside the consumer YES validate_edges_with(own_repo, resolve) (variant.rs:3756) + validate_edges_in(repo_root) (variant.rs:3815); dwarves supplies the real ladder at buildthing.rs:666 resolve_donor_kernel_decision
the plan — build the donor first, transitively, cycle-refused YES Coordinate { repo, variant } (build_dag.rs:83) and BuildDag::derive, driven from Edge for ANY typ (dwarves/src/build_steps.rs:349)
the hand-off — read the donor's BUILT artifact and prove it is the chosen one NO resolve_built_kernel (kernel.rs:3199) works only because of .kernel-out/ + KernelPin. Every non-kernel branch of the runner is s.edge.typ == ArtifactKind::Kernel-gated (dwarves/src/build_steps.rs:473, :906, :910): a non-kernel step is BUILT and then nothing stages it

So: kernel_donor generalises as a declaration and as a plan — and those halves are already generalised and shipping. It does NOT generalise as a hand-off, and the hand-off is the whole of what from_variant means. from_variant is not "name another artifact"; it is "the payload IS the bytes that artifact is". The edge vocabulary answers the first and is silent on the second.

Corollary, and it makes the row smaller than it looks in one direction and bigger in another: cross-repo from_variant should not be a new field at all. It is edges.<feature> of typ exe plus a publication rule. The declaration work is zero; the publication work is the row.


3. THE DESIGN

3a. Resolution — where the other repo is found

Reuse, do not invent. The constellation-layout rule, already spelled twice: <parent of repo_root>/<repo> (variant.rs:3815-3826), with dwarves' richer ladder (buildthing.rs:494 resolve_build_thing_from, which also accepts a repo/variant coordinate) as the door's resolver. edda stays dep-light and takes a &dyn Fn(&str) -> Result<PathBuf> exactly as validate_edges_with does; edda never resolves a checkout on its own.

The consumer never builds the donor. This is already a DECIDED rule for the kernel edge (installer.rs:147-152: "A donor is a CONSUMER of the built kernel, never a second builder"), and it must hold here for the same reasons plus one more: stage_variant_artifacts today would otherwise run cargo inside somebody else's checkout, which cargo_container.rs:575 already forbids ("get to edit another repo's checkout"). The donor's variant is built by dwarves buildthing <donor> --variant <v> — as a --build DAG step (build_dag.rs, already generic over typ) or by hand.

3b. Publication — THE NEW THING, and the only new thing

A cargo variant must land somewhere addressable, with a pin, exactly as a kernel does. Proposed, mirroring kernel_output_dir:

<repo_root>/.build-out/<variant>/
    <artifact basename>          the bytes cargo wrote
    <variant>.build-pin.json     BuildPin

BuildPin is not new work either — provenance::BuildOutcome already carries the artifacts and an Attestation; the pin is that attestation plus the artifact's sha256, written to a conventional path. Without this directory there is nothing for a consumer to open, and every alternative (an env var, a staged path, a warehouse lookup) is the workaround being cured.

The ledger seam generalises for free: KernelLedger (kernel.rs:3217) is already a trait so dwarves can serve a pinned artifact from the build ledger before falling back to the donor's local out-dir. ArtifactLedger is the same shape.

3c. Provenance — how a consumer knows the artifact IS the declared variant

Do not invent a second witness format. edda already owns one: contract::Receipt in linker section .nordisk.buildthing (contract.rs:44, contract.rs:189), carrying atom — "The atom this artifact is, e.g. korp-server" (contract.rs:195-196) — plus the build-thing Coordinate { krate, version, hash }, read back without executing the artifact by receipt::read_receipt (receipt.rs:101).

So the consumer's check is three lines: open the pinned artifact, read_receipt, refuse unless receipt.atom == <the declared variant> and receipt.build_thing.krate == "<donor>-build-thing".

Two measured facts make this the right choice rather than a preference:

Honest limit, stated: the receipt is a plaintext self-declaration in a section, not a signature. It is proof against the failure that actually happened (the wrong sibling artifact staged under a right-looking name) and not against a hostile editor. That is the correct strength for a build seam between checkouts the same operator owns, and the pin's sha256 covers the accidental half.

Second honest limit: embed_build_receipt!() is deployed unevenly — dwarves emits it (dwarves/src/main.rs:8398), gunnar and korp only READ it in tests (gunnar/crates/gunnar-recipe/tests/cli_matrix.rs:303, korp/crates/korp-build-thing/tests/cli_matrix.rs:202). A donor variant that carries no receipt cannot be a cross-repo from_variant donor. That is a precondition to enforce at declaration time, not a warning at stage time.

3d. Schema — and what an OLD reader does with it

RECIPE_SCHEMA = 4 (schema.rs:131), first reader 0.1.14 (schema.rs:178-179). If the capability is spelled as edges (§2's corollary) then no new vocabulary is added at all and this section is about a hole that already exists:

edges has no arm in required_by (schema.rs:263-354). grep -n edges src/schema.rs → nothing. So a recipe declaring edges emits schema = 1 (the floor), and IsoBuild / InstallerBuild / ContainerBuild are not deny_unknown_fields (schema.rs:59-67) — an older reader silently drops the whole edge table. That is R3's own named disease, live today: tunnr-lockfix/.nornir/build-variants.toml:459,473,487,501 declares four [build.kernel.edges.toolchain] tables under no schema header at all.

The fix is a required_by arm naming each edge ("[build.<kind>.edges.<feature>] on '<variant>'"), RECIPE_SCHEMA = 5, and FIRST_READER += (5, "0.1.15").

It is not a tonight edit. FIRST_READER may not name a version that does not exist — the module says so in its own words about the 0.1.11 gap (schema.rs:147-155: "Naming 0.1.11 here would make the refusal message a lie") — so the arm forces nornir-build-thing 0.1.14 → 0.1.15, a crate ~20 manifests across the constellation depend on. And it converts a silent drop into a loud whole-file refusal for every stale installed forge, which is the 2026-09-05 gunnar outage replayed on tunnr (schema.rs:100-120). Correct by doctrine; a release event in practice; Rickard's call, not a lane's.

3e. Failure — it must refuse loudly, never silently produce nothing

Five refusals, each naming a different repair. Modelled one-for-one on resolve_built_kernel's four (kernel.rs:3186-3198), which is the proven shape:

state refusal names
donor checkout absent the coordinate, the path looked at, and the layout rule — not "no such variant"
donor declares no build space wrong checkout, not a missing build (variant.rs:3775's wording)
donor declares no such variant the coordinate + the donor's declared names
donor's variant is declared but NOT PUBLISHED the exact line that publishes it: dwarves buildthing <donor> --variant <v>
published artifact's receipt disagrees expected atom vs. the atom in .nordisk.buildthing, and the pin sha vs. the file's — this is the tunnr /app defect, caught

Two rules govern all five:

  1. Refuse before building anything. The cheap gate above the expensive one, for the reason dwarves/src/assemble_cell.rs:490-500 measures: 18 m 29 s of cargo before a missing admin_key was mentioned.
  2. A missing donor is never a skip and never an empty payload. Today's iso.rs:458-462 ("needs a kernel, a staging tree, or a from_variant to be bootable") is the local version of the property; the cross-repo version must be at least as loud, because the failure is now REMOTE and the operator has no reason to look in the other checkout unless told to.

4. VERDICT — filed, not implemented

The row is one publication convention (§3b) away from being small, and that convention is a build-system-wide decision: where a cargo variant's artifact lands, what pins it, whether the ledger or the out-dir is authoritative, and which repos must start emitting a receipt before they may be donors. Landing half of it — a coordinate-shaped from_variant whose validator moves off the load path (§1/L2) and whose hand-off has nowhere to read from (§1/L3) — would trade a loud workaround for a quiet gap in the build system of five repos. The three seats' workarounds are honest and they hold; nothing is on fire.

What is genuinely small and separable, in dependency order:

  1. The required_by arm for edges + the 0.1.15 bump (§3d) — needed by anything that spells a cross-repo edge, and it repairs a live silent drop in tunnr-lockfix. Blocked only on the version bump being sanctioned.
  2. korp's witness → contract::Receipt (§3c), and a real reader on tunnr's side. Today the guard korp documents does not exist on any tunnr ref.
  3. .build-out/<variant>/ + BuildPin written by build_variant_with_info (§3b). Additive, no schema, no declaration change, useful on its own.

With 1–3 landed, cross-repo from_variant is edges.<feature> typ = "exe" plus a stage_variant_artifacts that takes (set, root) per dep instead of one pair — which IS a small edge, then.