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

nordisk/nornir

.nornir/design.md

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, vector search) and render as their own chapters.

Guiding constraints, in force everywhere below:

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 · warehouse (+sql) · test · build · release · docbook · jobs · airgap. This design.md is the f0 map that makes them one system; guide.md is the end-to-end workflow across them, and every why lives in 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.

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

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.

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.

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.

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.

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.

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:

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 (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:

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.

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.

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:

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 publishes each repo into it in dependency order, then cargo builds 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:

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 · idiot version: 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:

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.

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; 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. 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):

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

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.

Two config files, one job each (resolved). The split is by concern, not by member — both key on the same member names:

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 TablePreviews 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.

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.

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

🏛 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:

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.

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.