Gunnar

nordisk/dwarves

.nornir/build-pipeline-design-2026-09-10.md

<!-- nornir:class: design -->
# the build pipeline — `*.runa.ron`, and where it goes next · 2026-09-10

> **Class:** `design` · **Function axis:** `f0` the word is **runa** · `f1` what exists · `f2` a runa is hand-written beside `xtask` · `f3` one runa, fat or thin, same semantics · `f4` declarative vs procedural
> Vocabulary: **variant → features → settings / sub-settings → typ**. Never "cell".

## f0 · The word is `runa`, not "rune"

Measured, not preferred. The crate is `dwarves-runa` (`edda/crates/dwarves-runa`, `0.2.2`),
the format is `*.runa.ron`, the modules are `runa_exec` / `runa_backend`, the types are
`dwarves_runa::{Pipeline, Stage, Step, Trigger}`. "rune" occurs in exactly one place in the
tree — the logo prose *"two dwarves at the rune-anvil forge"* (`dwarves-gui/src/about.rs:11`).
Zero types, zero modules, zero file names. The bare crates.io name `runa` was taken; that is
the whole reason for the `dwarves-` prefix (`dwarves-runa/src/lib.rs:9`). **Write `runa`.**

## f1 · What is there — the datasheet

| part | where | what |
|---|---|---|
| the format | `*.runa.ron` → `Pipeline { name, triggers[], stages[ Stage { name, needs[], steps[] } ] }` | `edda/crates/dwarves-runa/src/pipeline.rs:19` |
| the step catalog | **22 typed variants**, `#[serde(deny_unknown_fields)]` | `pipeline.rs:212` |
| validation | duplicate stage · unknown `needs` · DAG cycle, at LOAD | `dwarves-runa/src/validate.rs:24`, reusing `funnel_core::PlanDag::topo_order` |
| the executor | `RunaStepRunner` — borrows a `&jera::agent::Scheduler`, owns none | `crates/dwarves/src/runa_exec.rs:184` |
| step → work | `step_payload()` maps a `Step` to a `WorkPayload` + caps | `runa_exec.rs:638` |
| the runner | `ForgeBackend` — one `LocalAgent "local-0"`, caps `[cargo, container, kvm]` | `crates/dwarves/src/runa_backend.rs:195` |
| **no shell** | argv-only `Command::new(program).args(args)`; there is **no** `Shell`/`Run`/`Script` step | `runa_backend.rs:238`; policy at `pipeline.rs:182` |
| dry is the default | `pipeline run` without `--execute` uses a fake scheduler that greens every unit | `main.rs:1925`; the closure at `pipeline_wizard.rs:442` |
| a second, file-less caller | `run_build_chain_watched` SYNTHESISES a `Pipeline` in Rust and runs it through the same executor | `crates/dwarves/src/build_steps.rs:874` |

Two verbs, two shapes: `dwarves run <f>.runa.ron` is always real; `dwarves pipeline run <f>`
is dry unless `--execute`. Every `pipeline` verb already prints **one JSON object** for agents
(`main.rs:302`) — there is no `--json` flag because JSON is the only output. There is no 3-col
human table; that is the crisping gap, not a missing machine format.

## f2 · A runa is written by hand, beside `xtask` — and an `xtask` verb is a step

The intent: a Rust developer commits a `*.runa.ron` next to their repo's `xtask`, and the
verbs they already wrote and tested become callable steps. Half of that shipped: `Step::Xtask
{ cmd }` lowers to `cargo xtask <cmd>` (`runa_exec.rs:744`) and both committed pipelines use it
(`dwarves.runa.ron:40`). The gap is that the step could name neither **which repo** nor **any
argument**, so `cargo xtask build --variant korp-ui` — the constellation's most common verb —
was unspellable, and every step ran in the forge's own checkout.

**The slice implemented in this lane** closes exactly that and nothing more:

```ron
Xtask(cmd: "build", args: ["--variant", "korp-ui"], repo: "korp")
```

`args` and `repo` are `#[serde(default)]`, so every existing file stays valid under
`deny_unknown_fields`. `repo` is a checkout **name**, resolved as a sibling of the forge's
root and refused by name if it is absolute, empty, or contains `..` — a pipeline step is not a
door onto the filesystem. It lowers to the existing `ProcessSpec.cwd`, which the backend has
honoured since day one (`runa_backend.rs:241`). No new machinery.

## f3 · One runa, fat or thin — the seam is the AGENT ROSTER

Today `*.runa.ron` is **fat only, and says so**: `dwarves run … --server` prints
*"a `*.runa.ron` run executes FAT (local jera Scheduler); ignoring --server"* (`main.rs:1765`),
and `runa` appears zero times in `thin_client.rs` and `server.rs`.

The seam is already cut, one layer below where people look for it. `RunaStepRunner` holds a
**borrowed** `&Scheduler` (`runa_exec.rs:185`); it never asks who will run a unit. Who runs it
is decided entirely by **which agents are registered**:

| roster | what it means |
|---|---|
| `ForgeBackend::into_scheduler` → one `LocalAgent` | **fat** — this machine (`runa_backend.rs:195`) |
| `pipeline_wizard::fake_scheduler` → one always-green agent | **dry** (`pipeline_wizard.rs:442`) |
| a `GrpcAgent` registered against `dwarves-server` | **thin** — named in prose at `agent_bridge.rs:8`, **not wired** |

So "the same runa runs locally or on the forge with the same semantics" is not a new executor
and not a `--server` branch in `runa_exec`: it is **register a different agent**. That is the
seam to name and the one to build — and it is the same shape the build already has, where
container-vs-host is decided by `[build.toolchain]` being present rather than by a flag.

