Gunnar

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`:

```rust
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:

* **korp already built the parallel mechanism, because it could not reach this
  one.** `korp/src/variant.rs` (origin/main, commit `820660f`) embeds
  `KORP-BIN-VARIANT=<name>;` padded to 64 bytes via a `const fn` witness, and its
  own doc (`korp/src/variant.rs:43-50`) says the format belongs in edda once if
  it ever grows a second field. It exists because for weeks the RDP appliance
  shipped `korp-ui` (the GPU build) under a path spelled `korp-ui-thin`.
* **The reader korp's doc names DOES NOT EXIST.** `korp/src/variant.rs:43-50`
  points at `tunnr-build-thing::staged`; `tunnr` has no `staged` module and no
  occurrence of `KORP-BIN-VARIANT` on **any ref**
  (`git grep -l KORP-BIN-VARIANT $(git for-each-ref …)` → empty). The witness is
  written and read by nobody. A provenance mechanism whose reader is prose is the
  same defect as the path it replaced, one layer up.

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.