# Design
Design overview for **nornir** — the *vegvísir* for a multi-crate Rust
workspace. This is the map of the system's parts and why they're shaped the way
they are. It is one chapter of `nornir docs book`; deeper, area-specific notes
live in their own `.nornir/*.md` files (e.g. [doc generation](docs-generation.md),
[vector search](vector.md)) and render as their own chapters.
Guiding constraints, in force everywhere below:
- **100% Rust, no shellout, no JS.** git via `gix` (HTTPS over `rustls`),
git-over-SSH via `russh` (never the `ssh` binary), Iceberg via `iceberg-rust`,
docs/diagrams via the embedded `typst` engine. The one accepted C exception is
the vector embedder's tokenizer/ONNX runtime (opt-in, documented in
[vector.md](vector.md)).
- **Append-only history, keyed by git SHA.** Every fact is an Iceberg row; the
workspace at any past release is a warehouse read, not a git walk.
- **Airgapped-friendly.** Every index and the plan DAG are served over MCP so a
local, offline agent works a large workspace by query.
## The system as one — the function map (f0)

Before the parts: nornir is **one workspace-orchestrator** whose capabilities are
**peer verb-functions** over a single **warehouse substrate**, reached through a
few **surfaces**. No function is a super-container — `build` does not "own" `test`,
`workspace` is not "under" anything; when one function *uses* another that is an
edge on this map, not nesting. Each verb-function is documented as its own chapter —
the design half in `{function}-design.md`, the how-to in `{function}-guide.md`:
[workspace](workspace-design.md) · [warehouse](warehouse-design.md) (+[sql](sql-design.md)) ·
[test](test-design.md) · [build](build-design.md) · [release](../../dwarves/.nornir/release-design.md) ·
[docbook](docbook-design.md) · [jobs](jobs-design.md) · [airgap](airgap-design.md). This
`design.md` is the `f0` map that makes them one system; [`guide.md`](guide.md) is the
end-to-end workflow across them, and every *why* lives in [`reasoning.md`](reasoning.md).
| function | what it does | entry |
|---|---|---|
| **workspace** | span N crates as one appointed set — nornir's raison d'être | `src/config.rs`, `src/workspace/` |
| **docbook** | render `.nornir/` → README + the thin manual (typst) | `src/docs/` |
| **release** | ENGINE moved to `dwarves::release_doctor` (advise → rehearse → promote); nornir keeps the quality gates + HISTORY/lineage read side | `dwarves/crates/dwarves/src/release_doctor/` · `src/release/` (retained: doctor/gate/pipeline/progress/regression/stage) |
| **test** ⊃ {bench · coverage · robot · introspect · saga} | the multi-aspect matrix; bench *gates* like a test | `crates/nornir-testmatrix`, `src/robot.rs`, `crates/nornir-saga` |
| **funnel** | intake → decompose → cards | `src/funnel/` |
| **security** | the modgunn scanner surface (1 crate → whole-OSS-world) | `src/security.rs` → modgunn |
| **bakeoff** | competitive judged comparison | `src/bakeoff/` |
| **build** | build variants + provenance (→ `nornir-build-thing`) | `src/build/`, `crates/nornir-build-thing` |
| **jobs** ⊃ {container} | the in-process single-writer job ledger | `src/jobs/` |
| **airgap** | offline install / sealed import | `crates/nornir-airgap`, `src/airgap/` |
| **warehouse** (substrate) ⊃ vector · knowledge · arch · map · index · diagram · admin | one append-only Iceberg store, keyed by git SHA — every function reads/writes here | `src/warehouse/` |
**Surfaces** (how a function is reached — NOT functions themselves): **CLI**
(`src/bin/nornir.rs`), **viz** (the egui app — "start the nornir UI"), **serve**
(gRPC + MCP thin-client servers), **key** (identity). The warehouse is reached both
through typed RPCs/markers and, now, a **SQL surface** — `nornir warehouse query
"SELECT …"` (DataFusion over Iceberg); see [sql-design.md](sql-design.md).
**The `uses` graph** (drawn as the constellation diagram below): a function may
stand on a sibling nordisk crate (deep-scan *uses* `znippy-zoomies` gatling; the
warehouse *uses* `skade`) — that is an f0 edge, irrelevant to how nornir's own
docs partition. Each function is documented as its own chapter; this f0 map is
what makes them one system rather than a pile of datasheets.
## Place in the nordisk constellation
nornir is not a standalone tool — it both **stands on** a few sibling nordisk
crates and **orchestrates** the rest of them. One diagram, two relationships:

