Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/knut

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()
}