Gunnar

nordisk/edda

.nornir/design.md

<!-- nornir:class: design -->
# edda — design

**edda is the nordisk shared-lib samling: the reusable, non-facett library crates
lifted out of nornir** so they release on their own cadence and are shared by the
whole constellation (nornir, korp, holger, skade, znippy, …) without any consumer
dragging in nornir's warehouse / embedder / server tree.

## Charter

- **Library-only, leaf-shaped.** edda holds no application. facett stays its own
  repo (it is the egui component framework); edda is every *library* that is not
  facett. Each crate is a lean leaf: the smallest dep set that does the job, so a
  consumer pulls only what it needs (the dep-hell rule — nornir and korp must not
  pull each other's trees).
- **A crate MOVE is additive (polyrepo rule).** Extraction never breaks a caller:
  nornir re-exports each moved crate under its old path (`nornir::bench`,
  `nornir::cli_outcome`, `nornir::testmatrix`, …) so existing `crate::…` call
  sites resolve unchanged. Leaf repos consume the published crates.io versions.
- **No shell, no C.** The tracked paths are pure Rust: `nornir-git` drives gix (no
  `git` binary, no libgit2), `jera`'s container path delegates to draupnir's ONE
  bollard-over-REST OCI engine (no `std::process::Command`, no second engine).
  Zero-shell charter.
- **Additive, no duplication.** jera routes container + KVM work THROUGH draupnir
  (one OCI engine, one boot spine) rather than re-implementing it — the
  consolidation law.

## The dependency spine

edda sits mid-stack in the release topo order:

```text
tunnr (tunnr-vm) ─▶ draupnir ─▶ edda (autonom, jera, nornir-*) ─▶ … ─▶ nornir
```

`jera` hard-deps `draupnir` (path) for provisioning; `autonom` is pinned by nornir.
So edda publishes after draupnir and before nornir — the crate-topo sort orders it
automatically.

## The crates and their seams

| crate | seam it owns |
|-------|-------------|
| `autonom` | the completeness gate `Gap = Surface − Covered − Allowlist` (HARD zero) + the runnable `coverage` feeder (syn call-graph → the code-that-lacks-tests worklist). The *pure* engines live in `nornir-testmatrix::discover`; autonom is the constellation front-end |
| `nornir-testmatrix` | the pure matrix core: `runner` (drives nextest/cargo, stall watchdog), `discover`/`coverage` (surface enumeration + the gate math), `uiplane` (UI-plane model). `std`+`serde`+`anyhow` only |
| `nornir-robotui` | drive (egui_kittest/AccessKit) + observe (`state_json`) + replay (TOML scenarios). `snapshot` feature gates the wgpu PNG path so the core stays light |
| `nornir-saga` | record nav/click/log/error/state events → warehouse → replay as a generated robot test. Toolkit-neutral (egui OR Leptos+Axum). `saga_to_ui(events)` is the event-model → UI-movement bridge (the inverse of `Saga::to_saga_events` / nornir's `saga_from_ui`): folds a stored `SagaEvent` stream back into a replayable `Saga` — actionable `nav`/`click` events become movements, ordered by capture `seq`, `t_ms` clamped non-decreasing, so a round-trip preserves the movement sequence and the `Player` frame count lines up with the `ReplayPlan` steps. The ONE primitive both nornir's viz tab and the dwarves tape-recorder pane consume |
| `nornir-bench` | the run/result envelope + append-only JSONL history; free-form per-repo `metrics` JSON; single-thread + core-saturation aware; the **`diff` regression gate** — `BenchDiff::between(baseline, new)` folds two runs into per-`(bench, metric)` `MetricDelta`s (direction-aware via `direction_of`/`unit_of`, skips static rows + `Neutral` metrics) and answers `is_regression(tolerance)`, failing on a scalar drop OR any test flipped red |
| `nornir-coverage` | cargo-llvm-cov line/region coverage (the orthogonal "how many lines ran?" to autonom's "is every surface reached?") + the **`diff` regression gate**: `CoverageDiff::between(old, new)` folds two reports into overall + per-crate `CoverageDelta`s and answers `is_regression(tolerance)` — the pure CI gate a third consumer wants, baseline-storage left to the owner |
| `nornir-git` | gix inspection + the pure-Rust `git filter-repo` analogue (path removal) |
| `nornir-cli-outcome` | the `{command, ok, data, human}` CLI result shape — the CLI twin of `state_json` — plus the **process-exit bridge** (`exit_code`/`sannr_exit_code`/`finish`) so one outcome drives both the render and `std::process::exit` |
| `jera` | the durable job ledger (redb), the no-shell container runtime, and the KVM boot runner — both delegated to draupnir. Plus `workspace_sink(workspace)` / `workspace_ledger_path(workspace)` — the app-facing "give me a durable job sink for my app" helper that korp (`jobs.rs`) and modgunn (`tools.rs`) had each hand-rolled byte-identically (`<WORKSPACE_UPPER>_JOBS` env else `<temp>/<workspace>_jobs.redb`; open the parent as the ledger root; `JobSink::noop()` on failure), now derived once with the env-var + file literals as the `workspace` argument |
| `nornir-depgraph` | the cross-repo dependency graph: `cargo-metadata`/direct-manifest compute → cross-repo edges + petgraph topo build order, the egui-free dependency-VIEW layout (`state_json`), and the `DepGraphSnapshot ⇄ DepGraphRow` persistence fold (`to_rows`/`group_snapshots`) so a warehouse stores flat rows and reconstructs snapshots. No warehouse I/O, no egui. `testmatrix` self-emitter (`selftest::emit`) records the round-trip verdict |
| `nornir-service` | the ONE tonic gRPC scaffold three servers used to twin: `token_matches` (constant-time bearer compare — empty supplied token always fails), `auth_interceptor` (the `with_interceptor` bearer factory; empty configured token = open dev mode), `workspace_header` (metadata reader), `resolve_token(flag, env_var, token_file)` (the server-secret **resolution ladder** `--flag` > env var > `<home>/.<app>/token` file > open — empty/blank at any rung falls through, never "set to empty"; the three apps' copies differed only in the env-var + file literals, now the two parameters, so korp/dwarves/nornir-server collapse onto it; the ≥16-char / required-token *policy* stays in each conductor), and the shared `serve`/`serve_with_tls` loops (the latter behind the `tls` feature). App-agnostic — an app supplies its own tonic `Router`. Also `client::bearer(token) -> Result<Option<MetadataValue>, BearerError>` — the ONE outbound `authorization: Bearer <token>` stamp (empty token ⇒ `None` ⇒ open, a newline/invalid token is a typed error) the leaf embed crates + nornir's client paths consume instead of hand-rolling `format!("Bearer …").parse().insert(…)`. And the pluggable, object-safe **`AuthProvider` seam** (`auth-provider` feature): `Principal{subject,scopes,method}` + `verify_bearer`/`verify_client_cert`/`verify_saml_assertion`/`verify_webauthn_assertion` with `ProviderError::{Invalid (authoritative reject), Unavailable (try next)}`, dispatched in order by `verify_any`/`verify_any_cert`. Each MODERN method lives in its own feature-gated `nornir-auth-*` sub-crate so a build links only the providers it pinned; the trait is object-safe so a future wasmtime-loaded provider slots in unchanged |
| `nornir-flight` | the ONE generic Apache Arrow **Flight transport** seam: raw `do_put`/`do_get`/`do_exchange` over `RecordBatch` behind two schema-agnostic traits (`FlightSink`/`FlightSource`), `FlightServer` composing `nornir-service`'s bearer interceptor + serve loop (auth NOT re-rolled), `FlightConn` client (bearer + `connect_tls`), `MemFlightStore` reference sink. TLS behind the `tls` feature; the schema + byte-meaning stay with each consumer (build-info stream, graphar-flight, …) |
| `nornir-release` | the warehouse-free release **logic** (the L5a canonical split — logic here, `cargo metadata`/`cargo publish` orchestration in nornir, dwarves a CLI caller): the **source-drift** detector (`assess`/`can_skip_republish`/`diff_source_maps` — skip republishing an unchanged crate) and the pure **publish-wave** planner (`plan_waves`/`topo_phases` → deps-first parallel `phases`, `publish=false` dropped, `Dev` edges excluded, cycles surfaced). std-only, own `DepKind` so no `cargo_metadata` enters the leaf |
| `nornir-hash` | the ONE shared **content-hash** primitive six repos hand-rolled (nornir warehouse `blob_store`, holger retention + loose-file backends, Skidbladnir `archive`, skade bench `aegir`, `nornir-guard`, build-thing `provenance` each re-spelled `Sha256::new();update;hex(finalize())` + a private `hex(&[u8])->String`): `sha256`/`sha256_hex` (lowercase-hex digest of a slice), `sha256_file` (streaming, 64 KiB chunks — never loads the whole file), the incremental `Sha256Hasher` (`update`/`finalize`/`finalize_hex`), and the dependency-free `hex_encode`/`hex_decode` round-trip (`HexError`). Dep-light: `sha2` only, hex hand-rolled — a third content-addressed store wants it as-is. `testmatrix` self-emitter records the FIPS-180 vectors + hex round-trip verdicts |
| `nornir-xtask-support` | the ONE std-only **xtask primitives** toolbox four repos' `cargo xtask` crates hand-rolled byte-identically (`publish = false`): `which` (PATH + `~/.cargo/bin`), `run` (spawn → non-zero exit is an `Err`), `copy_dir`/`fresh_dir`/`clear_worktree`, `short_rev` (`git rev-parse --short HEAD`), `target_dir` (honours `$CARGO_TARGET_DIR`), `workspace_root_of` (parent of the xtask manifest dir), and the wasm bootstrap trio `ensure_wasm_target`/`ensure_wasm_bindgen`/`locked_wasm_bindgen_version` (install a CLI matching `Cargo.lock` — the blank-canvas-mismatch fix). Boxed `Send+Sync` `Result` alias so `anyhow`/`Box<dyn Error>`/`Result<_,String>` callers all adapt. std-only; `testmatrix` self-emitter records the pure-primitive verdicts (target_dir override, copy_dir round-trip, root climb) |
| `nornir-pages` | the ONE parameterized **gh-pages demo/deploy FLOW** (`publish = false`, on `nornir-xtask-support`): `demo_wasm` (cargo build release wasm → wasm-bindgen `--target web --no-typescript` → optional `wasm-opt -Oz` → cache-busted `index.html` + `.domains`/`.nojekyll` into `dist/`) and `deploy_pages` (orphan-worktree squash + `push --force <remote> pages:pages`). ALL divergence is `PagesConfig`'s 11 knobs (demo_crate, canvas_id, state_export, title, description, pages_url/host/branch, remote_env, commit_label, extra_build_args) + `workspace_root`. Deliberately **shells `git`** for the publish (nornir-git's in-process gix has no orphan-worktree/force-push-branch primitive — routing it there would regress). GOLDEN test pins the full `index.html` bytes incl. the `?v=<short_rev>` cache-bust; `testmatrix` self-emitter records the render verdicts |
| `nornir-humanize` | the ONE shared **human-readable formatting** primitive eight-plus twins across nornir + facett hand-rolled and drifted on units (`nornir bin/nornir.rs` + `facett-pipeline`/`facett-plandag`/`facett-file` `human_bytes`/`human_size`; `facett-pipeline`/`facett-plandag` `human_count`; `facett-jobview` `fmt_elapsed` + `facett-cicd` `fmt_dur`): `bytes` (IEC binary `1.5 KiB` — the majority canon) + `bytes_si` (SI decimal `1.5 KB` — the documented minority), `count` (compact SI `3.4M`, lowercase `k`), `thousands` (grouped digits `1,234,567`), `duration`/`duration_ms`/`duration_secs` (`340ms`/`1.5s`/`2m 03s`/`1h 02m 03s` — the pure core the `fmt_elapsed`/`fmt_dur` twins share; their `—`/`…`/`JobStatus` sentinels stay domain-side, L5a), `throughput` (binary `3.5 MiB/s`) and `percent`/`percent_of` (`12.3%`). std-only, ZERO runtime deps; `testmatrix` self-emitter records the exact-string + boundary (1023 vs 1024) verdicts |
| `nornir-escape` | the ONE shared **markup-escaping** primitive SEVEN byte-identical `xml_escape`/`svg_escape` copies hand-rolled across four repos (nornir `viz/diagram`, `viz/coverage_metrics_svg`, `arch/mod`, `mimir`, `nornir-introspect`; edda `nornir-testmatrix` coverage SVG — which even carries a "local copy so this module stays PURE std" note; Skidbladnir `msi` WXS): `xml(&str) -> Cow<str>` escapes the four entities every twin spells identically — `&`→`&amp;`, `<`→`&lt;`, `>`→`&gt;`, `"`→`&quot;` — and returns a **borrowed** `Cow` (no allocation) when the input is already clean. Apostrophe is deliberately NOT escaped (zero twins do; SVG here uses double-quoted attributes) so every repoint is byte-identical to the twin's `.replace()` chain. The **five-entity** sibling `xml_attr(&str) -> Cow<str>` ALSO escapes `'`→`&apos;` — for the single-quoted-attribute case where a bare apostrophe is not legal; on apostrophe-free input it is byte-identical to `xml` (a property test pins that, and the apostrophe row is the RED-when-broken gate). Domain-coupled escapers STAY in their conductor (L5a): nornir's `escape_typst`/`escape_typst_text`/`escape_typst_str` (docs markdown→typst, three-way divergent), `md_escape`/`cell` (markdown-table, single-site), `sanitize_asset` (asset-name slug). std-only, ZERO runtime deps; `testmatrix` self-emitter records the four entity vectors + clean-borrow + RED corrupted-mapping verdicts |
| `nornir-appshell` | the two PURE, **egui-FREE** app-shell MODELS every suite app's *About* card + *Tools* menu used to hand-roll (`publish = false` — the facett-about egui *widget* + the per-app repoints land in a later wave): the **`Identity`** card (`name`/`display_name`/`version`/`tagline`/`subtitle`/`logo_png: Option<Vec<u8>>` (LFS-tolerant — holger's embedded bytes are an un-pulled git-lfs pointer)/`logo_alt`/`repository`/`links`/`framework`/`engines`) + `state_json(open)` — the oracle korp/holger/modgunn About surfaces assert (keys `open`/`name`/`wordmark`=`display_name`/`version`/`tagline`/`subtitle`/`logo_alt`/`has_logo`/`framework`/`engines` — both `wordmark` (korp/holger) and `display_name` (modgunn) so every existing robot reads it unchanged), and the generic **`ToolMenu`**/**`ToolEntry`** (`title`/`hover`/`effect_key`/`view_target`/`needs_container` + `keys`/`find`/`state_json(last_picked)`) — the menu STRUCTURE korp's + modgunn's `tools.rs` both hand-rolled. Lifts modgunn's `about.rs struct Identity` verbatim + generalises; each app KEEPS its domain (its `enum ToolEffect` + `entries()` builder, its container/demo specs, its `ServiceSpec`). serde + serde_json only, ZERO other runtime deps; `serde` round-trippable so a CLI-dumped oracle reads back; `testmatrix` self-emitter records the Identity oracle + ToolMenu entry/last-pick verdicts |
| `nornir-claude` | the ONE shared Anthropic **Claude Messages-API** client (nornir's viz tab + dwarves' `ClaudeTab` used to twin a bespoke `reqwest` client each): a provider-agnostic `ClaudeTransport` seam with a deterministic `OfflineTransport` stub (no network, no key — replaces dwarves' hand-rolled `OfflineClaudeTransport`) and a live `HttpTransport` (feature `http`, blocking `reqwest`+rustls). Full credential resolution `resolve_credential` — env `ANTHROPIC_API_KEY` (→`x-api-key`) → env `ANTHROPIC_AUTH_TOKEN` (→`Authorization: Bearer` + `anthropic-beta: oauth-2025-04-20`) → per-session token; NEVER hardcodes a key. `send_wire(model, &[WireMsg])->Result<String,String>` mirrors the facett-claude pane seam so a UI host adapts with a ~3-line newtype. `testmatrix` self-emitter records the offline-send + precedence verdicts |
| `nornir-auth-oidc` | the OIDC / OAuth2 **provider**: validate a bearer JWT OFFLINE against a cached/pre-provisioned JWKS (airgap) or an opaque token ONLINE via `/userinfo`, plus the device-authorization-grant (RFC 8628) flow for interactive CLI login. One protocol → Entra ID / Okta / Auth0 / Google / Keycloak / Zitadel. Implements `nornir_service::AuthProvider`; the jsonwebtoken/JWKS closure links ONLY when a tier pins `oidc`. Reference impl holger; also korp/dwarves + the shared `nornir-auth-client` login |
| `nornir-auth-bearer` | the static Bearer token / API-key **provider** — the tiny M2M method (CI runners, agents, headless clients). Implements `AuthProvider`; reuses the shared bearer-secret resolution ladder + `token_matches` constant-time compare. Feature-gated so an embedded/mini tier ships this ONE provider and nothing heavier |
| `nornir-auth-oauth2` | the OAuth 2.0 / 2.1 **provider**: verify a presented access token via RFC 7662 introspection (SP side) + drive the service-to-service client-credentials grant and interactive auth-code + PKCE flows (opaque tokens + pure-OAuth2 M2M, beyond oidc's offline JWT-vs-JWKS). REUSES the `oauth2` crate (no hand-rolled protocol). Implements `AuthProvider::verify_bearer`; `reqwest` egress links ONLY behind the `online` feature |
| `nornir-auth-apikey` | the hashed static API-key **provider**: verify a presented key against a store of SHA-256-HASHED keys (hashed-at-rest, distinct from bearer's single plaintext secret) — rotating / multi-tenant stores where plaintext is never persisted. REUSES `nornir-hash::sha256_hex` + `nornir_service::token_matches`; no crypto/HTTP of its own, so every tier can pin it cheaply |
| `nornir-auth-mtls` | the mTLS (client-certificate) **provider**: map a mutual-TLS leaf-cert Common Name to an identity via `verify_client_cert`. Cryptographic client-cert trust (the CA chain) is established by the rustls transport; this provider consumes the ALREADY-EXTRACTED CN and does NOT itself path-validate a CA — the tiny zero-crypto transport provider a server pins when it accepts client certs |
| `nornir-auth-saml` | the SAML 2.0 (enterprise SSO) **Service Provider** `sp` (off-by-default feature), pure Rust: verify a browser-posted Response (HTTP-POST binding) — exclusive C14N, enveloped RSA-SHA2 / ECDSA-P-256 XML-DSig against the configured IdP certificate only, XSW-proof reads — then issuer, audience, Destination (required on a signed Response), Recipient, InResponseTo and the time window (skew clamped to 300 s); a refusal status is said by its code only. ADFS / Entra ID / Okta / Ping / Keycloak. The samael path (`saml` feature) is deleted (A34-14): it was never linked and its defaults were weak |
| `nornir-auth-webauthn` | the WebAuthn / FIDO2 / passkey (passwordless human login) **relying party**, pure Rust (p256, no C): `pure` (default) — signature, challenge, origin (RP), UP/UV flags and counter, the MAC'd session token, `PurePasskeyProvider` on the `AuthProvider` seam; `ceremony` — the server-held per-client challenge store and the assertion/attestation checks Gunnar and `nornir-identity` link. The ceremony state stays on the server and the verified result (counter included) goes back to the caller. The webauthn-rs path (`webauthn` feature) is deleted (A34-32): its state carried the subject and it dropped the counter |
| `nornir-auth-client` | the shared CLIENT-side login — the missing `<app> login`: OIDC device-authorization-grant (RFC 8628) login (reusing the nornir-auth-oidc device helper), access-token acquire + silent refresh, and a `TokenStore` seam (default 0600 file store; pluggable to the dwarves encrypted Vault). Any CLI calls `login()` / `current_token()` so interactive SSO works the same in korp / holger / dwarves |

## jera → draupnir consolidation (honesty note)

Containers and KVM used to be re-implemented inside jera. They now route through
draupnir's ONE engine: `jera::vm::DraupnirVmBootRunner → draupnir::Boot` (KVM) and
`jera::DraupnirContainerBootRunner` (containers, feature `container-oci =
["draupnir/backend-oci"]`). jera's run-to-completion `run_container` routes through
`draupnir::container::run_to_completion`. **The legacy in-crate `BollardEngine` /
`BollardBootRunner` / `ContainerEngine` trait have been REMOVED** — there is now a
single bollard/podman engine, in draupnir. jera's `default = []` (no engine linked;
the lean std build); the live OCI engine is opt-in behind `container-oci`. The
delegation seams stay daemon-free testable via draupnir's `Boot`/`ContainerControl`
traits. Design + boundaries:
`draupnir/.nornir/container-consolidation-plan.md`.

## Docs

This `.nornir/` is the **single source** of the repo's README and docs
(class-tagged): `design.md` (this file) · `guide.md` (how to depend on the crates)
· `for-idiots.md` (the beginner tour). The renderer emits the thin README + the
full book from these same files — thinning is a render-time class filter, never a
manual file-move.