**Top band — what nornir is built from (crate deps, from `Cargo.toml`):**
- **`znippy-zoomies` `= "0.1.8"`** — znippy's *gatling* no-barrier worker-pool
streaming engine. nornir fans its deep-scan `syn` parses across all cores with
it (`src/deepscan.rs`); this is the GATLING + zero-copy law made concrete.
- **`skade-katalog` `= "0.1.7"`** (`path = "../skade"`) **+ `skade` `= "0.4.9"`**
(`path = "../skade/skade"`, `default-features = false`) — the Iceberg catalog
(a pure-Rust single-file ACID redb catalogue) plus the writer/reader stack
(ParquetWriter → DataFileWriter → `fast_append` + identity-partition keys). The
warehouse delegates its `append`/`scan` mechanism here instead of hand-rolling
Iceberg; this *is* the warehouse backend.
- **`nornir-testmatrix` `= "0.1.0"`** (`path = "crates/nornir-testmatrix"`) —
nornir's **own** reusable crate (an in-repo workspace member, published to
crates.io). The portable multi-aspect test engine — build · unit · doctest ·
clippy · fmt · audit — parsing results into rows and shipping them to any
`TestSink`. Deliberately heavy-dep-free (pure `std` + `serde`, no iceberg /
arrow / eframe) so leaf repos can pull just the matrix; nornir re-exports it
and implements `TestSink` over the Iceberg warehouse.
**Bottom band — nornir as orchestrator over the constellation.** A
`nornir-server` (the `nornir` user) tracks the sibling repos —
**holger · skade · znippy · knut · facett · korp · njord** — as *monitored
workspaces*. The flow: the **descriptor** (`nornir-workspace.toml`, where each
member's source lives — supplied at register time as a **git ssh URL** the server
clones, OR a local checked-out path; its raw TOML is then stored IN the registry
row as `descriptor_content`, no sibling file) plus the **registry**
(`src/registry.rs`, the redb record — **the source of truth** — that owns
*monitored-ness*: mode `monitored | pushed | external`, poll interval, the resolved
members + descriptor content, sync state) → a cheap `ls-remote` **poll** → on a HEAD change, a pure-Rust
**git clone/fetch** (`gix` + `russh`, no subprocess) into `<root>/<ws>/git` → the
**republish hook** (knowledge + dep-graph scan, `syn` fanned by znippy gatling,
optionally over the whole transitive dep closure) → each member's **own
per-workspace Iceberg warehouse** (written through the server's single redb
writer handle, via skade) → the **viz** (`nornir-ui`) and MCP, which read it
back as gRPC thin clients. Note the overlap: skade, znippy and facett appear in
*both* bands — they are crate dependencies **and** monitored repos.
The three deployment modes that frame this (fat / server-monitored / thin) are
detailed in [Deployment modes & server self-sync](#deployment-modes--server-self-sync).
## Architecture — the two DAGs
nornir has **exactly two** graphs; keep them distinct.
1. **Component dependency DAG** — orders N workspace components into one
build/release in dependency order. `WorkspaceGraph::build_order` in
`src/warehouse/dep_graph.rs`; persisted as `dep_graph_edges` /
`dep_graph_snapshots`. The release pipeline walks it.
2. **Ideas→plan DAG** — the agentic planning *funnel* (`src/funnel/`):
ideas → triage → plans → nodes+edges → runs, with a `topo_ready` dispatcher,
event-sourced over the `funnel_events` Iceberg table. A plan node carries a
free-form `kind` + `targets` and *references* DAG 1. Driven over MCP
(`funnel_*` tools) **or** the CLI (`nornir funnel {submit,plan,node,link,next,
status,show}`) — both append to the same table.
The **release pipeline** (`src/release/pipeline.rs::run_pipeline`: test → bench →
regression → guard → publish) is **not** a third DAG — it's a fixed sequence run
*over* DAG 1.
## Warehouse — one append-only Iceberg store
Every projection of the workspace is an Iceberg table under
`.nornir/warehouse/`, behind **skade-katalog** (a single-file `redb` catalog):
plan-DAG events, dependency edges, bench results, test outcomes, release
lineage, full-text index snapshots, code facts (symbols / call-edges from `syn`
and DWARF), and embeddings. Because each row is keyed by git SHA, **time-travel**
("restore the workspace as of release X") and **regression-trace** (find the
last-good → first-bad boundary for a target) are warehouse reads. Code:
`src/warehouse/` (`iceberg.rs`, `schema.rs`, `dep_graph.rs`), `src/release/`.
Hot scoped-read tables (`bench_runs`, the knowledge-map facts) are **partitioned
by `repo`**, so a `repo == X` read prunes whole data files at plan time rather
than scanning all history. The **full table-by-table catalogue** + the
partitioning details live in [warehouse.md](warehouse.md).
## Explore — full-text + vector + knowledge map
Two search engines side by side: **Tantivy BM25** (lexical) and an in-house
**exact-flat cosine** vector index (`src/vector/`, runtime SIMD + multicore).
The flat index is always the exact **oracle**; at scale (`≥ HNSW_MIN_ROWS`, 4096)
the live dense-kNN path routes through a **vendored, rayon-free HNSW** ANN
(`znippy_zoomies::vann` — nordisk's own crate, so still no third-party ANN dep),
toggle `NORNIR_VECTOR_HNSW`. **Hybrid** search fuses the two rankings by
**Reciprocal-Rank-Fusion** (RRF, `src/knowledge/fusion.rs`, k=60), with a
**Graph-RAG** expansion along the call graph. Embeddings are **materialized** in
the warehouse, so semantic search at any past SHA is a columnar read, never a
re-embed. Plus a `syn`/DWARF **knowledge map** (symbols, call graph) and
dependency blast-radius. Full detail and measured numbers: [vector.md](vector.md).
Embeddings run the same 768-dim `jina-v2-base-code` (ONNX) through one of two
**opt-in** backends: `embed-tract` (pure-Rust **CPU**, multicore) or `embed-ort`
(ONNX Runtime, **GPU/CUDA → CPU** silent fallback). The chunker is **item-aware**
for Rust (`syn` item spans, falling back to line-windows) so fewer, more coherent
units are embedded. CUDA libs are discovered at runtime (`cuda.rs`: `NORNIR_CUDA_LIBS`,
pip `nvidia-*` wheel dirs, the built-in `/opt/nornir/cuda`, ollama bundles,
`/usr/local/cuda*`); the NVIDIA **kernel driver** is the operator's job (nornir
detects it, can't install it). **`nornir vector doctor`** reports the whole chain
(driver/cudart/cuDNN, a test embed, `cuda_ready`) and advises. A build with no
embed feature still does BM25 + knowledge map; only the embed features cross the
no-C line (by accepted exception). See [vector.md](vector.md).
The DWARF knowledge map is **persisted and time-travelable**, not just live:
`introspect symbols --persist` snapshots a binary's symbols + inline-call edges
into the `dwarf_*` tables (git-SHA keyed, `src/introspect/persist.rs`), and the
warehouse-backed MCP tools `dwarf_{symbol_lookup,defined_in,callers,callees,
call_path}` answer queries with **no binary present** — so an offline agent reads
historized code facts at any SHA. How to drive a local/airgapped model over this
surface: [airgapped-agent.md](airgapped-agent.md).
**Resolved semantics — the `ra_ap` in-process resolved index.** The `syn`
knowledge map is *syntactic* (it sees `foo()` as a name, not as the one `foo` it
resolves to). nornir links **rust-analyzer as a library, in-process** (`ra_ap_*`
crates, no subprocess — honoring the no-shellout law) to ingest its *resolved*
defs/refs: `nornir scip ingest <repo> --in-process` (feature `ra-ingest`, off by
default; implies `scip`) loads the workspace into r-a's salsa DB and persists the
resolved map into the `scip_occurrences` warehouse table. Two things make it
practical rather than a curiosity:
- **Single-pass resolution.** Each file is walked **once**, calling the *cached*
`goto_definition` per `NameRef` (`ra_ingest::ingest_in_process_single_pass`),
not a `find_all_refs` per symbol. The file fan-out rides the rayon-free
`gatling_map_owned` (one owned `Analysis` snapshot per chunk, work-stolen, with
a per-file panic-guard). A real scoping bug — `vfs_rel_path` was indexing the
whole 1342-crate dep closure instead of the workspace — was fixed with a
`strip_prefix?` that restores **workspace-only** scope (2 M → 293 k defs).
Together with `prefill_caches`, that took a cold ingest from **~35 min → 63.5 s**
for a ~356k-row resolved index (51 MB on disk; persist ~10 s, merge 3 ms), and
it's validated against `find_all_refs` (defs identical, refs a superset).
- **Consumers prefer the resolved map.** `query::load_preferred` is the single
chokepoint: it returns the resolved SCIP view (built from `scip_occurrences`,
edges by containment via `scip::scip_call_edges`) tagged `"resolved/scip"` when
the warehouse has rows for the repo, else falls back to the syn map tagged
`"syn"`. The CLI (`knowledge query`), MCP tools, server RPCs, and the metro feed
all route `callers_of` / `callees_of` / `call_path` through it — so the resolved
edges close the bin→lib call paths the name-based syn `call_edges` miss entirely.

The precision win over `syn` (a method call resolves to its exact `impl`, not just
a name match), the SHA-keyed historization, and the perf roadmap (incremental
re-index next; a static search tree for the query layer) are in
[scip-resolved-knowledge.md](scip-resolved-knowledge.md) (cross-links
`workspace_nordisk/PLAN.md §Q`). Default `cargo check` stays fast — zero `ra_ap_*`
cost unless the feature is on.
## Workspace & its appointed members
A nornir **workspace** is the set of repos it manages — the *appointed members*,
declared as `[repo.<name>]` blocks in `nornir.toml` (each may carry gates, a bench
spec, and a crates.io `publish_order`). Membership is the unit of scope: the
dependency graph, the release pipeline, and the code index all operate over
exactly these members. `nornir repos` lists them.
**One workspace code index, scoped to the members.** There is a single Tantivy
code index per workspace, living next to the warehouse at
`<workspace>/<storage.local_path>/cache/index` (so two workspaces under one root
never collide). `nornir index build` walks the workspace and indexes only the
appointed members. Snapshots are byte-for-byte captures of that one index into
Iceberg, **keyed by a member's HEAD git SHA** for time-travel — `--repo X`
selects *which member's SHA keys the snapshot* and the recorded label, not a
separate physical index. `build`, `snapshot`, and `restore` therefore all agree
on the same directory.
Usage:
```sh
nornir repos # list the appointed members
nornir index build # (re)build the workspace index over all members
nornir index build --clean # wipe + full rebuild (evicts docs from a prior scope)
nornir map <repo> # one member, end-to-end: persisted knowledge scan +
# member-scoped index build + snapshot keyed to its HEAD
nornir index snapshot --repo <repo> # capture the index, keyed to <repo>'s HEAD
nornir index restore --repo <repo> # rehydrate it from the warehouse
```
`nornir map <repo>` is the convenience verb for "process one appointed member":
it runs a persisted knowledge scan (symbols + git-heat), builds the member-scoped
index, and snapshots it — all keyed to that member's current commit.
## Doc generation — everything through typst
Per-repo README/CHANGELOG, the whole-documentation **book**, and PDF/HTML/MD
exports are all rendered by the embedded `typst` engine — one engine, no JS, no
shellout. Markdown is translated to typst markup, marker sections
(`<!-- nornir:gen:start:… -->`) are filled from the warehouse (bench tables,
dependency graph as static SVG), and local media under `.nornir/assets/` is
embedded via a mounted typst filesystem root. Full pipeline, the marker syntax,
and `docs book` discovery: [docs-generation.md](docs-generation.md).
## Release & history
`nornir release run` walks DAG 1 in dependency order and, per repo, runs a fixed
sequence: test → bench (persist) → docs render → gate-all → **capture** → push →
publish. The **capture** step makes nornir a *fat client*: every projection it
computes at the release SHA lands in the warehouse — the `syn` knowledge map, the
Tantivy index (byte-for-byte pinned), semantic vectors, and DWARF facts from each
built binary (bench is persisted in its own step). The index + DWARF snapshot ids
are pinned into the `release_lineage` row. The same complete capture is one verb
outside a release —
`nornir map <repo>` — and every producer verb persists by default (`--no-save` /
`--no-snapshot` to opt out). Because each row is SHA-keyed, reading a past release
is a warehouse read: `index restore --sha`, semantic/lexical search at an old SHA,
and `release trace` (last-good → first-bad regression bisect, no rebuild). Full
flow, the capture table, and the history/trace commands: [release-history.md](../../dwarves/.nornir/release-history.md).
## The jobs ledger — in-process self-register, single-writer
Every long-running op (~1s–minutes) — a release run, workspace fetch/republish,
docs render/export/book, knowledge scan, deepscan, bench, test matrix, index
build — **self-registers a lifecycle row** in a dedicated redb ledger at
`<warehouse root>/jobs.redb` (`src/jobs.rs`). The op opens a `JobHandle`
(`running → done|failed`, finalized by a Drop guard so a panic or early-return
still closes the row), orthogonal to the *result* it writes to its domain Iceberg
table (a `result_ref` links the two). redb, not Iceberg, because a job record is
**mutable** and short-lived — overwrite-in-place, no append-only snapshot churn.

The ownership model mirrors the warehouse exactly. **The fat process or the
server owns `jobs.redb` (single-writer, like `registry.redb`)** — never both: in
the fat client the `JobHandle` writes the local redb directly; a thin client
submits each record over gRPC (`Telemetry.SubmitJobs`) and the server applies it to *its*
ledger. `release run` is the headline producer: the fat path opens a
`RELEASE_RUN` row, exactly the kind the server's `Ops.RunRelease` already
registers. The viz observes the ledger **read-only** (`open_read_only`, copying
the file aside when the server holds the lock), so it shows live job progress
without contending for the write lock. This is the same in-process
self-registration `cache up` uses (it shows as a `holger-proxy` job).
## The holger marriage — embedded `/cache` + `/sparring`
nornir links **holger** (`holger-server-lib`) IN-PROCESS — the `embed-holger`
feature, on by default (`src/holger_embed.rs`). No `holger-server dev-pair`
subprocess: `EmbeddedHolger::start` writes the dev-pair RON, instantiates the
backends, wires the routes and `start()`s the gRPC + HTTP listeners on an owned
tokio runtime; dropping the handle stops them. A lean client-only build drops it
with `--no-default-features` (then the same call sites spawn a subprocess).
Three consumers share it: `nornir cache up`, the release rehearsal, and the
`nornir-server` daemon at boot (`NORNIR_HOLGER=1`).

Two sparse-index gateways come up on one HTTP port:
* **`/cache`** — a transparent crates.io **mirror/proxy**. Point cargo's
`[source.crates-io]` at it (`cache up` prints the snippet); misses fetch
upstream once into a local znippy archive, so the next build is offline-fast.
* **`/sparring`** — a writable *fake crates.io* you publish INTO to rehearse a
release. It also mirrors crates.io upstream, so a published name must be unique
or it collides with the real crate (the cargo config source-**replaces**
crates.io with `/sparring`, so a dependent's build resolves the just-published
workspace crates from the registry and everything else falls through upstream).
### Release tiers — advise → rehearse → promote
> **⚠ MOVED (doctrine L5a — release is a build op).** The release orchestration ENGINE
> described in these tiers moved to **`dwarves::release_doctor`**
> (`dwarves/crates/dwarves/src/release_doctor/`) and is now driven by `dwarves release
> <workspace> [--execute]`. The `nornir release …` verbs are still-working shims over
> it. nornir RETAINS its own quality-gate + lineage code under `src/release/`
> (`doctor.rs`, `gate.rs`, `pipeline.rs`, `progress.rs`, `regression.rs`, `stage.rs`),
> reading the Mimir warehouse; the engine modules named below with a `dwarves::…` path
> live in dwarves.
1. **`release doctor`** *(advisory, read-only)* — dirty trees, external-dependency
version skew (target = highest declared; `--forbid arrow=56` bans one), topo
publish order, blast radius. No mutation. `src/release/doctor.rs`. Two
higher-order signals layer on top of the raw skew:
- **Transitive-pin detection (⛔ "held").** Being `behind` on a crate isn't
always a free bump. `doctor` reads each repo's `Cargo.lock` and, for a behind
repo whose lock ALREADY resolves the target major *alongside* the old one (a
dual-major tree), marks it `held_by_transitive_pin` — a transitive dependency
pins the old line, so a manifest bump alone won't take. The report splits the
hint: `💡 bump → …` for free bumps vs `⛔ blocked → …: a transitive dep pins
X (run cargo tree -i X to find it)`. This is exactly how `arrow` was held at 57
by `iceberg 0.9` before the fork, and how `toml` is held at 0.8 in nornir today.
- **Cycle-break advisor.** When the publish graph isn't a DAG, `doctor` runs
**Tarjan SCC** over the repo→repo edges and, per cycle, suggests the *cheapest*
edge to cut — the intra-cycle edge justified by the fewest crates — with advice
("extract them into a shared leaf crate, or make the dep optional/dev-only").
`cycle_advice()` in `doctor.rs`.
2. **`release stage`** *(rehearse)* — the brave-but-safe tier. `plan_stage` is pure
(topo order + per-repo verify = direct dependents); `--execute` brings the
embedded `/sparring` up, `cargo publish`es each repo into it in dependency
order, then `cargo build`s each dependent **against `/sparring`** to prove the
cascade resolves from a registry. holger is torn down on return; the registry
is ephemeral. `src/release/stage.rs`.
3. **`release promote`** — replays the same order against real crates.io once the
sparring run is green.
`doctor --rehearse` is the one-shot bridge: print the advice, then run the stage
engine (the SAME `run_stage_rehearsal` path `stage --execute` uses — one call
site, no duplication). doctor itself stays read-only unless you pass `--rehearse`.
### `release gate edition` — the Rust 2024 / cargo-standards gate
`release gate edition <repo>` (alias `release gate <name=edition_2024|rust_2024>`)
answers "is this crate following the current Rust/Cargo standard?" at release time
(`dwarves::release_doctor::edition`, moved out of nornir under doctrine L5a). Two layers:
- **Static** (cheap, `cargo_metadata` — no subprocess): every *publishable* package
declares `edition = "2024"` and `rust-version >= 1.85` (edition 2024's MSRV
floor), and the workspace sets `resolver = "3"`. `publish = false` crates are
skipped (they never ship, so they don't gate). The pure `static_findings()` is
unit-tested; `static_findings_only()` is cheap enough the viz Gate pane runs it
on load.
- **Dynamic** (one `cargo check --message-format=json` with
`RUSTFLAGS=--force-warn rust-2024-compatibility`): any rendered diagnostic that
flags a 2024-edition incompatibility (matched on the rendered "2024 edition" /
"Rust 2024" phrasing, robust to the evolving lint-name set) becomes a finding. If
cargo can't run, `lint_pass_ran = false` rather than failing the gate on an
environment hiccup.
**Coverage.** Pure plan logic (skew, topo, blast, plan, cycle advice, transitive
pins, edition findings) is unit-tested in-crate (`release::doctor`, `release::stage`
in nornir; the moved `dwarves::release_doctor::edition` carries the edition tests). The live marriage is `tests/holger_embed.rs` (boots
the pair on ephemeral ports, asserts `/cache` + `/sparring` both serve — fast,
hermetic). The full cascade is `tests/release_stage_execute.rs` (`#[ignore]`, heavy:
two unique-named fixture crates published into `/sparring` and built back out). The
viz release surfaces are robot-driven in `tests/robot_viz.rs` (the dashboard's gate
overlay/badges/cut-edges, the wizard's gating, saga replay → matrix rows). Full
how-to: [release-rehearsal.md](../../dwarves/.nornir/release-rehearsal.md) · idiot version:
[release-for-idiots.md](../../dwarves/.nornir/release-for-idiots.md).
## Guard & revert
Release-critical files are locked (`chmod -w`) against chaotic edits and checked
against a recorded sha256 + mode manifest (`crates/nornir-guard`). Because every version
is SHA-keyed in the warehouse, tampering is detectable, the culprit is
time-travelable, and the revert rides on git.
## One library, four binaries
`nornir` (CLI) · `nornir-mcp` (MCP/stdio, feature `mcp`) · `nornir-server`
(gRPC/tonic, feature `server`) · `nornir-ui` (egui time-travel visualizer,
feature `viz`). Build the CLI with `cargo build`; add `--features mcp,server,viz`
as needed. The doc pipeline above is feature `docs-export`.
**Embedded *or* client — never both on one warehouse.** A warehouse has exactly
one owner of its redb/iceberg write lock, so the CLI runs in one of two modes,
mutually exclusive *per warehouse*:
- **Embedded (default).** The CLI opens the warehouse in-process
(`IcebergWarehouse::open`) — single-tenant, no daemon. This is the everyday
local mode; `nornir-mcp` works the same way today.
- **Client.** A long-running `nornir-server` owns the warehouse and the CLI talks
to it over gRPC. Required for sharing, concurrency, remote/airgapped access, and
serving several workspaces at once. **CLI→client conversion is complete:** a
reusable `on_server(server, f)` helper (connect + bearer + service-client call)
+ `server_target()` selector route *every* everyday verb to the server when
`NORNIR_SERVER` is set — repos, guard, bench, index (incl. `index upload`, which
client-streams a locally-built index up via `Index.UploadSnapshot`), knowledge,
release gates, introspect, docs. (`nornir-mcp` and the `nornir-ui` viz still
open the warehouse embedded; converting them to clients is the remaining piece.)
Mode selection is explicit (`--server <url>` / `NORNIR_SERVER`; an optional
`[server]` block in `nornir.toml` can pin a shared workspace to client mode),
defaulting to embedded. It is **by design** that a standalone CLI cannot touch a
warehouse a server already owns — that's the expected behavior, not a limitation
to engineer around. Command logic lives once in the library: server handlers and
the embedded CLI call the same functions; only the request/response cross the
wire. The **embedded CLI** (and `nornir-mcp`) resolve config as `--config`, then
the `NORNIR_CONFIG` env var, then cwd discovery. The **server** has **no config
ingress at all** — no `nornir.toml`, no `--config`/`NORNIR_CONFIG`, no
`NORNIR_WORKSPACES`, and no `--monitor`/`--poll`/`--warehouse`. Its **entire** boot
surface is two settings — `--token`/`NORNIR_SERVER_TOKEN` (auth, ≥16 chars, falling
back to `<home>/.nornir/token`) and `--addr`/`NORNIR_SERVER_ADDR` (bind, default
`127.0.0.1:7878`), precedence flag > env > default. Workspaces come ONLY from the
redb registry (`<home>/.nornir/workspaces/registry.redb`), mutated **at runtime**
via `nornir workspace register` → `Workspaces.Register` (an idempotent upsert,
eagerly served without a restart). An empty registry (zero workspaces) is a valid
resting state — the server starts clean and serves 0. See
[running-as-a-server.md](running-as-a-server.md).
## Deployment modes & server self-sync
The verbs above run in **one of three** deployment modes — same verbs, differing
only in *where source lives*, *where git/compute runs*, and *who triggers*. The
full capability + test matrix is [mode-matrix.md](mode-matrix.md); the shape:
1. **Fat (embedded)** — no server; a human's local checkout (pristine *or*
dirty); compute + warehouse in-process. The everyday local mode.
2. **Server-monitored (`nornir` user)** — the server pulls members from their git
URLs on a timer and republishes. It is the server *playing thin client against
itself*, fed by pull instead of push; just another tenant.
3. **Thin client** — a remote human pushes working-tree source; the server
computes into that client's own warehouse. gitx (tag/publish) stays
client-side; analysis runs server-side.
**Server-monitored is the only mode that adds new machinery**, and it reuses the
existing pieces: one server-user `nornir` owns many workspaces under a single
**home-derived root** — `config::registry_root()` = `<home>/.nornir/workspaces`,
where `<home>` is the *running user's* home (the server runs as `nornir`, so
`/home/nornir/.nornir/workspaces`). **No path env-var** — see
[data-root.md](data-root.md). Laid out **per-workspace** as
`<root>/<workspace>/{git,builds/warehouse,funnel}` — `git/` is the per-workspace
`workspace_root` the `config.rs` `repo_dir` convention already resolves against
(members land at `git/<member>/`); it stays **empty** when sources live outside
(the fat-external case), leaving only `builds/` server-owned. **Fat mode uses the
identical layout** (`Loaded::warehouse_root()` → the same
`<home>/.nornir/workspaces/<name>/builds/warehouse`) — the only difference is who
fills `git/`; there is **no global warehouse**. Because the server reaches source by
`git fetch`, a monitored workspace is **always a committed ref** — there is no
"dirty" state it can observe, which is what makes unattended republish safe.
**Workspace registry** (`src/registry.rs`) — **the single source of truth**. The
server's index of every workspace it tracks is one `redb` file at
`<root>/registry.redb` — `name → serde_json` bytes (redb stores bytes, not
structs). A row carries the mode, the seed descriptor *path/URL* **and the raw
descriptor TOML content itself** (`descriptor_content`), poll interval, current
Iceberg snapshot id, and per-member `{remote, ref, last_seen_sha, last_synced,
sync_state}`. Because the descriptor content is stored **in the row**, there is **no
`descriptors/*.toml` sibling file** the server depends on: `register` stores the
shipped content directly (`Workspace::from_content`), `republish` reads `deep_scan`
from the row (file fallback only for legacy rows written before content was
persisted), and members (name + git URL) derive from the stored content too. The
registry is therefore self-contained — a wipe is total, and a re-add re-populates a
workspace from the row's own git URLs. Server is the sole writer — same
single-writer discipline as the warehouse. Rows are mutated **at runtime** (no boot
config): `nornir workspace {ls,show,register,rm,fetch}` routes to the
**`Workspaces.*` gRPC service** when `NORNIR_SERVER` is set and runs embedded
otherwise; `Workspaces.Register` is an idempotent **upsert** (re-registering updates
settings like `poll`), and a monitored register **eagerly** clones members →
knowledge-scans → indexes → snapshots inline, so the workspace is served without a
restart (`--lazy`/`lazy: true` defers population to the poll loop). The server and
its poll loop share **one** `Registry` handle (redb is single-writer). The egui viz
picker over `Workspaces.List` lists/switches the served workspaces live.
**The self-sync loop** (`src/monitor.rs`, spawned by `nornir-server`). The server's
monitor loop ticks at a **fixed 60s default**; the per-workspace cadence is the
registry row's `poll` field (set at register time). Each tick it runs the **fetch
primitive** (`gitio::clone_or_fetch`):
clone each git member into `git/<member>/`, else fetch; compare HEAD to the
registry's `last_seen_sha`; write back `last_synced`/`sync_state`; count changes.
On change it **republishes** (`monitor::republish_with`): knowledge scan +
member-scoped code index + per-member Iceberg snapshot + a cross-repo dep-graph
snapshot (`cargo metadata --no-deps`, offline) under `<root>/<ws>/builds/`,
recording the snapshot id on the registry row. When the server serves that
workspace, the republish writes **through the server's warehouse handle** (redb
single-writer), so the viz reads it live over gRPC. Incremental rebuild (Mímir
`affected_by_change`) and an atomic snapshot-pointer flip are the remaining
refinements; today it rebuilds all members and is verified end-to-end over HTTPS.
Transport is **pure-Rust** (no `git`/`ssh` subprocess, honoring the no-shellout
constraint above):
- **HTTPS** (`gix` + `rustls`) — full clone/fetch; materializes worktrees.
- **SSH** (`crates/nornir-git/src/ssh.rs`, `russh`) — **full clone/fetch**: `ls-remote` for cheap
change-detection, then `git-upload-pack` pack transfer driven by gix over the
russh channel (ed25519 deploy-key auth), and the worktree materialized from the
fetched tree. Only `git-receive-pack` (push) is still pending. gix's own SSH
transport execs `ssh` — hence the russh path (full pipeline in "Git transport"
below). The
`nornir key` ed25519 identity (created by `install add`, registered as a
deploy key) drives SSH auth and, later, push.
**Git SSH identity** (`nornir key {show,path,generate}`). The server owns an
ed25519 keypair generated **in pure Rust** (`ssh-key` crate) — created idempotently
by `install add` at `<home>/.ssh` (system) or `<home>/.nornir/ssh` (user),
preserved across re-installs like the bearer token. `key show` prints the public
half to paste as a deploy key.
**THE UNINSTALL LAW — always kill the data, always keep the key.** Suite-wide and
non-negotiable (`workspace_nordisk/.nornir/suite-uninstall-law.md`): an uninstall
ALWAYS erases the data and ALWAYS keeps the ssh keys — *not behind a flag, not
opt-in, not `--purge`*. So `install remove`, with no flags, removes the unit, the
`/usr/local/bin` binaries (system scope) and everything under the state root
(warehouses, workspace registry, caches, funnel, jobs), while **always preserving
the install identity**: the `ssh/`⁄`.ssh` deploy key that the git remotes have
already registered, and the bearer `token`. System scope therefore also keeps the
`nornir` account, because that account owns the spared key — an uninstall must
never orphan or destroy the key as a side effect. A reinstall gets a genuine clean
slate and still clones the private repos with the key the remotes trust.
Destroying the identity requires the one explicit opt-in `--wipe-identity` (which
additionally removes the home and the service user); after that a reinstall mints
a NEW key you must re-register on every remote. `--purge` and `--keep-identity`
survive as **deprecated no-ops** — the behaviour is unconditional now — so existing
scripts and the runa pipelines keep parsing. The state root is **resolved by
scope** (`$HOME` under user scope, the passwd home under system scope), never a
hardcoded constant: sibling apps that erased a hardcoded `/home/<app>` wiped
nothing on a per-user install and printed success anyway. Every individual delete
is executed by skidbladnir's guarded primitive
(`systemd::guarded_remove_dir_all`, the same authority behind
`purge_service_data_keep_keys`): it refuses any path with a `.ssh` component, a
real user's home, a system directory, or a symlink out of the tree, and is pinned
to the resolved state root. An uninstall that removed nothing says so rather than
printing success. This is the constellation-wide rule dwarves already encodes as
`purge_all_data_keep_ssh`.
**`nornir install` — two scopes, one law.** The server runs as a systemd service
in one of two scopes, auto-picked by privilege (`--scope user|system` overrides):
- **user** (default when not root): a `systemctl --user` unit that runs as the
invoking user, execs that user's own `~/.cargo/bin/nornir-server` **in place**
(no copy — a user can exec from their own cargo dir), with all state in that
user's `~/.nornir`. No root, no system user, no `/etc`. Because its warehouse is
the user's home-derived default — the **same** one the fat CLI opens — starting
and stopping the service flips that user between *fat* (own the redb lock) and
*served* (the daemon owns it, you query thin) over **one** warehouse; the
single-writer lock makes them mutually exclusive, never concurrent.
- **system** (default when root): a system unit running as the dedicated `nornir`
user; binaries copied to `/usr/local/bin` (the `nornir` user can't exec from a
login user's home/removable drive → `203/EXEC`), monitoring inputs **staged**
into `/home/nornir`, state under `/home/nornir/.nornir`.
The law that unifies them: **the bearer token is the 0600 `<home>/.nornir/token`
— the SAME file the CLI client resolves — and there is NO `/etc/nornir` and no env
file.** The server reads `NORNIR_SERVER_TOKEN` if set, else falls back to
`<home>/.nornir/token` (HOME drives the path, so the unit just sets
`Environment=HOME=<service-home>`). The only non-secret server setting is the bind
addr, an inline `Environment=NORNIR_SERVER_ADDR=` line in the unit. (The old
`--monitor`/`--poll` flags and their `NORNIR_MONITOR`/`NORNIR_POLL` env lines are
**gone** — removed in June by `0c240f9`/`9ea97c4`; `render_server_unit`'s unit
tests assert the generated unit carries neither, and the claims below bind this
paragraph to that code. Workspaces come from the registry at runtime.
`NORNIR_CONFIG` is emitted only for an explicit `--config` pin; no install flag
passes a warehouse pin, so no `NORNIR_WAREHOUSE` line is written.) In user
scope the server and the client share one home → one token file, no sync; in
system scope the two homes differ, so a same-box client copies the token across
once (`sudo cat /home/nornir/.nornir/token > ~/.nornir/token`). `update` is a pure
local binary swap that never touches a registry: system scope copies the binaries
you ran into `/usr/local/bin` + restarts; user scope just restarts (your
`cargo install` already replaced `~/.cargo/bin`). The bind port is never
auto-bumped — a taken port fails loudly so you pick another `--addr`.
<!-- nornir:claim: absent "Environment=NORNIR_MONITOR" in src/bin/nornir.rs -->
<!-- nornir:claim: absent "Environment=NORNIR_POLL" in src/bin/nornir.rs -->
**Two config files, one job each (resolved).** The split is by *concern*, not by
member — both key on the same member names:
- **`nornir-workspace.toml`** (the *descriptor*, `src/workspace/descriptor.rs`)
owns **where source comes from**: each member is `path = …` *or* `git = …` +
`branch = …`. The descriptor's `branch` **is** the tracked ref. This is the
authoritative source for monitored fetch. For a server workspace its **content is
copied into the registry row** (`descriptor_content`) at register time — the row,
not a sibling file, is what the server re-reads thereafter.
- **The registry record** (`src/registry.rs`) owns **monitored-ness** *and* the
descriptor content: mode (monitored | pushed | external), poll interval, the raw
descriptor TOML, and per-member sync state. The self-contained source of truth.
- **`nornir.toml`** (`src/config.rs`) owns **release recipes** for the *embedded
CLI/MCP* (per-member gates, bench spec, `publish_order`). Its `[repo].remote` is
kept only as doc-link metadata — *not* a second source of truth. **The server
never reads it** (no boot config).
- **Server root** (`<root>/<ws>/{git,builds}`) is **home-derived** —
`config::registry_root()` = `<home>/.nornir/workspaces`, the running user's home
(`/home/nornir/.nornir/workspaces` for the systemd service) — **no path env-var**;
never a per-workspace `nornir.toml` key.
So a monitored workspace uses the descriptor (source) + a registry row
(monitored-ness) + optionally `nornir.toml` (recipes), with no overlapping
source-of-truth.
### Memory bounds of the served process — the working-set byte budget
The served nornir (standalone `nornir-server`, or embedded in the fat
`dwarves-server`) keeps a process-global, snapshot-keyed cache of the DECODED hot
warehouse tables (`src/warehouse/working_set.rs`): the Mímir lens read set as
stringified `TablePreview`s plus the dup run's three Arrow tables, warmed at
populate end so the first lens/dup RPC after a populate hits RAM. **That cache is
bounded in bytes, not entries.** Measured 2026-08-21 on oden (fat
`dwarves-server`, 1 s RSS sampler against the monitor journal): idle 110 MB;
deep-scan a 10.5 GB *transient* that returns; then the populate-end warm took the
process from 1.8 GB to **46 GB RSS in 18 s and it stayed there** — thirteen
previews at the 5,000,000-row cap (`call_edges` is 194 M rows on disk,
`symbol_facts` 98 M, so every big table hit the cap) under a "64 most recent
entries" count cap that bounded nothing. That plateau is stinky kopiera-6.
Two knobs, both honest at the cap:
| knob | default | what it bounds |
|---|---|---|
| `NORNIR_WORKING_SET_MAX_BYTES` | `4g` | the WHOLE cache (previews + Arrow). LRU-evicted until it fits; every eviction is logged; an entry larger than the whole budget is served but never retained (logged, counted). |
| `NORNIR_PREVIEW_MAX_BYTES` | `1g` | ONE preview decode (`scan_preview_bounded`) — bounds the transient as well as the resident size. A cut table is logged BY NAME with this knob; it is never served as if whole. |
| `NORNIR_ARROW_MAX_BYTES` | `2g` | ONE Arrow-table decode through the cache (`scan_arrow_bounded`, batch-granular). Same loud cut. |
The single largest term was not a preview at all: the warm list carried
`symbol_facts` as an ARROW table for a reader that did not exist (every Arrow
reader is an embedding table; `mimir_dup` reads `symbol_facts` as a preview), and
its full-history decode measured **29.11 GiB** — warmed for nobody, every populate.
`HOT_ARROW_TABLES` is now exactly the tables `arrow_cached` readers use
(`embedding_snapshots`, `embedding_manifest`, `embeddings`). With the bounds and
that fix the same populate load measured 5.9 GB RSS after the warm against 46.1 GB
before (working-set warm line: 13 preview + 2 arrow tables resident = 3.10 GiB).
Values take `4g` / `512m` / `8k` / plain bytes. The warm ends with one summary
line — `⏱ working-set warm N preview + M arrow table(s) resident = X (budget Y;
E eviction(s), R refused-too-big) in T s` — and skips an Arrow warm whose data
files on disk already exceed the budget (a full decode could never be retained;
the decode itself is the OOM-sized transient). `working_set::stats()` exposes the
accounting. Honest limit: the 5 M-row cap was already a silent truncation (the
lens read the *oldest* 5 M of 194 M `call_edges` rows and called it the latest
snapshot); it is now loud, and reading only the latest snapshot per repo is the
separate cure. `rust-analyzer` (resolved ingest) is a subprocess with its own
memory-budgeted fan-out (`NORNIR_SCIP_FANOUT`, 10 GB per instance) and is never
part of the server's RSS.
## Git transport — pure-Rust over HTTPS *and* SSH (no subprocess)
A monitored member's source is `path = …` (local) or `git = …`. For git, both
HTTPS and SSH are pure-Rust — **no `git`/`ssh` subprocess, no libgit2/C**
(`crates/nornir-git/src/{gitio,ssh}.rs`):
> **SSH-first when a key is present (the server case).** `gitio::clone_or_fetch`
> coerces every `http(s)://` remote to its scp-like SSH form (`git@host:owner/repo.git`)
> and tries SSH first **whenever a deploy key is available**, falling back to the HTTPS
> path only when no key exists. An installed server *always* holds a key, so it fetches
> **every** member — public `https://` URLs included — over SSH and needs the key
> registered as a deploy key. The keyless HTTPS clone below is taken only by the
> FAT/no-key client.
- **HTTPS** (`https://…`): `gix` + `reqwest`/rustls clone & fetch directly.
- **SSH** (`git@host:owner/repo.git`, `ssh://…`): `russh` runs `git-upload-pack`
over a key-authenticated channel and `gix` drives the wire protocol + pack
indexing over it. The server's ed25519 **deploy key** (`/home/nornir/.ssh`,
`nornir key show`) is the identity; register it on each private repo. This is
what lets a **private** repo be monitored without any credential helper on the
box.
The SSH fetch path (`gitio::ssh_sync`), per poll:
```
member git = "git@codeberg.org:nordisk/holger.git"
│ poll loop (monitor.rs, inside spawn_blocking → not on a runtime thread)
▼
ssh::ls_remote ── russh: exec `git-upload-pack`, read ref advertisement, send a
│ flush-pkt (0000) so upload-pack closes instead of blocking ──► HEAD sha
│ HEAD == local HEAD? ──► return early (cheap change-detection; steady-state no-op)
▼ changed
ssh::connect_upload_pack
russh channel ─exec─► `git-upload-pack '<path>'` (GIT_PROTOCOL=version=2 env)
channel.into_stream() ─ tokio::io::split ─► (ReadHalf, WriteHalf)
│ tokio_util SyncIoBridge ── blocking std::io ⇄ async, on an owned runtime
▼
gix git::Connection (ConnectMode::Process, protocol V2)
│ Remote::to_connection_with_transport(transport)
│ prepare_fetch → receive ← negotiation + thin-pack resolution, all gix
▼
objects + refs/remotes/origin/* written into the repo's ODB
│ committer fallback (committer_or_set_generic_fallback) so reflogs write
│ set HEAD/branch → materialize worktree (index_from_tree + public blob
│ writes: exec-bit + symlinks, skips gitlinks)
▼
knowledge scan → member-scoped index → Iceberg snapshot, republished **through the
server's own warehouse handle** (redb is single-writer; see Deployment).
```
Why this shape: `gix`'s own SSH transport execs the `ssh` binary (breaks the
no-shellout rule), and gix's protocol-V1 client is documented as deadlock-prone —
so we keep gix's robust pack machinery but feed it a `russh` channel as a
`ConnectMode::Process` transport, negotiating V2. `ls-remote` is the cheap poll
primitive (advertisement only); pack transfer only runs on an actual HEAD change.
Not yet: SSH **push** (`git-receive-pack`) — monitoring only fetches.
## Reach — viz and MCP are gRPC thin clients
One `nornir-server` owns the warehouse write lock; everything else reads/writes
over gRPC (the lock rule, above). Both remote surfaces are thin clients:
```
┌───────────────── nornir-server (owns warehouse) ─────────┐
viz (nornir-ui) ─gRPC┤ Viz.Timeline · Warehouse.{Tables,Scan} (nornir-workspace ├─► monitored
│ header selects the workspace) │ workspaces'
friend's Claude Code │ Repos/Mimir/Knowledge/Release/… one RPC per CLI+MCP verb │ builds/ warehouses
│ stdio └──────────────────────────────────────────────────────────┘
▼
nornir-server mcp ──(NORNIR_SERVER set)──► gRPC relay to the server (mcp_connect + bearer)
└─(NORNIR_SERVER unset)─► embedded: IcebergWarehouse::open in-process
```
So with `NORNIR_SERVER` + token set, **every** MCP tool (`src/mcp_stdio.rs`,
`if server_target().is_some()`) relays to the live server over gRPC — an agent on
another machine drives the server's warehouse without opening any local files.
Unset, the same binary runs embedded against a local warehouse. The CLI client
mode and the MCP relay share the same gRPC services and bearer-token auth.
Two corrections this paragraph carried until 2026-08-25. **`src/bin/nornir-mcp.rs`
does not exist** — the MCP loop moved into the library as `src/mcp_stdio.rs` and is
reached as `nornir-server mcp` (2026-08-03), so the cited path had been dead for
three weeks. And "**every**" was 35 of the 38 warehouse-touching tools, not all 38:
`arch_svg`, `arch_graph` and `arch_trace` had no `server_target()` branch at all and
called the EXCLUSIVE `IcebergWarehouse::open`, so against a co-located server they
died on redb's single-writer lock and against a remote one they answered from an
empty local warehouse. The word is now GATED rather than asserted —
`tests/mcp_client_mode_law.rs` fails the build if any `#[tool]` that reaches the
warehouse lacks the branch.
<!-- nornir:claim: present "fn every_warehouse_touching_mcp_tool_has_a_grpc_branch" in tests/mcp_client_mode_law.rs -->
## Viz — the release surfaces (dashboard · wizard · saga · holger)
The `nornir-ui` viz grew a release-engineering home this session. Four panes,
each emitting its full state as machine-readable JSON (the robot-testing law,
below):
- **🧬 nornir release dashboard** (`src/viz/nornir_dashboard.rs`). The
gate-overlaid dependency graph. Each repo node carries the doctor verdict as a
badge — `⛔` held by transitive pin / `✗` edition-gate fail (both RED, blocking),
`▼` behind / `🟡` dirty (AMBER, advisory), no badge = GREEN clean — and each
dependency cycle is drawn with a **dashed cut edge** labelled `✂ cut · <rationale>`
(the cycle-break advisor's suggestion). The **🚀 Release** button is GREEN/enabled
only when the gate is `go`; clicking a node **scopes** the release to that repo
and lights its **downstream blast radius**; a `🕘 <repo> — release history`
section reads the timeline. The button opens the wizard.
- **🧙 release wizard** (`src/viz/release_wizard.rs`). A gated four-step walk —
**1 · Advise → 2 · Fix → 3 · Rehearse → 4 · Promote** — over the SAME gate model
the dashboard paints. Each step is a gate (`green`/`red`/`pending`); you can't
*advance* past a red/pending step without arming an explicit `⚠ override`. Fix
surfaces the concrete moves (`💡 bump X → target in: …`, `💡 cut A → B`,
`cargo fix --edition for …`). Rehearse runs `stage --execute` into `/sparring`.
Promote is IRREVERSIBLE and double-gated: rehearsal green **and** the operator
types the literal `PROMOTE` confirm phrase. The `WizardModel` is a pure,
unit-tested state machine (no I/O); the egui layer just renders it.
- **🎬 Saga Sessions** (under the 🧪 Test tab, `src/viz/test_tab.rs`;
engine `crates/nornir-saga`, warehouse `src/warehouse/saga_events.rs`). Recorded
user journeys (`saga_events` table: nav/click/log/error/state, keyed by session +
git SHA) listed as rows; **▶ Replay** turns a session into a `ReplayPlan` (only
nav/click events become steps), then a robot `Scenario` driven headless — each
step fills one **matrix row** (aspect `robot-ui`). A replay whose target widget
has *gone* is a **RED matrix row**: that's the UI-regression seam. Record→replay
→matrix is end-to-end in `tests/saga_roundtrip.rs`.
- **📦 Holger** (`src/viz/holger_tab.rs`, feature `viz` + `embed-holger`). The
married, in-process dev pair viewed through holger's **functional API**
(`read_registry()` — no gRPC/HTTP, the same backends `cache up` serves):
`/cache` (crates.io mirror) + `/sparring` (rehearsal registry) + `/crates-io`
(upstream), each with its crate/version counts and the recent `/sparring`
publishes. `▶ bring up /cache + /sparring` starts the embedded pair live.
### 🏛 Architecture lenses
The Architecture tab (`src/viz/arch_tab.rs`, the `ArchSubView` enum) consolidates
six lenses over the same scanned codebase — keep two pairs of them distinct:
- **🔗 Deps** — the cross-repo + intra-repo dependency graph.
- **📡 Live** vs **🏛 Wiring** — the SAME EPIC-ARCH wiring board (UI components ↔
gRPC ↔ core fns ↔ warehouse tables, coverage-ring overlaid). **Wiring is STATIC**
(the persisted board); **Live** additionally re-reads the warehouse on a 2s timer
and repaints on change. Same picture, different freshness.
- **📍 Hållpunkter (Boundaries)** — just the *landmark* functions at the layer
boundaries (ui / gRPC / emitter) as nodes, no edges, classified by the shared
`coverage::classify`.
- **🕸 Call Graph** — the full function call graph (`caller → callee` from
`call_edges`), drilled repo → crate → fn.
- **🚇 Metro** — **a call-PATH map, NOT the call graph.** One subway line per UI
button answering "I'm about to click this — what fires, and where does the
request go?": it BFS-traces the single path from the button's click-handler to
the gRPC handler implementing its RPC, every fn a station, boundary fns labelled
interchanges (`src/arch/metro.rs`).
## Viz introspection (debugging + robot testing)
The `nornir-ui` viz exposes its full UI state externally: a JSON dump
(`$NORNIR_VIZ_STATE`, default `/tmp/nornir_viz_state.json`, rewritten every
frame) carrying the workspace picker options/selection, the tab set, timeline +
warehouse population, `connect_status`, and any empty-state placeholders; plus a
stderr CLICK trail on tab/workspace change. This is the assertion surface for
headless robot testing and the first thing to read when the picker looks empty.
Every release pane above adds its own block (`nornir_dashboard`, `release_wizard`
inside it, `test.saga_sessions`, `holger`), so the dashboard's gate overlay, the
wizard's per-step gating, a saga replay's matrix rows, and the holger registry are
all asserted as data — `tests/robot_viz.rs` drives real AccessKit clicks and reads
them back. See [`viz-introspection.md`](viz-introspection.md).
## Which recipe reader this repo uses, and why (lane AJ, 2026-08-30)
**Reader:** `nornir-build-thing` **0.1.12**, PATH (root + `xtask`).
**Why:** in-constellation. The root said `0.1.10` and `xtask` said the floating `"0.1"`
(= `^0.1.0`, every reader that ever existed) while both compiled 0.1.11. nornir's
recipe is schema 1. OPEN: nornir's graph carries a second, registry-sourced copy
of the same crate — it removed all eight `[patch.crates-io]` entries on
2026-08-23 on purpose, so this one closes on the release cascade.
The rule itself — `[nornir-build-thing] schema = N`, a refusal that names the
version to upgrade to, and a loader that refuses to DROP a key it cannot read —
lives once, in **edda `.nornir/build-design.md`, "THE COMPATIBILITY RULE"**. The
cross-version guard that would have caught the 2026-08-30 skew is
`edda/nornir-build-thing-skew`.