Gunnar

nordisk/edda

.nornir/guide.md

<!-- nornir:class: guide -->
# 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`:

```toml
[dependencies]
nornir-testmatrix = "0.2"
```

```rust
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

```toml
[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)

```rust
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 `BenchRun`s:

```rust
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

```rust
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:

```rust
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-`kind`s 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:

```rust
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:

```rust
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:

```rust
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:

```rust
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":

```rust
// 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.

```rust
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):

- **`nornir-xtask-support`** (std-only) — the generic primitives any Rust xtask
  wants as-is: `which`/`run`/`copy_dir`/`fresh_dir`/`clear_worktree`/`short_rev`,
  `target_dir` (honours `$CARGO_TARGET_DIR`), `workspace_root_of`, and the wasm
  bootstrap trio `ensure_wasm_target`/`ensure_wasm_bindgen`/`locked_wasm_bindgen_version`
  (installs a `wasm-bindgen` CLI matching `Cargo.lock` — the fix for the classic
  blank-canvas version mismatch). Everything fallible returns the boxed `Send+Sync`
  `Result` alias, so `anyhow`/`Box<dyn Error>`/`Result<_,String>` callers all adapt.
- **`nornir-pages`** — the parameterized flow on top of it.

```rust
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:

- **`bytes(n)`** → IEC binary `1.5 KiB` (the majority convention). `bytes_si(n)` →
  SI decimal `1.5 KB` for a caller that genuinely wants powers of ten.
- **`count(n)`** → compact SI `3.4M` (lowercase `k`, `M`/`B`/`T`). `thousands(n)`
  → grouped digits `1,234,567` (display only — bench numbers stay plain, L3).
- **`duration(d)`** / `duration_ms(ms)` / `duration_secs(s)` → `340ms` / `1.5s` /
  `2m 03s` / `1h 02m 03s`. This is the pure core; a conductor keeps its own `—`/`…`
  sentinels and calls the leaf for the real span (L5a).
- **`throughput(bytes_per_sec)`** → binary `3.5 MiB/s`; **`percent(fraction)`** /
  `percent_of(part, whole)` → `12.3%`.

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:

- **`xml(&str) -> Cow<str>`** → escapes `&`→`&amp;`, `<`→`&lt;`, `>`→`&gt;`,
  `"`→`&quot;`. When the input contains none of the four it is returned
  **borrowed** (`Cow::Borrowed`) — no allocation on the clean-label common path,
  where the old `.replace()` chain always allocated four times.
- **`xml_attr(&str) -> Cow<str>`** → the **five-entity** sibling that ALSO escapes
  `'`→`&apos;`, for a *single-quoted* attribute value where a bare apostrophe is
  not legal (`<t label='Rickard&apos;s repo'>`). On apostrophe-free input it is
  byte-identical to `xml` (a property test pins that), so reach for `xml_attr` only
  when the sink single-quotes; keep `xml` for element text and double-quoted attrs.

**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):

```rust
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 `String`s, 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:

```rust
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:

```rust
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`:

```rust
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:

```rust
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.