# build-thing `ArtifactKind` multi-axis design (task #25)
**Status:** design, ready for a build agent to execute.
**Crate:** `edda/crates/nornir-build-thing`.
**Goal:** add a first-class **`ArtifactKind{Exe, Lib, Wasm, Container, Vm, Service}`**
axis on `BuildVariant`, **orthogonal** to the existing `shape` (fat/thin)
axis — so the today-separate cargo / OCI-image / VM / wasm / service build
paths become one kind-tagged output datatype with one dispatch, one
enumeration, and one validation gate.
---
## 1. What exists today (the seam we are unifying)
Four unrelated descriptor families, three of them living in three sibling
collections on `BuildVariantSet`, plus a runtime-only kind tag and two
"implicit" kinds with no first-class representation:
| Output | Descriptor | Where declared | Build path |
|---|---|---|---|
| cargo exe(s) | `BuildVariant` | `[[build.variant]]` | `provenance::build_variant` (`variant.rs:384`) via `BuildVariant::cargo_args` (`variant.rs:209`) |
| OCI/appliance image | `ContainerBuild` (`container.rs:160`) | `[[build.container]]` (`variant.rs:319`) | `image_build::build_container` |
| bootable VM | `VmBuild` (`vm.rs:114`) | `[[build.vm]]` (`variant.rs:325`) | `image_build::build_vm` |
| **wasm** | *(none — just a `BuildVariant` whose `target="wasm32-…"`)* | `[[build.variant]]` | same cargo path, no coherence check |
| **service** | *(none — a `BuildVariant` + `service::service_spec()` bridge, `service.rs:165`)* | `[[build.variant]]` | cargo path, then hand-off to skidbladnir |
Two axes already coexist on `BuildVariant` and prove orthogonal axes are the
established pattern:
- `Profile` — string-serde enum (`variant.rs:40-75`).
- `ServiceShape{FatService, ThinStandalone, Mixed}` — the fat/thin axis
(`variant.rs:88-128`), field `BuildVariant.shape` (`variant.rs:180-181`),
validated by `validate_shape` (`variant.rs:455-513`).
There is ALSO a redundant runtime enum `image_build::TargetKind{Container, Vm}`
(`image_build.rs:69-87`) that tags *provenance records only* — it is a strict
subset of what we are introducing and gets **replaced** by `ArtifactKind`.
**The core problem:** "which build path runs" is currently decided by *which
collection the descriptor came out of* (variants vs containers vs vms) and, for
wasm/service, not decided at all — nothing distinguishes a wasm cargo build
from an exe cargo build, and nothing enforces `target=wasm32` for the former or
a derivable `ServiceSpec` for the latter. `ArtifactKind` makes that decision a
first-class, validated field.
---
## 2. The design in one paragraph
Add an `ArtifactKind` enum (the axis vocabulary, all six kinds) and a
`BuildVariant.kind: ArtifactKind` field (default `Exe`). The **cargo family**
(`Exe`, `Lib`, `Wasm`, `Service`) is fully described by the existing
`BuildVariant` fields — `kind` just names what `cargo_args()` produces and turns
on the right coherence checks. The **appliance family** (`Container`, `Vm`)
keeps its rich dedicated tables (`ContainerBuild` / `VmBuild`) as the authoring
surface; `ArtifactKind` unifies those at the dispatch / enumeration / provenance
layer (replacing `TargetKind`), and a `BuildVariant` may optionally act as a
thin *delegating node* (`kind="container"` + `container="<name>"`) so a UI can
present one flat kind-tagged build list. One `validate()` gate enforces
kind×target / kind×service / kind×shape coherence; one dispatcher routes each
kind to its builder.
The `shape` axis stays exactly where it is and stays orthogonal: `shape` answers
*"fat or thin service composition"* and is meaningful for `Service` (and
`Container`); `kind` answers *"what artifact comes out"*. A build point is one
`(kind × shape × package × bin × features × profile × target)` point.
---
## 3. The enum (new — add to `variant.rs`, modelled on `ServiceShape`)
Insert after `ServiceShape`'s `impl` block (i.e. after `variant.rs:128`). String
serde so it reads from a bare TOML `kind = "service"`, identical style to
`Profile` / `ServiceShape`:
```rust
/// The **artifact kind** a build variant produces — the suite's THIRD build
/// axis, ORTHOGONAL to `shape` (fat/thin) and to
/// `(package × bin × features × profile × target)`.
///
/// `Exe`/`Lib`/`Wasm`/`Service` are all produced by `cargo build`
/// (`BuildVariant::cargo_args`); `Container`/`Vm` delegate to the rich
/// `ContainerBuild` / `VmBuild` descriptors. Deserializes from a bare TOML
/// string (`kind = "wasm"`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum ArtifactKind {
/// A native executable (default) — a plain binary/tool.
Exe,
/// A cargo library artifact (`--lib`); no `--bin`.
Lib,
/// A `wasm32-*` module (requires a wasm `target`).
Wasm,
/// An OCI / appliance image — delegates to a `ContainerBuild`.
Container,
/// A bootable VM / appliance — delegates to a `VmBuild`.
Vm,
/// A nordisk suite-member daemon: an executable that ALSO wraps as a
/// skidbladnir `ServiceSpec` (dedicated system user + unit). Requires a
/// `member`.
Service,
}
impl Default for ArtifactKind {
fn default() -> Self { ArtifactKind::Exe }
}
impl From<String> for ArtifactKind {
fn from(s: String) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"lib" | "library" | "rlib" | "cdylib" | "staticlib" => ArtifactKind::Lib,
"wasm" | "wasm32" | "web" => ArtifactKind::Wasm,
"container" | "image" | "oci" | "docker" => ArtifactKind::Container,
"vm" | "appliance" | "boot" => ArtifactKind::Vm,
"service" | "daemon" | "unit" => ArtifactKind::Service,
// "exe" | "bin" | "binary" | "" → the default.
_ => ArtifactKind::Exe,
}
}
}
impl From<ArtifactKind> for String {
fn from(k: ArtifactKind) -> Self { k.as_str().to_string() }
}
impl ArtifactKind {
/// Stable label (matches the canonical string form).
pub fn as_str(&self) -> &'static str {
match self {
ArtifactKind::Exe => "exe",
ArtifactKind::Lib => "lib",
ArtifactKind::Wasm => "wasm",
ArtifactKind::Container => "container",
ArtifactKind::Vm => "vm",
ArtifactKind::Service => "service",
}
}
/// True for the kinds produced by `cargo build` (Exe/Lib/Wasm/Service) — the
/// dispatcher routes these through `build_variant`, the rest through the
/// image/vm builders.
pub fn is_cargo(&self) -> bool {
matches!(self, ArtifactKind::Exe | ArtifactKind::Lib
| ArtifactKind::Wasm | ArtifactKind::Service)
}
/// True for kinds whose composition the fat/thin `shape` + `embeds`/`reaches`
/// machinery applies to (Service, Container).
pub fn is_service_shaped(&self) -> bool {
matches!(self, ArtifactKind::Service | ArtifactKind::Container)
}
}
```
---
## 4. The field on `BuildVariant`
In the `BuildVariant` struct (`variant.rs:148-191`) add — placed after `shape`
(`variant.rs:181`) so the two axes sit together, but with a serde default so
every existing TOML/JSON still parses:
```rust
/// The **artifact kind** this variant produces (default [`ArtifactKind::Exe`]).
/// Orthogonal to [`shape`](Self::shape).
#[serde(default, skip_serializing_if = "is_exe_kind")]
pub kind: ArtifactKind,
/// For `kind = "container"`: the `[[build.container]]` this variant delegates
/// to (a thin unifying node so a UI can list every kind in one build list).
/// Ignored for other kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_ref: Option<String>,
/// For `kind = "vm"`: the `[[build.vm]]` this variant delegates to. Ignored
/// for other kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vm_ref: Option<String>,
```
Add the serde predicate next to `is_fat_shape` (`variant.rs:197`):
```rust
fn is_exe_kind(k: &ArtifactKind) -> bool { *k == ArtifactKind::Exe }
```
Notes:
- `kind` defaults to `Exe`, so **`BuildVariant::default()` (`derive(Default)` at
`variant.rs:148`) is unchanged** — `ArtifactKind::Default = Exe`. No existing
`..Default::default()` test breaks.
- `container_ref` / `vm_ref` exist ONLY to support the optional delegating-node
authoring style. Most suites keep declaring `[[build.container]]` /
`[[build.vm]]` directly and never write a `kind="container"` variant; the
unified enumeration in §6 covers both authoring styles.
---
## 5. Build dispatch per kind
### 5a. `cargo_args` becomes kind-aware (`variant.rs:209-242`)
Add, right after the `--bin` block (`variant.rs:218`) and before `--features`:
```rust
match self.kind {
// A lib variant builds the crate library, never a bin.
ArtifactKind::Lib => a.push("--lib".to_string()),
_ => {}
}
```
`Wasm` needs no new arg here — the `--target wasm32-…` it requires is already
emitted by the existing `target` block (`variant.rs:237-240`); §5c's validation
guarantees the target is present and is a `wasm32` triple. `Exe`/`Service` are
plain `cargo build -p … --bin …` exactly as today.
### 5b. `ContainerBuild` / `VmBuild` report their kind
`container.rs` — add to `impl ContainerBuild` (near `container.rs:199`):
```rust
/// This descriptor's artifact kind — always [`ArtifactKind::Container`].
pub fn artifact_kind(&self) -> crate::variant::ArtifactKind {
crate::variant::ArtifactKind::Container
}
```
`vm.rs` — add to `impl VmBuild` (near `vm.rs:173`):
```rust
/// This descriptor's artifact kind — always [`ArtifactKind::Vm`].
pub fn artifact_kind(&self) -> crate::variant::ArtifactKind {
crate::variant::ArtifactKind::Vm
}
```
### 5c. Replace `image_build::TargetKind` with `ArtifactKind`
`image_build::TargetKind` (`image_build.rs:69-87`) is `{Container, Vm}` — a strict
subset. Delete it and use `ArtifactKind` in `TargetProvenance.kind`
(`image_build.rs:159`) and everywhere `TargetKind` appears
(`image_build.rs:191`, `:207`, the `build_container`/`build_vm` bodies, the
`TargetKind` re-export in `lib.rs:66`).
`ArtifactKind`'s string serde already yields `"container"` / `"vm"`, matching the
old `#[serde(rename_all = "snake_case")]` output for those two variants, so
**existing sealed target-provenance JSON keeps deserializing**. Keep the
`TargetKind` name as a deprecated alias for one release if any external caller
imports it: `pub use variant::ArtifactKind as TargetKind;`.
### 5d. Single dispatcher
Add to `impl BuildVariantSet` (after `build`, `variant.rs:627`). It resolves a
handle to a kind, then routes:
```rust
/// The artifact kind a declared handle produces — a variant's `kind`, or
/// Container/Vm for a handle naming a declared image/vm.
pub fn kind_of(&self, handle: &str) -> Option<ArtifactKind> {
if let Some(v) = self.get(handle) { return Some(v.kind); }
if self.container(handle).is_some() { return Some(ArtifactKind::Container); }
if self.vm(handle).is_some() { return Some(ArtifactKind::Vm); }
None
}
/// Build any declared artifact by handle, dispatching on its kind. Cargo
/// kinds (Exe/Lib/Wasm/Service) run `build_variant`; Container/Vm run through
/// the injected image/vm builder seams. Returns the kind that was built (the
/// caller pairs it with the produced attestation).
pub fn dispatch_kind(&self, handle: &str) -> Result<ArtifactKind> {
self.kind_of(handle)
.ok_or_else(|| anyhow!("no build artifact '{handle}'"))
}
```
The *actual* build entrypoints already exist and stay the front door — the
dispatcher only decides which to call:
- `Exe` / `Lib` / `Service` → `provenance::build_variant(set, handle, root)`
(`provenance.rs:384`). For **`Service`**, the caller additionally calls
`service::service_spec(variant, exec_dir)` (`service.rs:165`) on the built
variant to get the skidbladnir hand-off spec — this is the existing bridge, now
gated to `kind == Service`.
- `Wasm` → `provenance::build_variant` too (it is a cargo build); the wasm
`--target` rides through unchanged. (A later slice may add a
`wasm-bindgen`/`wasm-opt` post-step; out of scope here.)
- `Container` → `image_build::build_container(builder, container, repo, root)`.
- `Vm` → `image_build::build_vm(builder, vm, engine, root)`.
Because the image/vm builders are seams (`ImageBuilder`, `VmImageBuilder`,
`image_build.rs:114`/`:132`), a fully-generic `build(handle)` that also runs
containers/vms would have to carry those seam objects; keep the thin `kind_of` /
`dispatch_kind` resolvers in the dep-light core and let the host (nornir CLI /
`elm.rs`) own the `match kind { … }` that calls the concrete builder with its
injected seam. This preserves the crate's dep-light default (`lib.rs:5-7`).
---
## 6. Unified enumeration + surface
Add to `impl BuildVariantSet` a flat kind-tagged listing so a CLI / UI shows one
build list instead of three:
```rust
/// Every declared artifact as `(kind, handle)`, across variants + containers
/// + vms — the unified multi-axis output list.
pub fn artifacts(&self) -> Vec<(ArtifactKind, &str)> {
let mut out: Vec<(ArtifactKind, &str)> =
self.variants.iter().map(|v| (v.kind, v.name.as_str())).collect();
out.extend(self.containers.iter().map(|c| (ArtifactKind::Container, c.name.as_str())));
out.extend(self.vms.iter().map(|vm| (ArtifactKind::Vm, vm.name.as_str())));
out
}
```
`surface.rs` — add a `kind: String` field to `VariantView` (`surface.rs:34-60`)
set from `v.kind.as_str()` in `VariantView::of` (`surface.rs:63`); the container /
vm views already carry their kind implicitly, but adding an explicit `kind` to
`ContainerView` / `VmView` lets the UI colour/group by kind uniformly. `elm.rs`'s
`BuildThingMsg::Build` (`elm.rs:160`) then dispatches via §5d's `kind_of`.
---
## 7. Validation rules (the coherence gate)
Extend `BuildVariantSet::validate` (`variant.rs:398-442`) with a new
`validate_kind(v)` call, added as **step 5** right after the `validate_shape`
call (`variant.rs:440`). New method (mirrors `validate_shape`):
```rust
/// Kind coherence: enforce that a variant's `kind` agrees with its target,
/// service identity, bin, and composition fields.
pub fn validate_kind(&self, v: &BuildVariant) -> Result<()> {
match v.kind {
// Wasm ⇒ a wasm32 target is mandatory.
ArtifactKind::Wasm => {
let t = v.target.as_deref().unwrap_or_default();
if !t.starts_with("wasm32") {
return Err(anyhow!(
"variant '{}': kind 'wasm' requires a wasm32 `target` \
(e.g. wasm32-unknown-unknown), got '{}'",
v.name, if t.is_empty() { "<none>" } else { t }
));
}
}
// Service ⇒ must resolve a ServiceSpec identity: needs a `member`
// (the dedicated system-user name skidbladnir installs it under).
ArtifactKind::Service => {
if v.member.as_deref().unwrap_or("").trim().is_empty() {
return Err(anyhow!(
"variant '{}': kind 'service' requires a `member` \
(the dedicated system-user / ServiceSpec identity)",
v.name
));
}
// A service is exe-shaped, never a wasm module.
if v.target.as_deref().map_or(false, |t| t.starts_with("wasm32")) {
return Err(anyhow!(
"variant '{}': a 'service' is a native daemon, not a wasm target",
v.name
));
}
}
// Lib ⇒ builds the crate library, so no `--bin` may be selected.
ArtifactKind::Lib => {
if v.bin.is_some() {
return Err(anyhow!(
"variant '{}': kind 'lib' builds the package library and \
may not select a `bin`",
v.name
));
}
}
// Delegating nodes must point at a declared descriptor.
ArtifactKind::Container => {
let name = v.container_ref.as_deref().ok_or_else(|| anyhow!(
"variant '{}': kind 'container' needs `container_ref = \"…\"`", v.name))?;
self.require_container(name)?; // exists?
}
ArtifactKind::Vm => {
let name = v.vm_ref.as_deref().ok_or_else(|| anyhow!(
"variant '{}': kind 'vm' needs `vm_ref = \"…\"`", v.name))?;
self.require_vm(name)?;
}
ArtifactKind::Exe => {}
}
// Composition (embeds/reaches) is only meaningful for service-shaped
// kinds. A non-service that embeds siblings is incoherent.
if !v.embeds.is_empty() && !v.kind.is_service_shaped() {
return Err(anyhow!(
"variant '{}': `embeds` requires kind 'service' (or 'container'); \
kind '{}' produces no composed service",
v.name, v.kind.as_str()
));
}
Ok(())
}
```
Rule summary (the coherence matrix):
| kind | requires | forbids | shape/embeds/reaches |
|---|---|---|---|
| `Exe` | — | — | shape ignored; `reaches` OK (thin gRPC-client tool), `embeds` rejected |
| `Lib` | — | `bin` | no embeds |
| `Wasm` | `target=wasm32-*` | — | no embeds |
| `Service` | `member` | wasm target | full fat/thin machinery (`validate_shape`, `variant.rs:455`) applies |
| `Container` | `container_ref` → declared | — | may embed (fat image) |
| `Vm` | `vm_ref` → declared | — | — |
`validate_shape` (`variant.rs:455-513`) is unchanged but is now *scoped* by this
step: because `embeds` non-empty now implies `kind ∈ {Service, Container}`, the
downstream-asymmetry rule (`variant.rs:477-511`) only ever fires for genuinely
composed services — a tightening, not a behaviour change for existing valid data
(every current embedding variant becomes `kind="service"`, see §8).
---
## 8. TOML migration (`kind =` on existing files)
`kind` defaults to `Exe`, so **every existing file keeps parsing untouched** —
migration is additive and per-variant. The rule: a variant with a `member` +
`shape` (a suite daemon) becomes `kind = "service"`; a plain build stays `Exe`
(omit `kind`). Concrete diffs:
### `nornir/.nornir/build-variants.toml`
Both variants have `member = "nornir"` + `shape` ⇒ services.
- `[[build.variant]] name = "nornir"` (line 30): add `kind = "service"`.
- `[[build.variant]] name = "nornir-thin"` (line 42): add `kind = "service"`.
### `holger/.nornir/build-variants.toml`
- The three deployment exes `embedded` / `mini` / `full` (lines 56, 67, 77) have
no `member` ⇒ leave as `Exe` (optionally add explicit `kind = "exe"` for
documentation).
- `[[build.variant]] name = "holger"` (line 105, member+shape=fat) → `kind = "service"`.
- `[[build.variant]] name = "holger-thin"` (line 119, member+shape=thin) → `kind = "service"`.
### `dwarves/.nornir/build-variants.toml`
- `dwarves` (line 38), `dwarves-thin` (line 52), `dwarves-server` (line 66) —
all have `member = "dwarves"` + `shape` ⇒ add `kind = "service"`.
- `dwarves-ui` (line 82) has **no `member`**, `shape="mixed"`, `reaches=["nornir"]`
— it is a UI/demo binary that is a gRPC *client*, not a daemon. Leave `kind =
"exe"`. This is exactly why `reaches` is permitted for `Exe` while `embeds`
is not: the UI depends on a remote nornir but composes nothing in.
### `skade/.nornir/build-variants.toml` (+ any facett/web member)
Add the first **wasm** example to exercise the new coherence rule:
```toml
[[build.variant]]
name = "skade-wasm"
package = "skade-web" # the cdylib crate
kind = "wasm"
target = "wasm32-unknown-unknown"
no_default_features = true
profile = "release"
```
### Container / Vm tables
`[[build.container]]` / `[[build.vm]]` need **no change** — they report their
kind via `artifact_kind()` (§5b) and appear in `artifacts()` (§6). Only add a
delegating `kind="container"` / `kind="vm"` variant if a suite wants that image
in the *same flat variant list* as its exes (rare; optional).
---
## 9. Exports + tests
**`lib.rs`:**
- `variant.rs` re-export (`lib.rs:80`): add `ArtifactKind` →
`pub use variant::{ArtifactKind, BuildConfig, BuildVariant, BuildVariantSet, Profile, ServiceShape};`
- `image_build` re-export (`lib.rs:64-67`): drop `TargetKind` (or alias it, §5c).
**Tests to add** (mirror the existing `service_shape_parses_and_round_trips`
style, `variant.rs:1106`):
1. `artifact_kind_parses_and_round_trips` — every spelling + canonical string +
default `Exe`.
2. `kind_defaults_to_exe_and_round_trips` — a `[[build.variant]]` with no `kind`
parses as `Exe`; serialize omits the field (via `is_exe_kind`).
3. `wasm_kind_requires_wasm32_target` — `kind="wasm"` with no/`x86_64` target is
rejected; with `wasm32-unknown-unknown` it validates and `cargo_args`
contains `--target wasm32-unknown-unknown`.
4. `service_kind_requires_member` — `kind="service"` without `member` rejected;
with `member` set it validates and `service_spec` yields `user=Some(member)`.
5. `lib_kind_forbids_bin_and_emits_lib_flag` — `kind="lib"` + `bin` rejected;
valid lib's `cargo_args` contains `--lib`, not `--bin`.
6. `embeds_requires_service_kind` — a `kind="exe"` variant with non-empty
`embeds` is rejected; the same `embeds` under `kind="service"` validates.
7. `container_delegating_node_resolves` — `kind="container"` +
`container_ref="tunnr-appliance"` validates only when that container is
declared.
8. Update `nornir_own_build_variants_discovers` (`variant.rs:1122`) to assert
`set.get("nornir").unwrap().kind == ArtifactKind::Service` after the §8 edit.
9. `target_provenance` round-trip test in `image_build` still green after the
`TargetKind → ArtifactKind` swap (proves the serde `"container"`/`"vm"`
compat).
**Red-when-broken guarantee** (testable-code law): tests 3–6 each assert a
`validate` **error string** on the incoherent case and success on the coherent
one, so a regression that drops a coherence rule turns them red.
---
## 10. Orthogonality proof (why this is a clean axis, not a merge)
- `kind` and `shape` never collapse: a `Service` can be `FatService`,
`ThinStandalone`, or `Mixed`; an `Exe` ignores `shape`. The two enums are
independent fields with independent validators (`validate_kind` vs
`validate_shape`).
- `kind` and `(package × bin × features × profile × target)` stay independent —
`kind` adds only *constraints* on `target` (Wasm) and `bin` (Lib), never
replaces the tuple.
- The appliance kinds keep their descriptors; `ArtifactKind` is the *seam label*
that lets one dispatcher, one enumeration, and one provenance `kind` field
span all six — replacing the accidental "which collection did it come from"
dispatch and the subset `TargetKind`.
---
## 11. Execution order for the build agent
1. `variant.rs`: add `ArtifactKind` enum + `is_exe_kind` + `kind`/`container_ref`/`vm_ref` fields.
2. `variant.rs`: `cargo_args` `--lib` for `Lib`; `validate_kind` + wire into `validate` as step 5.
3. `variant.rs`: `kind_of` / `dispatch_kind` / `artifacts` on `BuildVariantSet`.
4. `container.rs` / `vm.rs`: `artifact_kind()`.
5. `image_build.rs`: `TargetKind` → `ArtifactKind` (field + fns + serde-compat check).
6. `service.rs`: gate `service_spec` usage to `kind == Service` at call sites (no signature change needed).
7. `surface.rs` / `elm.rs`: `kind` field on views + kind dispatch in `Build`.
8. `lib.rs`: exports.
9. Add the §9 tests; `cargo test -p nornir-build-thing`.
10. Apply the §8 TOML `kind =` edits across nornir / holger / dwarves / skade; run each repo's `validate_all` (the `*_own_build_variants_discovers` tests) green.
```