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

nordisk/edda

.nornir/guide.md

edda — guide

How to depend on the edda crates. Each is an independent crates.io leaf; pull only the ones you need.

1. Run the test matrix in a leaf repo

nornir-testmatrix is the portable core — std + serde + anyhow, nothing else. Add it and drive your repo's native cargo test/nextest:

[dependencies]
nornir-testmatrix = "0.2"
use nornir_testmatrix::runner::MatrixRun;
// wrap `cargo nextest run` (or plain `cargo test`), parse pass/fail + duration,
// enforce the stall watchdog — no test registration, pure subprocess driving.

The discover/coverage modules are the pure engines behind the completeness gate (Gap = Surface − Covered − Allowlist).

2. Emit the "code that lacks tests" worklist

autonom builds and runs as a CLI:

cargo build -p autonom --features scan
./target/debug/autonom coverage <repo-root> --out needs-tests.md [--json]

It parses every .rs with syn, marks #[test]/#[cfg(test)] (and everything in a tests/ dir) as reachability ROOTS, builds a simple-name call graph, and lists functions no test reaches. Call resolution is by simple name, so it under-reports on name collisions rather than crying wolf — triage each entry as a worklist item, not a proof of deadness (trait/impl/builder call sites are missed).

3. Robot-test an egui app

[dependencies]
nornir-robotui = "0.2"                      # add `features = ["snapshot"]` for wgpu PNGs

Drive real interactions (click a label, type, press a key, step frames), then assert against the component's state_json — the same blob nornir's viz dumps to $NORNIR_VIZ_STATE. Scenarios load from TOML (Scenario::load_toml).

4. Run a tracked job (jera)

use jera::jobs::JobStore;
let store = JobStore::open(dir)?;           // one redb ledger: running → done|failed + timing
let id = store.submit(spec)?;

Container and KVM work is delegated to draupnir — enable container-oci for the draupnir OCI backend; the default build keeps the in-crate path as a fallback.

5. Bench a crate

nornir-bench gives a uniform envelope + append-only JSONL history. Register arms under <repo>/bench/examples/nornir-bench.rs and run nornir bench run <repo>; per-repo metrics go in the free-form metrics JSON object.

Gate a run against a baseline with the diff module — the same shape as nornir-coverage's coverage gate, but over two BenchRuns:

use nornir_bench::diff::BenchDiff;

let diff = new_run.diff(&baseline);            // == BenchDiff::between(&baseline, &new_run)
if diff.is_regression(5.0) {                   // any metric worse by >5%, OR any test red
    for d in diff.regressions(5.0) {
        eprintln!("{} {} {:.1} → {:.1} (-{:.1}%)",
            d.bench, d.metric, d.before, d.after, d.regression_pct());
    }
    println!("{}", diff.to_markdown(5.0));      // PR-comment table, ⚠ per regression
    std::process::exit(1);
}

between reads each metric's direction from direction_of/unit_of (throughput drop = regression, latency rise = regression), skips static capability rows and Neutral count/ratio metrics (never a false alarm), and surfaces a bench present on only one side in added_benches/removed_benches rather than as a regression. A test that flipped green→red gates on its own via test_regressions. Pure std + serde — feed two runs, assert the verdict; baseline STORAGE (JSONL / warehouse) stays with the consumer.

6. Git without shelling out

use nornir_git::gitio;
let (sha, branch) = gitio::head_sha_and_branch(repo)?;   // pure gix — no `git` binary, no libgit2

gitclean is the pure-Rust git filter-repo --invert-paths --path <p> analogue.

7. Cross-repo dep graph + snapshot persistence (nornir-depgraph)

Build the suite graph without a subprocess, ask it questions, and persist a snapshot as flat warehouse rows:

use nornir_depgraph::{WorkspaceGraph, DepGraphSnapshot, group_snapshots};

let g = WorkspaceGraph::build_from_members(&resolved)?;   // direct-manifest, no `cargo metadata`
let order = g.build_order()?;                              // deps first, consumers last
let blast = g.dependents_transitive("edda");              // who a change to edda invalidates

