crates/knut-build-thing/src/recipe.rs
//! **The recipe** — knut's `impl BuildThing`, the ONE declaration of what this
//! repository builds. `.nornir/build-variants.toml` is EMITTED from
//! [`KnutBuild::variants`] (see [`crate::emit`]); nothing here reads it.
//!
//! # What was wrong, exactly
//!
//! Until 2026-09-20 this crate did:
//!
//! ```ignore
//! const VARIANTS_TOML: &str = include_str!("../build-variants.toml");
//! BuildVariantSet::from_toml_str(VARIANTS_TOML).expect("must parse (compiled in)")
//! ```
//!
//! — the hand-written toml COMPILED IN and READ BACK AS AUTHORITY. That is the
//! exact inversion `dwarves/.nornir/build-thing-code-is-source-design.md` forbids:
//! the code is the source, the toml is its projection, one-directional. With the
//! file as authority the declaration was a thing nothing could check — no type,
//! no exhaustiveness, no closure, and a `toml_parity` test that could only ever
//! compare a file with itself. It is gone; `emitted_toml()` is the only writer and
//! the file is inert (`tests/toml_parity.rs` measures the inertness rather than
//! asserting it).
//!
//! # The fourteen rows
//!
//! | variant | role | typ | package · bin | features |
//! |---|---|---|---|---|
//! | `knutpipe` ⭐ | tool | exe | knut-pipelines · knutpipe | — |
//! | `bifrost` | tool | exe | knut-bifrost · bifrost | `cli` |
//! | `bifrost-doris` | server | exe | knut-bifrost · bifrost | `cli`, `service` |
//! | `bifrost-falkordb` | server | exe | knut-bifrost · bifrost | `cli`, `falkordb-service` |
//! | `bifrost-falkordb-aistor` | server | exe | knut-bifrost · bifrost | … + `catalog-aistor` |
//! | `bifrost-falkordb-polaris` | server | exe | knut-bifrost · bifrost | … + `catalog-polaris` |
//! | `bifrost-falkordb-unity` | server | exe | knut-bifrost · bifrost | … + `catalog-unity` |
//! | `bifrost-falkordb-gravitino` | server | exe | knut-bifrost · bifrost | … + `catalog-gravitino` |
//! | `bifrost-falkordb-grpc` | server | exe | knut-bifrost · bifrost | … + `skade-grpc` |
//! | `bifrost-full` | server | exe | knut-bifrost · bifrost | `cli`, `service`, `catalogs`, `skade-grpc` |
//! | `knut-bifrost-wasm-guest` | tool | wasm | knut-bifrost-wasm-guest | — |
//! | `knut-spark-server` | server | exe | knut-pipelines · knut-spark-server | `server` |
//! | `graphar-flight` ⭐ | server | exe | graphar-flight · graphar-flight | `bin` |
//! | `knut-popviz` ⭐ | client | exe | knut-popviz · knut-popviz | `gui` |
//!
//! ONE BUILD ⇒ ONE VARIANT. A variant that is not a distinct build is a row that
//! lies, which is why `spark41` — a feature that MODIFIES `bifrost` and ships no
//! artifact of its own — is offered and excused rather than given a row.
//!
//! # The two rules only knut knows
//!
//! 1. **`required-features` binaries.** FOUR of knut's five binaries carry them
//! (`bifrost` → `cli`, `knut-popviz` → `gui`, `knut-spark-server` → `server`,
//! `graphar-flight` → `bin`; the fifth, `export-iceberg` → `skade`, is built by
//! nothing and is excused by name). `cargo build --all-targets` SILENTLY SKIPS
//! them, so a plain build produces one binary and reports success.
//! [`validate_against`] refuses a request that would emit nothing, BY NAME.
//! 2. **`gui` transitively autostarts a database.** `gui = [… , "autostart"]` and
//! `autostart = ["graphar-falkordb/autostart"]`, so the popviz GUI STARTS A
//! FALKORDB at runtime. That is no longer a hand-kept list: [`consequence_of`]
//! derives it from the declared feature closure, and
//! `tests/typed_consequence.rs` holds both the closure and
//! [`AUTOSTARTS_A_DATABASE`] to the crates' own `[features]` tables.
//!
//! # The typed consequence
//!
//! Every row here is `kind = exe | wasm`, i.e. CARGO, so the typed consequence of
//! a toggle is the **dep/feature CLOSURE** delta
//! (`nornir_build_thing::consequence::feature_consequence` over
//! [`FeatureTable`]), never a Kconfig delta. The tables in [`feature_table`] are
//! the declaration; the manifests are what they are held to.
//!
//! Implementations must be **pure** — no filesystem, no network, no clock (the
//! wasm sandbox grants none, so an impl that reaches for one compiles native and
//! traps as a plugin). Everything here is a const table and a match.
use std::collections::BTreeMap;
use nornir_build_thing::cargo_container::CargoToolchain;
use nornir_build_thing::consequence::{feature_consequence, Consequence};
use nornir_build_thing::contract::Coordinate;
use nornir_build_thing::plugin::{
ArtifactFacts, BuildRequest, BuildThing, Manifest, Role, Verdict, ABI_VERSION,
};
use nornir_build_thing::variant::{
AllowEntry, ArtifactKind, BuildVariant, BuildVariantSet, FeatureTable, Profile, ServiceShape,
};
/// The crate a receipt names as its recipe.
pub const KRATE: &str = "knut-build-thing";
/// The content hash over this crate's sources, computed by `build.rs`.
pub const CONTENT_HASH: &str = env!("KNUT_BUILD_THING_CONTENT_HASH");
/// **The delivery this repo ships in.** `suites` is a LIST — one row may ship in
/// more than one flavour — but knut 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 SIX ROWS HERE WERE
/// EXACTLY THAT until 2026-09-20 (knut was six of the 27 untagged rows the
/// 2026-09-17 census counted). 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 and holger needs no flag the others reject.
pub const SUITE: &str = "ORIGINAL";
/// The target the transform guest is built for. It is also the target this
/// recipe's own cdylib is built for by the forge, which is why the toolchain pin
/// below must carry it.
pub const WASM_TARGET: &str = "wasm32-unknown-unknown";
/// Where the wasm guest's manifest lives, relative to the repo root.
///
/// The guest is `exclude`d from the root workspace on purpose (it only ever
/// builds for wasm32 and must not be pulled into the native build), so
/// `-p knut-bifrost-wasm-guest` from the repo root CANNOT REACH IT whatever
/// features it carries. `manifest_dir` is rendered as `--manifest-path` by
/// `nornir-build-thing` ≥ 0.1.9 — the field's own documentation names knut's
/// excluded guest as one of the two reasons it exists — and until today knut's
/// declaration did not use it, so row 6 named a build cargo could not run.
pub const WASM_GUEST_MANIFEST_DIR: &str = "crates/knut-bifrost/wasm-guest";
/// knut's build thing.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct KnutBuild;
impl KnutBuild {
/// 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()
}
/// `(binary, the feature without which cargo produces nothing)` — the whole
/// `--all-targets` trap as data, for the four bins a row builds.
///
/// `export-iceberg` (→ `skade`) is knut's fifth `required-features` bin and is
/// deliberately NOT here: no row builds it, so it is excused by name in
/// [`allow_unbuilt_bins`] instead. This table is the one `validate_against`
/// enforces on declared rows, and an entry with no row would make it enforce a
/// build that does not exist.
pub const REQUIRED_FEATURES: &[(&str, &str)] = &[
("bifrost", "cli"),
("knut-popviz", "gui"),
("knut-spark-server", "server"),
("graphar-flight", "bin"),
];
/// Features that pull a **running FalkorDB** in behind them.
///
/// `autostart` is the direct one; `gui` and `spark41` acquire it transitively,
/// which is precisely why they are listed — the transitive case is the one a
/// reader misses. NOT a hand-kept copy any more: `tests/typed_consequence.rs`
/// derives the same set from the declared closure and fails if the two disagree.
pub const AUTOSTARTS_A_DATABASE: &[&str] = &["autostart", "gui", "spark41"];
/// The token a `graphar-falkordb/autostart` closure ends at. Opaque to
/// [`FeatureTable::closure`] (a `crate/feature` token never expands), which is
/// exactly what makes it a stable name for "this build starts a database".
pub const FALKORDB_AUTOSTART_TOKEN: &str = "graphar-falkordb/autostart";
// ── the rows ─────────────────────────────────────────────────────────────────
/// One cargo executable row.
fn exe(
name: &str,
role: Role,
is_default: bool,
package: &str,
bin: &str,
features: &[&str],
) -> BuildVariant {
BuildVariant {
name: s(name),
suites: vec![s(SUITE)],
role: Some(role),
is_default,
package: Some(s(package)),
bin: Some(s(bin)),
features: features.iter().map(|f| s(f)).collect(),
kind: ArtifactKind::Exe,
// FAT, and said out loud on every row: these are ordinary statically
// linked Rust binaries that open their own sockets and their own graph
// client. It is NOT a measured claim about embedded siblings the way
// dwarves' `embeds` is — no symbol count was ever taken here — which is
// why `embeds` and `reaches` stay empty rather than being guessed at.
shape: ServiceShape::FatService,
profile: Profile::Release,
..BuildVariant::default()
}
}
/// `bifrost-falkordb-<vendor>`: the FalkorDB service with one catalog connector.
fn bifrost_connector(vendor: &str) -> BuildVariant {
// gRPC is skade's own wire, not a vendor preset: its feature is `skade-grpc`.
let feature = if vendor == "grpc" { "skade-grpc".to_string() } else { format!("catalog-{vendor}") };
exe(
&format!("bifrost-falkordb-{vendor}"),
Role::Server,
false,
"knut-bifrost",
"bifrost",
&["cli", "falkordb-service", &feature],
)
}
/// The fourteen rows, in declaration order.
pub fn variants() -> Vec<BuildVariant> {
vec![
// ── the pipeline CLI — the only binary a plain `cargo build` produces ──
exe(
"knutpipe",
Role::Tool,
true,
"knut-pipelines",
"knutpipe",
&[],
),
// ── the Spark Connect + SDP server ──
// `server = ["draupnir/backend-oci", "dep:draupnir", "dep:tokio",
// "dep:zbus"]` — the container-manager path. Off by default in the crate
// so a plain build never pulls it.
exe(
"knut-spark-server",
Role::Server,
false,
"knut-pipelines",
"knut-spark-server",
&["server"],
),
// ── graphar-flight — Flight SQL over a FalkorDB graph ──
// `bin = ["dep:skidbladnir", "dep:nornir-service", "dep:anyhow"]` — the
// CLI and the skidbladnir lifecycle door, on by default in the crate so a
// bare `cargo build -p graphar-flight` produces it. Until 2026-09-10 this
// crate shipped no binary at all and the only way to serve Flight SQL over
// a graph was to embed the library (korp does).
exe(
"graphar-flight",
Role::Server,
true,
"graphar-flight",
"graphar-flight",
&["bin"],
),
// ── bifrost — the transform CLI ──
// `cli = ["dep:clap", "dep:knut-tracing", "airflow"]`, so `cli`
// transitively pulls `airflow` (→ reqwest). Derived, not assumed:
// `consequence_of("bifrost", "cli", true)` names it.
exe("bifrost", Role::Tool, false, "knut-bifrost", "bifrost", &["cli"]),
// ── bifrost-doris — Iceberg commits into Apache Doris, as a service ──
// `service = ["cli", "iceberg-doris", "dep:skidbladnir", "dep:anyhow"]`:
// the same `bifrost` binary with `bifrost doris …`, and the ONE build
// behind BOTH packages — `bifrost doris install` renders the systemd unit
// through skidbladnir's Lifecycle, `bifrost doris k8s` the StatefulSet +
// Secret through skidbladnir's KubernetesRenderer, from one ServiceSpec.
// A distinct build (skade + aegir + the REST client + skidbladnir), so a
// distinct row; it is also what now builds `doris` and `skade`.
exe("bifrost-doris", Role::Server, false, "knut-bifrost", "bifrost", &["cli", "service"]),
// ── bifrost-falkordb — the Iceberg lake → FalkorDB stream, as a service ──
// `falkordb-service = ["cli", "skade-rest", "dep:skidbladnir",
// "dep:anyhow"]`: `bifrost watch` plus `bifrost falkordb install|k8s|…`
// and NO Doris sink. Reaches a lake on plain S3, a skade warehouse or a
// generic Iceberg REST catalog (Nessie, Lakekeeper, …).
exe(
"bifrost-falkordb",
Role::Server,
false,
"knut-bifrost",
"bifrost",
&["cli", "falkordb-service"],
),
// ── one connector per build ──
// The same service with exactly ONE vendor catalog compiled in, so a
// site running Polaris ships a bifrost that knows Polaris and nothing
// else. Each is a distinct closure (`skade/catalog-<vendor>`), so each is
// a distinct build — not a row per feature-combination for its own sake.
bifrost_connector("aistor"),
bifrost_connector("polaris"),
bifrost_connector("unity"),
bifrost_connector("gravitino"),
// `skade-grpc` adds a real dependency (skade-katalog-grpc: tonic +
// rustls), not a preset — the one connector row that grows the binary.
bifrost_connector("grpc"),
// ── bifrost-full — every sink, every catalog ──
// `service` (Doris + FalkorDB verbs) with `catalogs` (the four REST
// presets) and `skade-grpc`. The build for a site that has not chosen,
// and the one row whose test suite compiles every connector.
exe(
"bifrost-full",
Role::Server,
false,
"knut-bifrost",
"bifrost",
&["cli", "service", "catalogs", "skade-grpc"],
),
// ── the population viz ──
// `gui = ["dep:eframe", "dep:egui", "dep:facett-pop", "autostart"]` — so
// this row STARTS A FALKORDB at runtime. A real runtime dependency of the
// artifact, not just a link, and `validate` says so on every request.
exe(
"knut-popviz",
Role::Client,
true,
"knut-popviz",
"knut-popviz",
&["gui"],
),
// ── the WASM transform guest ──
// Deliberately EXCLUDED from the workspace because it only ever builds for
// wasm32 and must not be pulled into the native build/test. It is a real
// shipped artifact, so it gets a row — a build that exists but is declared
// nowhere is what this declaration is for — and `manifest_dir` is what
// makes the row REACHABLE (see WASM_GUEST_MANIFEST_DIR).
BuildVariant {
name: s("knut-bifrost-wasm-guest"),
suites: vec![s(SUITE)],
role: Some(Role::Tool),
is_default: false,
package: Some(s("knut-bifrost-wasm-guest")),
manifest_dir: Some(s(WASM_GUEST_MANIFEST_DIR)),
kind: ArtifactKind::Wasm,
target: Some(s(WASM_TARGET)),
shape: ServiceShape::FatService,
profile: Profile::Release,
..BuildVariant::default()
},
]
}
// ── the feature space ────────────────────────────────────────────────────────
/// **Every feature knut offers, across every workspace member.**
///
/// Declared so a variant naming a feature this repo does not have fails loudly
/// rather than reading green — and so `FeatureAxis`'s manifest gate has something
/// to compare the crates against. The hand-written file listed 17 and grouped
/// them by crate in a comment; that grouping was a fiction (one flat namespace:
/// `gui` is knut-popviz's AND knut-pipelines', `airflow` is knut-bifrost's AND
/// knut-pipelines', `skade` is knut-bifrost's, graphar-flight's and xtask's) and
/// it was ELEVEN SHORT: `axum`, `cozo`, `doris`, `dsl`, `falkordb`, `flight`,
/// `metrics-http`, `native`, `orc`, `serde`, `sql` were real, shipped features
/// that the declaration did not know existed. A feature nothing names is a
/// feature nothing ever compiles and nothing notices — korp's `spark41` rotted
/// exactly that way. `tests/offers_are_the_manifests.rs` holds this list equal to
/// the `[features]` tables of every member.
fn offers() -> Vec<&'static str> {
vec![
"airflow",
"autostart",
"axum",
"bin",
"catalog-aistor",
"catalog-gravitino",
"skade-grpc",
"catalog-polaris",
"catalog-unity",
"catalogs",
"cli",
"cozo",
"doris",
"dsl",
"facett-adapter",
"falkordb",
"falkordb-service",
"flight",
"grpc",
"gui",
"http",
"iceberg",
"iceberg-doris",
"iceberg-rest",
"live",
"metrics-http",
"native",
"orc",
"rel2graph",
"serde",
"server",
"service",
"skade",
"skade-rest",
"skade-s3",
"spark",
"spark41",
"sql",
"testkit",
"testmatrix",
"wasm",
"web",
]
}
/// **Every offered feature no row builds, each with a written reason.**
///
/// An unbuilt feature with no entry is a GAP; an unbuilt feature with an entry is
/// a decision or a gap somebody has looked at. The hand-written file carried one
/// of these — `spark41` — in prose at the bottom, where no gate could read it.
/// These are data, so the gate asks about them every run.
///
/// "Built" here means built BY THE CLOSURE, not merely named: `airflow` and
/// `autostart` are absent from this table because `cli` and `gui` drag them in.
pub fn allow_unbuilt() -> BTreeMap<String, String> {
[
("axum", "knut-wasm-host's HTTP door, reached only through knut-pipelines' `web`. A GAP with `web`: nothing declares a build point for the browser-facing pipeline server, so the axum surface compiles in no declared artifact."),
("cozo", "knut-graphdb's embedded CozoDB backend — the alternative to `falkordb` behind the same trait. A backend is a CONSUMER's choice of wire and knut ships no artifact per backend to choose between; the library is what is delivered."),
("dsl", "knut-thund's query DSL — a parsing surface over the same engine, selected by an embedder. No knut binary exposes Thund's DSL, so a build point here would ship nothing."),
("facett-adapter", "knut-pipelines' facett component adapter. Pulled by `web`, which nothing builds; on its own it is a rendering seam for an embedder rather than an artifact."),
("falkordb", "the FalkorDB backend of knut-graphdb and knut-thund — same reason as `cozo`: a backend a consumer picks. NOT the same thing as `autostart`, which STARTS one; this only compiles the client."),
("flight", "knut-bifrost's Arrow Flight client (arrow-flight + tonic + futures). The SERVER side is declared and built — the `graphar-flight` row — and this is the client half that lives in bifrost for embedders; no bifrost binary turns it on."),
("grpc", "knut-pipelines' tonic SDP control plane. It is a COMPONENT of `server`, which IS built, but `server` reaches the gRPC surface through its own dependency edge rather than by enabling this feature, so the name itself is unbuilt. A GAP if the plain `knutpipe` is ever meant to serve gRPC."),
("http", "graphar-flight's tiny_http Arrow-IPC door beside Flight SQL. The `graphar-flight` row builds `bin` alone; an HTTP-serving graphar-flight would be a distinct build and needs its own row when somebody ships one."),
("iceberg", "knut-popviz's Iceberg pane (`dep:skade` + `dep:arrow-select`). A real, shipped capability of the viz that NO declared row enables: the `knut-popviz` row builds `gui` alone. A GENUINE GAP — if the shipped viz has the Iceberg pane, the row is wrong; if it does not, the feature is dead weight. Owner's call."),
("iceberg-rest", "knut-popviz's Iceberg pane behind a REST catalog (`iceberg` + `skade/rest-catalog`). Unbuilt for the same reason as `iceberg`: the shipped viz row builds `gui` alone."),
("live", "knut-pipelines' `gui` + `grpc` — the live pipeline editor. A distinct artifact would need a distinct row and a distinct bin; knut-pipelines declares neither, so the editor exists as a feature and not as a build point."),
("metrics-http", "knut-bifrost's tiny_http metrics endpoint. A deployment choice an embedder makes; no shipped knut binary opens a metrics port, and one that did would be a new row."),
("native", "knut-thund's DataFusion engine (the incremental-vs-recompute path the `knut.thund_watermark_drop` bench arm drives). A library engine, selected by whoever embeds Thund; xtask's dev-dep turns it on for the bench and nothing ships it."),
("orc", "graphar's ORC chunk codec, beside the default Parquet one. A storage-format choice a consumer makes when it opens a graph, not a separate artifact."),
("rel2graph", "knut-thund's bridge to the bifrost rel-to-graph transforms (`dep:knut-bifrost` + `dsl`) — the one feature that joins the two halves of what knut IS. It was invisible to the hand-written declaration for the same reason `spark41` nearly was: a grep for offers that forgets a feature name may carry a DIGIT. Nothing builds it; it is an embedder's road from Thund into the transforms."),
("serde", "knut-popsim's serialization of its population model. An embedder's convenience; the popsim is linked, never shipped alone."),
("spark", "knut-bifrost's Spark Connect client (and knut-thund's same-named arm). Reached through the vendored spark-connect fork; no declared binary enables it, and `spark41` below is the combination that would."),
("spark41", "`spark41 = [\"spark\", \"graphar-falkordb/autostart\"]` — a feature with NO binary of its own: it MODIFIES `bifrost`. It gets no row because a variant per feature-combination is a row that is not a distinct build. If a `bifrost` built WITH spark41 is a distinct shipped artifact it needs its own row AND a measured difference — add it then, not now. (It is also one of the three features that start a FalkorDB; see AUTOSTARTS_A_DATABASE.)"),
("sql", "knut-thund's SQL surface over the same engine — same reason as `dsl` — and knut-bifrost's DataFusion SQL transform, an embedder's choice no bifrost row turns on."),
("testkit", "knut-pipelines' in-process test helpers. A TEST mode, never a delivery: a shipped pipeline binary carrying its own fixtures would be shipping the harness."),
("testmatrix", "the nornir-testmatrix functional-status emitter, declared by eight members. A TEST-HARNESS mode turned on by the test command; a shipped binary appending matrix rows would be reporting on a run nobody is measuring."),
("wasm", "knut-bifrost's wasmi HOST for the transform guest. The GUEST is declared and built (`knut-bifrost-wasm-guest`); the host side is an embedder's choice and no bifrost binary turns it on — which is also why `tests/wasm_guest.rs` is `#[ignore]`d."),
("web", "knut-pipelines' browser-facing server (axum + knut-wasm-host + facett-adapter). A GAP: it is a whole deployment shape with no row, and it is the reason `axum` and `facett-adapter` are unbuilt too."),
]
.into_iter()
.map(|(k, v)| (s(k), s(v)))
.collect()
}
/// **Cargo `[[bin]] required-features`, mirrored** so a bad combination fails
/// fast and by name instead of as a silent, empty, exit-0 cargo build.
///
/// FIVE entries, not four: `export-iceberg` is graphar-flight's second binary and
/// the hand-written file did not know it existed.
fn required() -> BTreeMap<String, Vec<String>> {
[
("bifrost", vec![s("cli")]),
("knut-popviz", vec![s("gui")]),
("knut-spark-server", vec![s("server")]),
("graphar-flight", vec![s("bin")]),
("export-iceberg", vec![s("skade")]),
]
.into_iter()
.map(|(k, v)| (s(k), v))
.collect()
}
/// **The declared binary no row builds**, with its reason — the bin half of
/// [`allow_unbuilt`], read by `nornir_build_thing::buildgate`'s bin tally.
fn allow_unbuilt_bins() -> BTreeMap<String, AllowEntry> {
[
// 2026-10-16 build-thing sweep (loki): the gate counted these and no row or reason covered them.
("knut-build-thing", "this recipe crate's own emit door (`cargo run -p knut-build-thing -- emit` regenerates .nornir/build-variants.toml); the forge loads the crate as wasm, the bin is repository tooling, never shipped."),
("xtask", "the `cargo run -p xtask -- <task>` workspace task runner — repository tooling, never shipped."),
("rel2graph-timing", "knut-rel2graph-bench's seeded timing loop (plan construction / fabric at 1x/10x/100x) — a bench, never shipped."),
("rel2graph-export", "knut-rel2graph-bench's export of the rel2graph fabric as node/edge lists + oracle for demo/rel2graph_networkx_demo.py — a demo/bench feeder, never shipped."),
("knut-flight-server", "knut-flight's Arrow Flight server over rel2graph output (N4). The service unit that would ship it (N5) is not declared, so no row builds it yet — a gap in the roadmap, written here rather than hidden."),
("knut-popload", "knut-popviz's headless population load into FalkorDB (what a Simulate press runs on the FalkorDB host); run in place by the operator, no row ships it yet."),
("graphar-graph-http", "graphar-flight's named read queries as Arrow IPC over HTTP for a wasm viewer (`required-features = [\"http\"]`); no deployment declares it yet, so no row builds it."),
(
"export-iceberg",
"graphar-flight's Iceberg exporter (`required-features = [\"skade\"]`). No row builds \
it because no row enables `skade` — the same gap `skade` is excused under. It is \
named here so the bin tally counts five declared binaries and accounts for all of \
them, instead of four and a silence.",
)]
.into_iter()
.map(|(k, v)| (s(k), AllowEntry::Reason(s(v))))
.collect()
}
// ── the typed consequence: cargo ⇒ dep/feature closure ───────────────────────
/// **The feature graph of the packages the rows name**, as the declaration.
///
/// One table per package, because a cargo feature name is only meaningful inside
/// its own manifest: `gui` in knut-popviz and `gui` in knut-pipelines are two
/// different features that happen to share four letters. A `crate/feature` or
/// `dep:` token never expands — it is opaque to the closure and perfectly
/// comparable as-is, which is what makes [`FALKORDB_AUTOSTART_TOKEN`] a stable
/// name for "this build starts a database".
///
/// `None` for a package this recipe declares no table for. Held to the crates'
/// own `[features]` tables by `tests/typed_consequence.rs` — the anti-drift half,
/// and the reason this is a declaration rather than a comment.
#[must_use]
pub fn feature_table(package: &str) -> Option<FeatureTable> {
let t = |pairs: &[(&str, &[&str])], default: &[&str]| FeatureTable {
features: pairs
.iter()
.map(|(k, v)| (s(k), v.iter().map(|x| s(x)).collect()))
.collect(),
default: default.iter().map(|x| s(x)).collect(),
};
Some(match package {
"knut-bifrost" => t(
&[
("testmatrix", &["dep:nornir-testmatrix", "nornir-testmatrix/testmatrix"]),
("spark", &["dep:spark-connect"]),
("airflow", &["dep:reqwest", "dep:rustls", "dep:webpki-root-certs"]),
("cli", &["dep:clap", "dep:knut-tracing", "airflow"]),
("spark41", &["spark", FALKORDB_AUTOSTART_TOKEN]),
("skade", &["dep:skade"]),
("wasm", &["dep:wasmi", "dep:arrow-ipc"]),
("flight", &["dep:arrow-flight", "dep:tonic", "dep:futures"]),
("metrics-http", &["dep:tiny_http"]),
("sql", &["dep:datafusion", "dep:futures"]),
(
"doris",
&[
"dep:reqwest",
"dep:rustls",
"dep:webpki-root-certs",
"dep:bytes",
"dep:znippy-zoomies",
"dep:anyhow",
],
),
("skade-s3", &["skade", "skade/aegir"]),
("skade-rest", &["skade-s3", "skade/rest-catalog"]),
("catalog-aistor", &["skade-rest", "skade/catalog-aistor"]),
("catalog-polaris", &["skade-rest", "skade/catalog-polaris"]),
("catalog-unity", &["skade-rest", "skade/catalog-unity"]),
("catalog-gravitino", &["skade-rest", "skade/catalog-gravitino"]),
(
"catalogs",
&["catalog-aistor", "catalog-polaris", "catalog-unity", "catalog-gravitino"],
),
("skade-grpc", &["skade-s3", "skade/grpc-catalog"]),
("iceberg-doris", &["doris", "skade-rest", "skade-grpc"]),
("service", &["falkordb-service", "iceberg-doris"]),
("falkordb-service", &["cli", "skade-rest", "dep:skidbladnir", "dep:anyhow"]),
],
&[],
),
"knut-popviz" => t(
&[
("gui", &["dep:eframe", "dep:egui", "dep:facett-pop", "autostart"]),
("autostart", &[FALKORDB_AUTOSTART_TOKEN]),
("iceberg", &["dep:skade", "dep:arrow-select"]),
("iceberg-rest", &["iceberg", "skade/rest-catalog"]),
("testmatrix", &["dep:nornir-testmatrix", "nornir-testmatrix/testmatrix"]),
],
&[],
),
"knut-pipelines" => t(
&[
("testkit", &[]),
("facett-adapter", &["dep:facett-pipeline"]),
(
"grpc",
&[
"dep:tonic",
"dep:tonic-prost",
"dep:prost",
"dep:prost-types",
"dep:tonic-prost-build",
"dep:protoc-bin-vendored",
"dep:futures-core",
"dep:futures-util",
"dep:tokio",
],
),
("gui", &["dep:eframe", "dep:egui", "dep:egui-snarl", "dep:image"]),
("live", &["gui", "grpc"]),
("airflow", &["dep:knut-bifrost"]),
(
"web",
&[
"facett-adapter",
"dep:axum",
"dep:tokio",
"dep:knut-wasm-host",
"knut-wasm-host/axum",
],
),
("testmatrix", &["dep:nornir-testmatrix", "nornir-testmatrix/testmatrix"]),
("server", &["draupnir/backend-oci", "dep:draupnir", "dep:tokio", "dep:zbus"]),
],
&[],
),
"graphar-flight" => t(
&[
("bin", &["dep:skidbladnir", "dep:nornir-service", "dep:anyhow"]),
("http", &["dep:tiny_http", "dep:arrow-ipc"]),
("skade", &["dep:skade", "dep:tempfile"]),
("testmatrix", &["dep:nornir-testmatrix", "nornir-testmatrix/testmatrix"]),
],
// The ONE package here whose `default` is not empty — `bin` is on, so
// a bare `cargo build -p graphar-flight` produces the binary.
&["bin"],
),
"graphar-falkordb" => t(
&[
("autostart", &["dep:testcontainers", "tokio/time"]),
("testmatrix", &["dep:nornir-testmatrix", "nornir-testmatrix/testmatrix"]),
],
&[],
),
_ => return None,
})
}
/// **What toggling `feature` on `variant` costs or saves** — the cargo typ's
/// consequence: the dep/feature CLOSURE delta, computed by
/// `nornir_build_thing::consequence::feature_consequence` over the row's own
/// package table. There is no second closure anywhere in knut.
///
/// `None` when the variant is undeclared, or when it names a package this recipe
/// declares no feature table for (the wasm guest has no features at all).
#[must_use]
pub fn consequence_of(variant: &str, feature: &str, on: bool) -> Option<Consequence> {
let set = KnutBuild.variants();
let v = set.get(variant)?;
let ft = feature_table(v.package.as_deref()?)?;
Some(feature_consequence(
&ft,
&v.features,
feature,
on,
!v.no_default_features,
))
}
/// **Does building `variant` start a database?** — derived from the closure, not
/// from a list somebody kept up to date.
#[must_use]
pub fn starts_a_database(v: &BuildVariant) -> bool {
let Some(ft) = v.package.as_deref().and_then(feature_table) else {
return false;
};
ft.closure(&v.features, !v.no_default_features)
.contains(FALKORDB_AUTOSTART_TOKEN)
}
// ── the pinned environment ───────────────────────────────────────────────────
/// **The pinned build image every cargo variant compiles in** — `[build.toolchain]`.
///
/// A BUILD THING BUILDS IN A CONTAINER OR A KVM, NEVER ON THE HOST: the host
/// contributes a container-engine socket and a workdir, nothing else. Its rustc,
/// its installed target set, its C compiler and its `~/.cargo/config.toml` linker
/// choice must not reach the build, because then the BOX decides the verdict
/// instead of the DECLARATION.
///
/// knut declared NO toolchain at all until today, and the consequence was not
/// that knut built on the host — it is that `CargoToolchain::validate` refuses an
/// empty `image_sha256` BY NAME ("a named tag is a convenience, NOT a pin"), the
/// code road closes, and the forge falls back to reading the emitted toml as if
/// it were the source. The digest below is the MANIFEST digest of
/// `nordisk-cargo-toolchain:live` (64 lowercase hex, no `sha256:`), the one the
/// image really carries on both build boxes as of 2026-09-20 — the same move the
/// `pinmove` lane is making across eight repos on the same day. The image recipe
/// is reproducible (SOURCE_DATE_EPOCH through podman's `timestamp`), so a pin
/// names a recipe and not a minting event, and it moves only when the recipe
/// moves, in the same commit.
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, not what knut delivers. wasm32
// is not optional: it is the target the `knut-bifrost-wasm-guest` row
// names AND the target this recipe's own plugin module is built for, so
// a declaration that omitted it would refuse the code road at validation
// rather than an hour later inside cargo.
targets: vec![
s("x86_64-unknown-linux-gnu"),
s("x86_64-unknown-linux-musl"),
s(WASM_TARGET),
],
timeout_secs: 3600,
mem_limit_mb: 8192,
cpus: 8,
network: false,
remap_paths: true,
// EMPTY, and measured rather than assumed: knut has no path dependency
// that escapes the repository (`grep 'path = "../../..'` over every
// manifest: nothing). That is deliberate and documented in the root
// manifest — two sibling escapes were removed on 2026-09-10 because they
// made a bare `git clone knut && cargo build` fail to load the whole
// workspace. If one is ever added, this list moves in the SAME commit.
siblings: vec![],
})
}
impl BuildThing for KnutBuild {
fn manifest(&self) -> Manifest {
Manifest {
abi: ABI_VERSION,
repo: s("knut"),
source_crate: s(KRATE),
summary: "knut — the constellation's GRAPH LAYER and rel-to-graph util: GraphAr \
chunked storage, the FalkorDB loader, Arrow Flight SQL over a graph, the \
bifrost transform CLI, the pipeline runner and the population sim."
.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(),
required: required(),
variants: variants(),
toolchain: toolchain(),
..BuildVariantSet::default()
}
}
fn validate(&self, req: &BuildRequest) -> Verdict {
validate_against(&self.variants(), req)
}
fn judge_artifact(&self, facts: &ArtifactFacts) -> Verdict {
judge_against(facts)
}
}
/// knut's validation rule, over an INJECTED build space.
///
/// Free rather than a method so a test can run the real rule against a
/// deliberately-broken declaration. knut's actual declaration is correct — that
/// is what `required_table_matches_declaration` asserts — so the failing case has
/// to be constructed, and constructing it must not mean re-implementing the rule.
pub fn validate_against(set: &BuildVariantSet, req: &BuildRequest) -> Verdict {
// RULE 1 — the `--all-targets` trap, enforced instead of documented.
//
// `BuildRequest` carries no `bin` field, so there are two cases and they need
// different answers:
// * a DECLARED variant names its bin, so the check is exact;
// * an AD-HOC feature set names no bin, so the honest answer is not a
// refusal but a warning about what such a build will and will not emit.
let feats: &[String] = match &req.variant {
Some(name) => match set.get(name) {
None => {
return Verdict::refuse(format!(
"no variant '{name}'; declared: {}",
set.names().join(", ")
));
}
Some(v) => {
if let Some(bin) = v.bin.as_deref() {
if let Some((_, need)) = REQUIRED_FEATURES.iter().find(|(b, _)| *b == bin) {
if !v.features.iter().any(|f| f == need) {
return Verdict::refuse(format!(
"variant '{name}' builds bin '{bin}', which carries \
`required-features = [\"{need}\"]`, and \"{need}\" is not \
among its features — cargo would succeed and emit NO \
binary. Build it by name: \
cargo build -p <pkg> --features {need} --bin {bin}"
));
}
}
}
&v.features
}
},
None => &req.features,
};
// The ad-hoc case: name, up front, every binary this feature set will NOT
// produce. Silence here is what lets a build report success having emitted
// one binary of four.
if req.variant.is_none() {
let missing: Vec<String> = REQUIRED_FEATURES
.iter()
.filter(|(_, need)| !feats.iter().any(|f| f == need))
.map(|(bin, need)| format!("{bin} (needs \"{need}\")"))
.collect();
if !missing.is_empty() {
return Verdict::ok_with(format!(
"note: this feature set produces `knutpipe` only — it will NOT emit {}. \
`cargo build --all-targets` skips `required-features` bins silently",
missing.join(", ")
));
}
}
// Fall through to the generic declaration check (unknown features, bad target
// triples, and so on) — knut's rules are additional, not a replacement.
let probe = BuildVariant {
name: req
.variant
.clone()
.unwrap_or_else(|| "<ad-hoc>".to_string()),
features: feats.to_vec(),
target: req.target.clone(),
..Default::default()
};
match set.validate(&probe) {
Ok(()) => {
// Not a refusal — a build that starts a database is legitimate, it
// just must never be a surprise. DERIVED from the closure now: the
// feature that pulls `graphar-falkordb/autostart` is found by asking
// the declared feature graph, not by consulting a hand-kept list.
if let Some(f) = AUTOSTARTS_A_DATABASE
.iter()
.find(|f| feats.iter().any(|g| g == *f))
{
return Verdict::ok_with(format!(
"note: feature \"{f}\" pulls `{FALKORDB_AUTOSTART_TOKEN}`, so this \
artifact STARTS A FALKORDB at runtime — a runtime dependency, not \
just a link"
));
}
Verdict::ok()
}
Err(e) => Verdict::refuse(e.to_string()),
}
}
/// knut's artifact rule. Free for the same reason [`validate_against`] is.
pub fn judge_against(facts: &ArtifactFacts) -> Verdict {
// The wasm transform guest is deliberately excluded from the workspace and
// only ever builds for wasm32. It runs INSIDE the host's sandbox, so it must
// not carry a native graph client: if redis is linked into the guest, the
// exclusion has been undone and the guest is no longer sandboxable.
// `ArtifactFacts` carries no target triple — the sandbox deliberately hands
// over only what it resolved. The file name is what distinguishes a wasm
// artifact, and it is present.
if facts.file_name.ends_with(".wasm") && facts.linked_crates.iter().any(|c| c == "redis") {
return Verdict::refuse(
"a wasm32 knut artifact links `redis` — the transform guest must carry no \
native graph client; it runs inside the host's sandbox",
);
}
// `graphar-falkordb` and `graphar-flight` carry `redis` as a HARD,
// non-optional dependency, so every NATIVE knut artifact that touches the
// graph layer links it. Saying so is the point: it means there is no feature
// that makes a native knut build graph-client-free, and anyone planning an
// airgap tier needs to know that before designing one.
if facts.linked_crates.iter().any(|c| c == "redis") {
return Verdict::ok_with(
"links `redis` — expected: graphar-falkordb/graphar-flight declare it \
non-optionally, so no feature combination removes it from a native build",
);
}
Verdict::ok()
}