crates/skade-build-thing/src/recipe.rs
//! **The recipe** — skade's `impl BuildThing`, the ONE declaration of what this
//! repository builds. `.nornir/build-variants.toml` is emitted from
//! [`SkadeBuildThing::variants`] (see [`crate::emit`]); nothing here reads it.
//!
//! # What skade declares, honestly
//!
//! skade is the warehouse ENGINE and the **graph root** of the nordisk suite
//! build matrix. It is TRI-MODAL from the consumer's side — a consumer either
//! compiles skade in-process and owns the `RedbCatalog` directly, or reaches a
//! running `skade-katalog serve` over gRPC / Iceberg-REST — but note whose axis
//! that is: it is the CONSUMER's linkage, not skade's. What skade itself ships is
//! two things, and there are two rows:
//!
//! | variant | kind | package · bin | what it is |
//! |---|---|---|---|
//! | `skade` ⭐ | lib | `skade` | the embeddable Iceberg library, with the `s3` object-store backend |
//! | `skade-remote` ⭐ | service | `skade-katalog-serve` · `skade-katalog` | the catalog service that owns the state |
//!
//! # The root invariant, made unbreakable rather than remembered
//!
//! **Nothing is downstream of skade**, so it embeds no sibling and reaches none.
//! The EMPTY `downstream` allowlist is what turns that from a sentence into a
//! check: any `embeds = [...]` on a skade variant is a dependency-graph inversion
//! that `validate_shape` rejects. The service row states `embeds` and `reaches`
//! as explicitly empty rather than omitting them, for the same reason every other
//! recipe in this constellation states `linkage` — an omitted axis is
//! indistinguishable from one somebody forgot.
//!
//! # `sql` is gone, and the hand-written file kept offering it for nine days
//!
//! The DataFusion surface moved OUT of skade on 2026-08-14 (`0369d66`, "SQL moves
//! out of skade so skade can publish again") into the sibling `skade-sql` crate,
//! because a git datafusion dep with no version makes the whole crate
//! unpublishable. From that day `cargo check -p skade --features s3,sql` failed
//! with *"the package `skade` does not contain this feature: sql"* — which means
//! the DEFAULT library variant, the one the build menu offers, named a build
//! point that could not be built. It was removed on 2026-08-23. It is worth
//! recording here because it is precisely what a hand-maintained recipe cannot
//! catch and an emitted one can: a feature list nothing compiles is a list nobody
//! checks.
//!
//! # The six features nothing builds
//!
//! `aws-s3`, `rustfs`, `lineage`, `stream`, `lineage-http` and `testmatrix` were
//! recorded as declared-but-never-built on 2026-07-22 — in prose. They are
//! [`allow_unbuilt`] data now, each with a written reason.
//!
//! Two feature names the workspace declares are deliberately NOT offered here,
//! and that is stated rather than quietly true: `geo-http` (skade's HTTP
//! byte-range GeoParquet reader) and `bench` (the re-export that lets the
//! out-of-crate nornir-bench harness A/B the partition-scan accelerator). Neither
//! is a shape a variant selects — the first is a consumer's viewer decision, the
//! second exists for a bench harness — and the sibling crates' own feature names
//! (`object-store`, `uring`, `rest`) belong to *their* manifests. Widening
//! `offers` to cover them would make this recipe claim an axis it does not drive.
//! See `.nornir/build-thing-design.md`, "what is deliberately not offered".
use std::collections::{BTreeMap, BTreeSet};
use nornir_build_thing::cargo_container::CargoToolchain;
use nornir_build_thing::contract::Coordinate;
use nornir_build_thing::plugin::{BuildThing, Manifest, Role, ABI_VERSION};
use nornir_build_thing::variant::{
ArtifactKind, BuildVariant, BuildVariantSet, Profile, ServiceShape,
};
/// The crate a receipt names as its recipe.
pub const KRATE: &str = "skade-build-thing";
/// The content hash over this crate's sources, computed by `build.rs`.
pub const CONTENT_HASH: &str = env!("SKADE_BUILD_THING_CONTENT_HASH");
/// **The delivery this repo ships in.** suites is a LIST — one row may ship
/// in more than one flavour — but skade has exactly one, and writing it down is
/// the whole point: an EMPTY list is not a third flavour, it is a row that
/// cargo xtask build --suite ORIGINAL cannot select. All three rows here were
/// exactly that until 2026-09-20. The only other flavour anywhere today is
/// holger's MVP; every other product declares ORIGINAL alone and TAGS it, so
/// the build verb has ONE shape everywhere.
pub const SUITE: &str = "ORIGINAL";
/// skade's build thing.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SkadeBuildThing;
impl SkadeBuildThing {
/// The coordinate a receipt names this recipe by: `krate:version:hash`.
#[must_use]
pub fn coordinate() -> Coordinate {
Coordinate {
krate: KRATE.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
hash: Some(CONTENT_HASH.to_string()),
}
}
}
fn s(x: &str) -> String {
x.to_string()
}
/// The three rows.
pub fn variants() -> Vec<BuildVariant> {
vec![
BuildVariant {
name: s("skade"),
suites: vec![s(SUITE)],
role: Some(Role::Library),
is_default: true,
member: Some(s("skade")),
package: Some(s("skade")),
// THE DETACHED-WORKSPACE FIX: skade/, serve/ and skade-flight/ are their
// own cargo workspaces (so the published skade-katalog manifest never
// reconciles with the arrow-59 tree). The forge runs ONE cargo at the
// repo root, where none of these packages exist — `manifest_dir` points
// it at the workspace that has them. MEASURED (skadefill-compat lane):
// cargo build --manifest-path skade/Cargo.toml -p skade --lib --release --features s3
// → Finished `release` in 8m29s
manifest_dir: Some(s("skade")),
kind: ArtifactKind::Lib,
shape: ServiceShape::NotApplicable,
features: vec![s("s3")],
profile: Profile::Release,
..BuildVariant::default()
},
BuildVariant {
name: s("skade-remote"),
suites: vec![s(SUITE)],
role: Some(Role::Server),
is_default: true,
member: Some(s("skade")),
package: Some(s("skade-katalog-serve")),
bin: Some(s("skade-katalog")),
// `serve`, NOT `skade`: `member` is the SUITE node, the package lives in serve/.
manifest_dir: Some(s("serve")),
kind: ArtifactKind::Service,
shape: ServiceShape::FatService,
// EMPTY, and said out loud: skade is the graph root, so there is
// nothing to embed and nothing to reach. `downstream` below makes any
// future `embeds` a refusal rather than a review comment.
embeds: BTreeSet::new(),
reaches: BTreeSet::new(),
profile: Profile::Release,
..BuildVariant::default()
},
BuildVariant {
// The SQL door: Apache Arrow Flight SQL over the warehouse — DataFusion
// SQL in, streamed Arrow out — for Power BI / ODBC / ADBC / DataFusion
// clients. This IS "skade remote" as the workspace decided it (skade IS
// DataFusion; remote = a standalone skade service). Own detached
// workspace (skade-flight/), like serve/: the datafusion tree never
// enters the published skade-katalog manifest. Not the server default:
// skade-remote keeps it. Opens the warehouse itself (one lock-owning
// RedbCatalog, same as serve) — run it OR serve on one dir, not both.
name: s("skade-flight"),
suites: vec![s(SUITE)],
role: Some(Role::Server),
is_default: false,
member: Some(s("skade")),
package: Some(s("skade-flight")),
bin: Some(s("skade-flight")),
manifest_dir: Some(s("skade-flight")),
kind: ArtifactKind::Service,
shape: ServiceShape::FatService,
embeds: BTreeSet::new(),
reaches: BTreeSet::new(),
profile: Profile::Release,
..BuildVariant::default()
},
]
}
/// The feature space skade offers — the object-store backends plus the
/// lineage/stream/testmatrix seams of the `skade` crate itself.
fn offers() -> Vec<&'static str> {
vec![
"s3",
"aws-s3",
"rustfs",
"lineage",
"stream",
"lineage-http",
"testmatrix",
]
}
/// **The six features no row builds, each with a written reason.**
pub fn allow_unbuilt() -> BTreeMap<String, String> {
[
("aws-s3", "the aws-sdk-s3 object-store backend, for a consumer that already carries the AWS SDK. WHICH backend is compiled in is the consumer's decision, not skade's — the ⭐ library row picks `s3` (rust-s3, MinIO/S3, configurable endpoint) because that is what this house runs, and a second row would be the same library with a different consumer's answer baked in."),
("rustfs", "the embedded local-filesystem ObjectStore — zero extra crates, just std. Same backend-is-the-consumer's-choice reason as `aws-s3`, and it is the one every test uses, so it is compiled constantly even though no delivery names it."),
("lineage", "the automatic lineage emit hook on `Table::append`. The lineage TYPES compile regardless; only the emission wiring is gated, so the default build pays nothing — and a shipped library that emitted lineage events to a sink nobody attached would be doing work for no reader."),
("stream", "the push CDC consume API (`ChangeStream` + `Table::changes`) over skade-katalog's commit broadcast. Purely additive and off by default for the same reason as `lineage`: the consumer decides whether it wants a changelog stream."),
("lineage-http", "the OpenLineage HTTP sink that POSTs each event to a Marquez-style collector. Its own feature so ureq + serde_json stay out of every other build, and enabling it does NOT imply `lineage` — the sink is attached explicitly, so a build point that turned it on would still emit nothing."),
("testmatrix", "flips the crate's `functional_status` markers from compiled-out no-ops into real nornir matrix rows. A TEST-HARNESS mode, never a delivery: the write/read data plane must pull no nornir dep."),
]
.into_iter()
.map(|(k, v)| (s(k), s(v)))
.collect()
}
/// **The root invariant as data**: nothing is downstream of skade, so the
/// allowlist is empty and any `embeds` is a refusal.
fn downstream() -> BTreeMap<String, BTreeSet<String>> {
[(s("skade"), BTreeSet::new())].into_iter().collect()
}
/// **The pinned build image every cargo variant compiles in** — `[build.toolchain]`.
///
/// Until 2026-09-10 this recipe declared none, and the consequence was NOT that
/// skade built on the host. `dwarves buildthing` builds this very crate's
/// wasm32 module INSIDE this container (`dwarves::build_thing::code::run_cargo`),
/// so an absent table meant `CargoToolchain::validate` refused —
/// *"no `image_sha256` — a named tag is a convenience, NOT a pin"* — the code
/// road closed, and the forge fell back to reading the emitted
/// `.nornir/build-variants.toml` as if it were the source. The whole
/// code-is-source ruling was out of force here for want of one line; the
/// 2026-09-10 inventory render says `toml` where it should say `kod`, for skade
/// and six siblings.
///
/// The digest is the **MANIFEST** digest (64 lowercase hex, no `sha256:`), read
/// off the engine rather than typed from memory:
/// `cargo run -p nornir-build-thing --features image-build --example
/// image_digest -- nordisk-cargo-toolchain:live` — the bollard door added the
/// same day, because before it the only way to learn a digest was the
/// `podman image inspect` the no-shelling law forbids. The image recipe is
/// reproducible (edda `ac3852e`: base pinned by digest, a snapshot.debian.org
/// instant, `SOURCE_DATE_EPOCH`), so every box that mints it lands on this
/// digest. It moves only when the image RECIPE moves, in the same commit.
/// MOVED 2026-09-20 (loki). The value below was
/// `2cf9b012c9d2ffa42b447e5b0d127184e5a3f998dd2574d771477e9c8e366659` — a
/// second mint of this same recipe which exists on exactly one box, t14s, and
/// sat there UNTAGGED (`<none>:<none>`, 2026-07-13). On loki it does not exist
/// at all, so `CargoToolchain::validate` refused every container build of this
/// repo BY DIGEST — correctly, and that is the whole point of a pin: it told
/// the truth about an environment that was not there. The digest now names the
/// mint that is tagged `nordisk-cargo-toolchain:live` and `:live-uefi` on both
/// boxes, and which composed the live gunnar appliance ISO; `gunnar` and
/// `holger` have pinned it since 2026-09-15. Read back on loki through the
/// engine door, not from podman hearsay:
/// `cargo run -p nornir-build-thing --features image-build --example
/// image_digest -- nordisk-cargo-toolchain:live` ->
/// `3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143`.
/// Same `rust_version` 1.97.1, and the target list below is a subset of what
/// that image carries (it adds `x86_64-unknown-uefi`), so the pin moves and
/// nothing else does.
fn toolchain() -> Option<CargoToolchain> {
Some(CargoToolchain {
image: s("nordisk-cargo-toolchain"),
// RE-PINNED 2026-10-01 (lane R, dwarf), `3d5115ba…` → `668468e7…`: edda 6b5f905 added
// clang + llvm to cargo-toolchain.Containerfile (same Debian snapshot, same
// SOURCE_DATE_EPOCH) and no longer mints `3d5115ba…`; this is the new manifest digest.
image_sha256: s("668468e775ad80f32d700cbc9709d41cac726f40d00649461a0932f30d1b2067"),
rust_version: s("1.97.1"),
// What the pinned image CARRIES std for (`rustup target list
// --installed` inside it), not what skade delivers. wasm32 is not
// optional here: it is the target this crate's own plugin module is
// built for, and a declaration that omitted it would refuse the code
// road by name before the container ever ran.
targets: vec![
s("x86_64-unknown-linux-gnu"),
s("x86_64-unknown-linux-musl"),
s("wasm32-unknown-unknown"),
],
timeout_secs: 3600,
mem_limit_mb: 8192,
cpus: 8,
network: false,
remap_paths: true,
// MEASURED 2026-09-10 (`--example escaping_siblings /home/rickard/git/skade`),
// not guessed: the checkouts skade's own `path = "../.."` dependencies
// reach into. `bench/` is most of it — `jera`, `nornir-hash`, `nornir`,
// `draupnir` and `znippy-zoomies` — and this crate's own
// `nornir-build-thing` is the sixth edda dep. A container holding only
// skade cannot resolve any of them, and the error it fails with names a
// manifest path, not this list. If a sibling path dep is added or
// removed, this list moves in the SAME commit.
siblings: vec![s("draupnir"), s("edda"), s("nornir"), s("znippy-zoomies")],
})
}
/// **The `[[bin]]` targets no row builds, each with its reason** —
/// `[build.allow_unbuilt_bins]`. ⚒ Build's gate (`buildgate::tally_bins`) counts every
/// bin cargo can see in this tree; MEASURED 2026-10-16 on loki these had neither a
/// row nor a reason (`tests/build_gate.rs` now asks the same question).
fn allow_unbuilt_bins() -> BTreeMap<String, nornir_build_thing::variant::AllowEntry> {
[
("skade-build-thing", "this recipe crate's own emit door (`cargo run -p skade-build-thing -- emit` regenerates .nornir/build-variants.toml); the forge loads the crate as wasm, the bin is repository tooling, never shipped."),
("catalog-bench", "bench/ (skade-katalog-bench, a DETACHED workspace kept out of the published crate's tree and lock): the catalog benchmark runner (embedded vs Nessie vs Polaris) — a bench tool, never shipped."),
("bench-containers", "bench/ (detached workspace): brings the bench's competitor backends (RustFS, Nessie, Polaris) up and down for catalog-bench — bench tooling, never shipped."),
]
.into_iter()
.map(|(k, v): (&str, &str)| (k.to_string(), nornir_build_thing::variant::AllowEntry::Reason(v.to_string())))
.collect()
}
impl BuildThing for SkadeBuildThing {
fn manifest(&self) -> Manifest {
Manifest {
abi: ABI_VERSION,
repo: s("skade"),
source_crate: s(KRATE),
summary: "skade — the warehouse ENGINE and the graph ROOT of the nordisk suite: \
a fast, embeddable Apache Iceberg library and the `skade-katalog` \
catalog service that owns the state. Nothing is downstream of it."
.to_string(),
}
}
fn variants(&self) -> BuildVariantSet {
BuildVariantSet {
// ONE delivery, named rather than left implicit, so --suite ORIGINAL
// resolves here instead of falling through to UNSUITED.
base_suite: Some(s(SUITE)),
offers: offers().into_iter().map(s).collect(),
allow_unbuilt: allow_unbuilt(),
allow_unbuilt_bins: allow_unbuilt_bins(),
downstream: downstream(),
variants: variants(),
toolchain: toolchain(),
..BuildVariantSet::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_build_thing::feature_axis::FeatureAxis;
/// **The graph root embeds nothing, and a variant that tries is refused.**
/// Stated as a check rather than a sentence, because a dependency-graph
/// inversion is not something anybody notices by reading.
#[test]
fn nothing_is_downstream_of_skade_and_an_embed_is_refused() {
let set = SkadeBuildThing.variants();
assert_eq!(
set.downstream.get("skade").map(BTreeSet::len),
Some(0),
"the allowlist must be EMPTY and present — an absent allowlist and an \
empty one are different claims"
);
for v in &set.variants {
assert!(v.embeds.is_empty() && v.reaches.is_empty(), "'{}'", v.name);
}
let inverted = BuildVariant {
name: s("skade-inverted"),
package: Some(s("skade-katalog-serve")),
member: Some(s("skade")),
kind: ArtifactKind::Service,
embeds: ["korp"].into_iter().map(s).collect(),
..BuildVariant::default()
};
assert!(
set.validate(&inverted).is_err(),
"a skade variant embedding a sibling inverts the suite dep graph"
);
}
/// **`sql` may not come back by accident.** It moved to the sibling
/// `skade-sql` crate on 2026-08-14 and the recipe kept offering it for nine
/// days, during which the ⭐ library row named a build point cargo refused.
#[test]
fn the_recipe_does_not_offer_a_feature_this_crate_no_longer_has() {
let set = SkadeBuildThing.variants();
assert!(
!set.offers.contains("sql"),
"`sql` lives in the sibling skade-sql crate; offering it here makes the \
default row a build point cargo refuses"
);
for v in &set.variants {
assert!(!v.features.iter().any(|f| f == "sql"), "'{}'", v.name);
}
}
/// The feature axis does not rot: seven offered, one built, six excused by
/// name — and a stale excuse fails too.
#[test]
fn the_feature_axis_gate_is_clean() {
let set = SkadeBuildThing.variants();
if let Err(rot) = FeatureAxis::of("skade", &set).gate() {
panic!("the feature axis is rotting: {rot:#?}");
}
}
}