## f4 · Declarative vs procedural — the line

| | declarative | procedural |
|---|---|---|
| **what** | build-thing: variant → features → settings/sub-settings → typ | runa: stages, `needs`, ordered steps |
| **who owns it** | `<repo>-build-thing` (`impl BuildThing`), toml EMITTED from it | a hand-written `*.runa.ron` beside `xtask` |
| **the question** | *what can be built, and what does a toggle cost* | *what shall be done, in what order, on what* |
| **who may branch** | nobody — a declaration has no `if` | a compiled `Step::Xtask`, never the RON |

The rule that keeps them apart: **a runa step names a build; it never describes one.**
`Step::Build { variant, opts, params }` carries a coordinate and the operator's picks and
hands them to the evaluator — it does not carry features, a profile or a target, because those
are the declaration's and copying them into a pipeline file forks the source. Conversely a
build-thing declaration never carries an order: it says what `--build kernel` *consumes*, and
the DAG derives the order (`build_dag.rs:719`).

## The improvement the flow audit points at

`.nornir/assets/build-flow.svg` (generated, `cargo xtask build-flow-svg`) counts **10
hard-coded steps against 8 declared** on the `Build{variant}` path. The finding that matters is
not any one of them — it is that `BuildPlan` / `PlanStep` **already exist**, typed, transported
over the wasm ABI (`edda/…/plugin.rs:767`, `:790`, `abi::OP_PLAN = 9`), with a doc that states
the intent verbatim: *"The build thing never performs an action: it returns a plan and the
forge executes it."* And `BuildPlan` appears **zero times** in the whole `dwarves` repo. dwarves
loads a recipe module and asks it three things — `manifest()`, `variants()`, `menu()`
(`build_thing/code.rs:344-346`) — then runs the same imperative Rust chain the toml road runs.

So the flow becomes data by **executing the plan that is already emitted**, not by inventing a
plan format: teach `code.rs` to ask `plan()`, and give the forge a small interpreter over
`PlanStep { name, program, args, env }` that runs each step through the same `ForgeBackend` a
runa stage runs through. A runa `Step::Build` then lowers to *the plan the evaluator emitted*
rather than to a marker the backend special-cases (`BUILD_THING_MARKER`, `runa_backend.rs:52`),
and the two pipelines — the build chain and the CI pipeline — become one interpreter over one
typed plan. That is the next step, and it is a wiring job, not a design one.

See also: [build-thing-code-is-source-design.md](build-thing-code-is-source-design.md) ·
[build-thing-inventory-2026-09-10.md](build-thing-inventory-2026-09-10.md) ·
`assets/build-flow.svg`

---

## Addendum · auto-scan and the missing Refresh (audited 2026-09-10)

**Auto-scan already works, and the per-pane Scan buttons are already gone.** A populate
(`Workspaces.Register` eager, `Workspaces.Fetch`, or the monitor poll loop) funnels through
`republish_served_locked` (`nornir/src/server_grpc.rs:6379`), and at `:6426` / `:6433` it calls
`LensChain::submit_feeds` then `submit` — one `jera` chain job per lens per population event,
keyed `lens:{workspace}:{repo}:{snapshot_id}:{lens}` (`nornir/src/lens_chain.rs:210`), grouped
`lens:{workspace}:{repo}` (`:227`), with a `generation` that is an FNV-1a over the current
Iceberg snapshot ids of all 16 lens-read tables (`:297`) — so it moves exactly when a populate
commits rows a lens reads. Dedup is `ChainQueue`'s (`edda/crates/jera/src/jobs/chain.rs:196`):
a repeat submit returns `Duplicate(id)` and queues nothing; a higher generation supersedes
every lower-generation PENDING job in the group (`:314`). A second layer catches anything that
slips past: `run_job` short-circuits when `lens_runs` already has a clean row for the
`(workspace, lens, repo, snapshot_id, params)` (`lens_chain.rs:397`). Feeds go at
`Priority::Normal`, lenses at `Low`, one worker — so a producer always drains before the fold
that needs it. The panes read on open and have no Scan control; the deletions are cited in
place (`dwarves-gui/src/mimir_lens.rs:2111`, `:1010`; `warehouse_select.rs:225`). **No work is
needed here. The rule is implemented.**

**What is missing is the Refresh.** There is no clear-and-repopulate verb — no
`Warehouse.Reset`, no `Mimir.Refold`, nothing on the `Workspaces` service
(`edda/crates/nornir-wire/proto/nornir.proto:1073`). Three unlinked pieces exist:
`nornir warehouse reset --yes` (CLI-only, fat-only, **refuses while the server holds the catalog
lock**, `nornir/src/bin/nornir.rs:17057`, and does not repopulate), `Workspaces.Remove{purge}`
(deletes the workspace outright), and `Workspaces.Fetch{force}` (repopulates without clearing —
its own proto comment says *"e.g. the warehouse was cleared and must be rebuilt"*,
`nornir.proto:1102`). The operator recipe today is four manual steps across a server stop/start,
and nothing verifies the sequence.

The shape it should take: **one RPC, `Workspaces.Repopulate { workspace, purge: bool }`**, that
takes the same populate lock `republish_served_locked` takes, refuses BY NAME with the in-flight
job's id when a populate is already running (the lock is already there — the refusal is what is
missing), clears the workspace's warehouse in-process rather than asking the operator to stop the
server, then runs the ordinary populate path so the lens chain re-arms itself through the
generation bump it already computes. One visible **Refresh** per repo in the UI drives exactly
that, and nothing else in the pane needs a button. This is a nornir-side change and is named
here rather than made: this lane's repos are dwarves, edda and facett.