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), fieldBuildVariant.shape(variant.rs:180-181), validated byvalidate_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:
/// 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:
/// 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):
fn is_exe_kind(k: &ArtifactKind) -> bool { *k == ArtifactKind::Exe }
Notes:
kinddefaults toExe, soBuildVariant::default()(derive(Default)atvariant.rs:148) is unchanged —ArtifactKind::Default = Exe. No existing..Default::default()test breaks.container_ref/vm_refexist ONLY to support the optional delegating-node authoring style. Most suites keep declaring[[build.container]]/[[build.vm]]directly and never write akind="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:
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):
/// 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):
/// 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:
/// 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). ForService, the caller additionally callsservice::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 tokind == Service.Wasm→provenance::build_varianttoo (it is a cargo build); the wasm--targetrides through unchanged. (A later slice may add awasm-bindgen/wasm-optpost-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:
/// 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):
/// 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): addkind = "service".[[build.variant]] name = "nornir-thin"(line 42): addkind = "service".
holger/.nornir/build-variants.toml
- The three deployment exes
embedded/mini/full(lines 56, 67, 77) have nomember⇒ leave asExe(optionally add explicitkind = "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 havemember = "dwarves"+shape⇒ addkind = "service".dwarves-ui(line 82) has nomember,shape="mixed",reaches=["nornir"]— it is a UI/demo binary that is a gRPC client, not a daemon. Leavekind = "exe". This is exactly whyreachesis permitted forExewhileembedsis 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:
[[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.rsre-export (lib.rs:80): addArtifactKind→pub use variant::{ArtifactKind, BuildConfig, BuildVariant, BuildVariantSet, Profile, ServiceShape};image_buildre-export (lib.rs:64-67): dropTargetKind(or alias it, §5c).
Tests to add (mirror the existing service_shape_parses_and_round_trips style, variant.rs:1106):
artifact_kind_parses_and_round_trips— every spelling + canonical string + defaultExe.kind_defaults_to_exe_and_round_trips— a[[build.variant]]with nokindparses asExe; serialize omits the field (viais_exe_kind).wasm_kind_requires_wasm32_target—kind="wasm"with no/x86_64target is rejected; withwasm32-unknown-unknownit validates andcargo_argscontains--target wasm32-unknown-unknown.service_kind_requires_member—kind="service"withoutmemberrejected; withmemberset it validates andservice_specyieldsuser=Some(member).lib_kind_forbids_bin_and_emits_lib_flag—kind="lib"+binrejected; valid lib'scargo_argscontains--lib, not--bin.embeds_requires_service_kind— akind="exe"variant with non-emptyembedsis rejected; the sameembedsunderkind="service"validates.container_delegating_node_resolves—kind="container"+container_ref="tunnr-appliance"validates only when that container is declared.- Update
nornir_own_build_variants_discovers(variant.rs:1122) to assertset.get("nornir").unwrap().kind == ArtifactKind::Serviceafter the §8 edit. target_provenanceround-trip test inimage_buildstill green after theTargetKind → ArtifactKindswap (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)
kindandshapenever collapse: aServicecan beFatService,ThinStandalone, orMixed; anExeignoresshape. The two enums are independent fields with independent validators (validate_kindvsvalidate_shape).kindand(package × bin × features × profile × target)stay independent —kindadds only constraints ontarget(Wasm) andbin(Lib), never replaces the tuple.- The appliance kinds keep their descriptors;
ArtifactKindis the seam label that lets one dispatcher, one enumeration, and one provenancekindfield span all six — replacing the accidental "which collection did it come from" dispatch and the subsetTargetKind.
11. Execution order for the build agent
variant.rs: addArtifactKindenum +is_exe_kind+kind/container_ref/vm_reffields.variant.rs:cargo_args--libforLib;validate_kind+ wire intovalidateas step 5.variant.rs:kind_of/dispatch_kind/artifactsonBuildVariantSet.container.rs/vm.rs:artifact_kind().image_build.rs:TargetKind→ArtifactKind(field + fns + serde-compat check).service.rs: gateservice_specusage tokind == Serviceat call sites (no signature change needed).surface.rs/elm.rs:kindfield on views + kind dispatch inBuild.lib.rs: exports.- Add the §9 tests;
cargo test -p nornir-build-thing. - Apply the §8 TOML
kind =edits across nornir / holger / dwarves / skade; run each repo'svalidate_all(the*_own_build_variants_discoverstests) green.