// persistence: one flat row per (snapshot, edge, via-crate) for a columnar sink…
let rows = snap.to_rows();
// …and fold a queried row stream back into whole snapshots (inverse of to_rows):
let snapshots: Vec<DepGraphSnapshot> = group_snapshots(rows);

group_snapshots groups by snapshot_id (first-seen order preserved), accumulates each edge's via crates, and keeps distinct dep-kinds separate — so group_snapshots(snap.to_rows()) round-trips a snapshot exactly. The crate defines the data types and the fold; the warehouse I/O stays with whoever owns the warehouse.

8. Gate coverage against a baseline (nornir-coverage)

nornir-coverage parses a cargo llvm-cov --json run into a pure CoverageReport. Its diff module turns two reports (a saved baseline + today's run) into a regression verdict — the CI gate:

use nornir_coverage::diff::CoverageDiff;

let diff = new.diff(&old);              // == CoverageDiff::between(&old, &new)
if diff.is_regression(0.5) {           // overall OR any crate dropped > 0.5 pts
    for d in diff.regressions(0.5) {
        eprintln!("{} {:.1} → {:.1} ({:+.1})",
            d.label, d.line_pct_before, d.line_pct_after, d.line_pct_delta());
    }
    println!("{}", diff.to_markdown(0.5));   // PR-comment table, ⚠ on regressions
    std::process::exit(1);
}

between matches crates by name (crates only in one side land in added_crates / removed_crates, never a false regression); worst() returns the single biggest line-coverage drop. Pure std + serde — feed two reports, assert the verdict; no cargo llvm-cov run needed to test the gate. Storing the baseline (warehouse or a checked-in JSON) stays with the consumer.

9. One CLI result shape, one exit code (nornir-cli-outcome)

CommandOutcome is the {command, ok, data, human} result every migrated command emits — the CLI twin of a Facet's state_json. Build it once, then let it drive both the render and the process exit so no command re-rolls that boilerplate:

use nornir_cli_outcome::CommandOutcome;

let outcome = match run() {
    Ok(rows) => CommandOutcome::ok("bench history", rows, "3 runs"),
    Err(e)   => CommandOutcome::fail("bench history", e.to_string()),
};
// print (pretty JSON when --json, else `human`) AND return the exit status:
std::process::exit(outcome.finish(json));

finish(json) prints via print(json) and hands back exit_code() — the honest user contract: 0 when ok, 1 when it ran but found a red/blocked state (an empty-but-ok "list" is still a good run, so it exits 0). For the strict autopilot gate, sannr_exit_code() returns 0 only when is_sannr() holds, so a falsely green outcome (ok=true but empty data, or an error atom leaked into human) exits non-zero (RAGNARÖK: empty is RED, never silently green).

10. Talk to Claude, one shared client (nornir-claude)

There is no official Anthropic Rust SDK, so nornir's viz Claude tab and dwarves' ClaudeTab each grew a bespoke reqwest client + credential logic. nornir-claude is the single primitive both consume: a ClaudeTransport seam with a deterministic offline stub (no network, no key) and a live HTTP transport, plus the full credential-resolution chain. It NEVER hardcodes a key.

Robot-test / demo with zero network and zero secrets:

use nornir_claude::{ClaudeTransport, OfflineTransport, WireMsg};

let t: Box<dyn ClaudeTransport> = Box::new(OfflineTransport::echoing());
let reply = t.send_wire("claude-opus-4-8", &[WireMsg::user("2+2?")]).unwrap();
assert_eq!(reply, "[offline · claude-opus-4-8] 2+2?");

send_wire(model, &[WireMsg]) -> Result<String, String> has the exact shape of the facett-claude pane seam, so a UI host (dwarves ClaudeTab, nornir viz) adopts this with a ~3-line newtype that maps its own WireMsg and forwards here:

struct EddaBackend(std::sync::Arc<dyn nornir_claude::ClaudeTransport>);
impl facett_claude::ClaudeTransport for EddaBackend {
    fn send(&self, model: &str, msgs: &[facett_claude::WireMsg]) -> Result<String, String> {
        let m: Vec<_> = msgs.iter()
            .map(|w| nornir_claude::WireMsg { role: w.role.clone(), content: w.content.clone() })
            .collect();
        self.0.send_wire(model, &m)
    }
}

The live path (--features http) resolves the credential first-hit-wins, mirroring the Anthropic SDKs / ant CLI — an unset ANTHROPIC_API_KEY does NOT mean "no credential":

// env ANTHROPIC_API_KEY (x-api-key) → env ANTHROPIC_AUTH_TOKEN (Bearer + oauth-beta)
// → per-session token (the pane's paste field, as x-api-key).
match nornir_claude::resolve_credential(session_token) {
    Some(cred) => { /* HttpTransport POSTs /v1/messages, anthropic-version 2023-06-01 */ }
    None => { /* render ClaudeError::NoCredential's "set the key" hint */ }
}

Endpoint POST https://api.anthropic.com/v1/messages, default model claude-opus-4-8 (never a date-suffixed variant). A live-network arm exists as an #[ignore]d test — ANTHROPIC_API_KEY=… cargo test -p nornir-claude --features http -- --ignored.

11. One content-hash primitive (nornir-hash)

Six repos each hand-rolled the identical let mut h = Sha256::new(); h.update(bytes); hex(h.finalize()) plus a private hex(&[u8]) -> String — nornir warehouse blob_store, holger retention + loose-file backends, Skidbladnir archive, skade bench aegir, nornir-guard, build-thing provenance. nornir-hash owns that primitive once. It is sha2-only (hex hand-rolled — no extra dep), no domain coupling: exactly what a third content-addressed store wants.

use nornir_hash::{sha256_hex, sha256_file, Sha256Hasher, hex_encode, hex_decode};

// The workhorse: lowercase-hex content id of a byte slice.
let id = sha256_hex(blob);                         // 64 lowercase hex chars

// Streaming a file on disk — 64 KiB chunks, never loads the whole file.
let id = sha256_file("artifact.tar")?;             // io::Result<String>

// Data that arrives in pieces (a read loop, a chunked body):
let mut h = Sha256Hasher::new();
h.update(b"ab"); h.update(b"c");
assert_eq!(h.finalize_hex(), sha256_hex(b"abc"));  // streaming == one-shot

// The dependency-free hex round-trip:
assert_eq!(hex_encode(&[0xde, 0xad]), "dead");
assert_eq!(hex_decode("dead")?, vec![0xde, 0xad]); // Err(HexError) on bad input

The known FIPS-180 vectors are RED-when-broken tests (sha256_hex(b"") = e3b0…b855, sha256_hex(b"abc") = ba78…15ad). To repoint a consumer: delete its private sha256_hex/hex, add nornir-hash = { version = "0.1", path = "../edda/crates/nornir-hash" }, and call nornir_hash::sha256_hex.

12. One gh-pages demo/deploy flow (nornir-xtask-support + nornir-pages)

Four repos' cargo xtask crates — nornir, holger, dwarves, facett — grew a near-byte-identical demo-wasm/deploy-pages pair (~1,800 LOC, ~80% shared): build a demo crate to release wasm, wasm-bindgen it into dist/, write a cache-busted static index.html + the pages host's helpers, then force-push dist/ to an orphan pages branch. Two cooperating leaves own that once (both publish = false — consumed only by the publish = false xtasks, never crates.io):

use nornir_pages::{PagesConfig, demo_wasm, deploy_pages};
use nornir_xtask_support::workspace_root_of;

fn cfg() -> PagesConfig {
    PagesConfig {
        demo_crate: "nornir-demo", canvas_id: "demo_canvas",
        state_export: "__nornir_state", title: "nornir — live web demo",
        description: "nornir: the release dependency-DAG in your browser.",
        pages_url: "https://pages.example/nornir",
        pages_host: "pages.example", pages_branch: "pages",
        remote_env: "NORNIR_PAGES_REMOTE", commit_label: "nornir live web demo",
        extra_build_args: &[],  // facett passes &["--no-default-features"]
        workspace_root: workspace_root_of(env!("CARGO_MANIFEST_DIR").as_ref()),
    }
}
// xtask dispatch: "demo-wasm" => { demo_wasm(&cfg())?; }  "deploy-pages" => deploy_pages(&cfg())?

The eleven PagesConfig knobs capture 100% of the per-repo divergence; the index.html template (canvas + spinner + the ?v=<short_rev> cache-bust that stops a redeploy serving stale wasm) is byte-pinned by a GOLDEN RED-when-broken test. deploy_pages deliberately shells git for the orphan-worktree force-push — nornir-git's in-process gix has no such primitive, so routing it there would regress. To repoint a consumer: delete its xtask/src/demo.rs, add nornir-pages = { version = "0.1", path = "../../edda/crates/nornir-pages" }, and call the two verbs with the repo's cfg().

13. One human-readable formatter (nornir-humanize)

Reports, table cells and UI labels across the constellation turn raw numbers into short human strings — a byte count into 1.5 KiB, a row count into 3.4M, an elapsed span into 2m 03s. Eight-plus tiny helpers grew independently and quietly drifted on units: nornir's and facett-pipeline's human_bytes print IEC binary with a space (KiB/MiB), facett-plandag prints single-letter no-space (1.5K), facett-file's human_size prints SI labels on a binary divisor (KB/MB — a latent mislabel); facett-pipeline's human_count uses lowercase k + billions B, facett-plandag's uses uppercase K and no billions; two duration helpers (facett-jobview::fmt_elapsed, facett-cicd::fmt_dur) share the same 2m 03s shape wrapped in different domain sentinels.

nornir-humanize owns the canon — std-only, zero runtime deps, pure functions:

The < 1024 (IEC) / < 1000 (SI) boundary is a RED-when-broken test: flip it and bytes(1024) stops being 1.0 KiB. Each ladder also picks its step from the value as it will be rendered, not the raw one — otherwise the top 0.05% of a step rounds into the next unit's number while keeping the current unit's label (1024.0 KiB, 1000.0k, 60.0s). So bytes(1048575) is 1.0 MiB, count(999_999) is 1.0M and duration_ms(59_999) is 1m 00s; only the top of a ladder stays put, because bytes(u64::MAX) → 16.0 EiB genuinely IS 16 EiB. To repoint a consumer: delete its private human_bytes/human_count/fmt_elapsed, add nornir-humanize = { version = "0.1", path = "../edda/crates/nornir-humanize" }, and call nornir_humanize::bytes / count / duration — keeping any domain sentinel wrapper in the caller.

14. One markup-escaping primitive (nornir-escape)

Every tool that emits SVG or XML has to escape the entities in a label before it drops the label into markup — otherwise a &, <, > or " in a repo name or a bench label corrupts the document. This one-liner escaper had been hand-rolled seven times, byte for byte identical, across four repos: xml_escape in nornir's viz/diagram, viz/coverage_metrics_svg, arch/mod, mimir (as svg_escape) and nornir-introspect; svg_escape in edda's own nornir-testmatrix coverage renderer (which even carries a comment apologising for the "local copy so this module stays PURE std"); and xml_escape in Skidbladnir's MSI/WXS builder. Every copy escapes the same four entities with the same .replace() chain — a textbook LAW #5 twin.

nornir-escape owns the canon — std-only, zero runtime deps, one function:

Apostrophe (') is deliberately left unescaped. Zero of the seven twins escape it (the SVG they emit uses double-quoted attributes, where a bare ' is legal), so escaping it would break byte-parity with every existing consumer and could trip the docs-render regression guard. The output is byte-for-byte identical to the twin's .replace('&',…).replace('<',…).replace('>',…).replace('"',…), which is what makes every repoint safe; a property test pins that equivalence over a mixed corpus, and neutralizing any one entity mapping turns three tests RED.

The domain-coupled escapers stay in their conductor (L5a): nornir's escape_typst / escape_typst_text (with its markdown→typst ////* comment-avoidance logic) / escape_typst_str are three divergent docs-pipeline functions, not twins; md_escape (| + backtick) and cell (|) are single-site markdown-table escapers; sanitize_asset is a filename slug, not an escaper. None has a second identical copy, so none belongs in the leaf.

To repoint a consumer: delete its private xml_escape/svg_escape, add nornir-escape = { version = "0.1", path = "../edda/crates/nornir-escape" }, and call nornir_escape::xml(s) — deref or .as_ref()/.into_owned() at the call site if it needs a &str/String (the old fns returned String).

15. One server-token resolution ladder (nornir-service::resolve_token)

Every nordisk gRPC server has to answer the same question at boot: what bearer secret guards my RPCs? And all three answered it the same way — try the --token flag, then the app's *_SERVER_TOKEN env var, then a home-derived <home>/.<app>/token file, else run open (empty token = no auth, the dev / single-workspace default the shared auth_interceptor already understands). That ladder had been hand-rolled three (really four) times — korp-server's resolve_token_with, dwarves-server's resolve_token + dwarves's flag-aware main.rs variant, and the inline --token/env/<home>/.nornir/token block in nornir-server. The copies differed in exactly two literals: the env var name and the token-file path.

nornir_service::resolve_token(flag: Option<&str>, env_var: &str, token_file: Option<&Path>) -> String makes those two the parameters and shares the rest:

  1. flag (a non-empty --token) wins outright.
  2. else the env_var value, when non-empty (what a rendered systemd unit carries).
  3. else token_file, read and trimmed — a missing/unreadable/blank file is simply skipped (open, not an error — bring-up must not require a token).
  4. else "" ⇒ open.

The rule at every rung is "an empty/blank value falls through to the next", so --token "", an empty env, or a whitespace-only file all mean unset, never "set to the empty token". The core is korp-server's testable resolve_token_with lifted verbatim, generalised on the env var.

What deliberately stays in each conductor is the policy layered on top of the raw secret: korp/dwarves treat "" as open (Phase-0), whereas nornir-server requires a token and rejects anything under 16 chars. This helper resolves the secret; the app decides what a secret (or its absence) is allowed to mean.

To repoint a server: replace the private fn with nornir_service::resolve_token( flag, "<APP>_SERVER_TOKEN", <home>/.<app>/token file) and hand the result to auth_interceptor. A table-driven test pins flag > env > file > open and the fall-through rule; neutralizing the file rung turns two tests RED (the interceptor would silently go open).

16. The shared app-shell models (nornir-appshell) + a workspace job sink (jera)

Every suite app carries a JetBrains-style common shell: an About card, a Tools menu, and a shared lifecycle. Lifecycle already comes from skidbladnir (unchanged); the About + Tools data was hand-rolled four+ times. nornir-appshell owns the two PURE, egui-free models so a GUI About window and a CLI <app> about feed ONE shape (the egui widget is a separate facett-about leaf, a later wave):

use nornir_appshell::{Identity, ToolMenu, ToolEntry};

// About — lift each app's const-blob / struct onto ONE Identity, then dump its oracle.
let id = Identity {
    subtitle: "…".into(),
    logo_png: Some(include_bytes!("../assets/korp_logo.png").to_vec()), // Option, LFS-tolerant
    logo_alt: "Korp logo — Hugin & Munin ravens".into(),
    framework: vec!["jera — the shared job manager".into()],
    ..Identity::new("korp", "Korp", env!("CARGO_PKG_VERSION"), "…tagline…")
};
let about = id.state_json(open); // keys: open/name/wordmark(=display_name)/version/…/has_logo/framework

// Tools — fill the generic menu from the app's own `entries()` over its ToolEffect enum.
let menu = ToolMenu::new("korp", vec![
    ToolEntry::new("🕸 FalkorDB — load demo graph", "…hover…", "falkor_demo_graph", "graph")
        .with_container("falkordb"),
]);
let tools = menu.state_json(last_picked_effect_key); // entries + last_picked

Identity/ToolMenu/ToolEntry are serde round-trippable (owned Strings, not &'static str), so a CLI-dumped oracle reads back and asserts out of process. logo_png is Option<Vec<u8>>: None = no logo; present-but-not-a-PNG bytes (holger's un-pulled git-lfs pointer) still count as has_logo — the renderer decodes best-effort. Each app KEEPS its domain: its enum ToolEffect + key() + entries() builder, its container/demo specs, its ServiceSpec.

The workspace job sink the Tools menu writes through was the other twin — korp's jobs.rs sink() == modgunn's tools.rs sink(), byte-identical. That collapses onto one jera helper:

use jera::jobs::{workspace_sink, workspace_ledger_path};

let sink = workspace_sink("korp"); // <WORKSPACE_UPPER>_JOBS env else <temp>/<workspace>_jobs.redb;
                                    // open the parent as the ledger root; JobSink::noop() on failure
let handle = jera::jobs::JobHandle::start(sink, kind, target, "korp", detail);

workspace_ledger_path("korp") resolves the path (env KORP_JOBS beats the temp default; a blank env value falls through, never "set to empty"). A RED-when-broken test writes a job through workspace_sink(ws) and reads it back from the env-resolved ledger; neutralizing the env resolution (helper ignores <WS>_JOBS) drops the job out of the asserted dir and fails.

17. Pluggable server auth — the AuthProvider seam + the provider leaves

A server no longer hand-rolls its auth. It holds a set of Box<dyn AuthProvider> (one per MODERN method its build pinned) behind the nornir-service seam (auth-provider feature) and dispatches a presented credential to them in order:

use nornir_service::{AuthProvider, verify_any, ProviderError};

// each provider is its own feature-gated edda leaf; a build links only what it pins
let providers: Vec<Box<dyn AuthProvider>> = vec![
    Box::new(nornir_auth_oidc::OidcProvider::from_cached_jwks(jwks)?),
    Box::new(nornir_auth_bearer::BearerProvider::new(secret)),
];
match verify_any(&providers, presented_token).await {
    Ok(Some(principal)) => { /* principal.subject / .scopes / .method → RBAC */ }
    Ok(None)            => { /* no provider claimed it → 401 */ }
    Err(ProviderError::Invalid) => { /* authoritative reject → 401 */ }
    Err(ProviderError::Unavailable) => { /* provider offline → 503 */ }
}

verify_bearer returns Ok(Some) (authenticated), Ok(None) (not mine — try the next), Err(Invalid) (recognized but rejected — authoritative, stop) or Err(Unavailable) (can't serve now — try the next). verify_any_cert is the mTLS twin over a transport-extracted CN. The trait is object-safe, so a future wasmtime-loaded provider slots in with no change to nornir-service.

Pick the leaves a tier needs — each method is a separate crate so you link only its dependency closure (an embedded/mini tier ships one bearer provider and nothing heavier):

add for
nornir-auth-oidc OIDC JWT (offline JWKS / online /userinfo) + device-grant login
nornir-auth-bearer a static machine-to-machine Bearer secret
nornir-auth-oauth2 RFC 7662 introspection + client-credentials / auth-code+PKCE (online feature)
nornir-auth-apikey hashed-at-rest API keys (rotating / multi-tenant)
nornir-auth-mtls a mutual-TLS client-cert CN → identity
nornir-auth-saml SAML 2.0 enterprise SSO, the pure-Rust SP (sp feature)
nornir-auth-webauthn WebAuthn / FIDO2 / passkey login, pure Rust (webauthn-pure default, ceremony)

SAML and passkeys have no C path at all: samael and webauthn-rs are gone (A34-14, A34-32), so no tier pulls xmlsec1, libxml2 or openssl for them.

Client-side login is the mirror: nornir-auth-client is the missing <app> login:

use nornir_auth_client::{login, current_token};

let token = match current_token()? {           // cached + silently refreshed
    Some(t) => t,
    None    => login().await?,                  // OIDC device-grant (RFC 8628)
};

It reuses nornir-auth-oidc's device helper and stores the token behind a TokenStore seam — a 0600 file store by default, pluggable to the dwarves encrypted Vault — so interactive SSO login works the same in korp, holger and dwarves.

18. Stamp an outbound bearer header (nornir-service::client::bearer)

The client half of the bearer contract. Instead of each caller hand-rolling format!("Bearer {token}").parse().insert(…), build the metadata value once:

use nornir_service::client::bearer;

if let Some(val) = bearer(&token)? {            // empty token ⇒ Ok(None) ⇒ send no header (open)
    req.metadata_mut().insert("authorization", val);
}

An empty token yields Ok(None) (open, no header — the same "empty ⇒ open" contract the server interceptor honours); a token that can't encode as an ASCII header value (e.g. an embedded newline) is a typed BearerError, never a silent bad header.