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):
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 trioensure_wasm_target/ensure_wasm_bindgen/locked_wasm_bindgen_version(installs awasm-bindgenCLI matchingCargo.lock— the fix for the classic blank-canvas version mismatch). Everything fallible returns the boxedSend+SyncResultalias, soanyhow/Box<dyn Error>/Result<_,String>callers all adapt.nornir-pages— the parameterized flow on top of it.
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 binary1.5 KiB(the majority convention).bytes_si(n)→ SI decimal1.5 KBfor a caller that genuinely wants powers of ten.count(n)→ compact SI3.4M(lowercasek,M/B/T).thousands(n)→ grouped digits1,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)→ binary3.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&→&,<→<,>→>,"→". 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'→', for a single-quoted attribute value where a bare apostrophe is not legal (<t label='Rickard's repo'>). On apostrophe-free input it is byte-identical toxml(a property test pins that), so reach forxml_attronly when the sink single-quotes; keepxmlfor 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:
flag(a non-empty--token) wins outright.- else the
env_varvalue, when non-empty (what a rendered systemd unit carries). - 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). - 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.