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:
- 100% Rust, no shellout, no JS. git via
gix(HTTPS overrustls), git-over-SSH viarussh(never thesshbinary), Iceberg viaiceberg-rust, docs/diagrams via the embeddedtypstengine. The one accepted C exception is the vector embedder's tokenizer/ONNX runtime (opt-in, documented in 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 · 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):
znippy-zoomies= "0.1.8"— znippy's gatling no-barrier worker-pool streaming engine. nornir fans its deep-scansynparses 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 itsappend/scanmechanism 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 anyTestSink. Deliberately heavy-dep-free (purestd+serde, no iceberg / arrow / eframe) so leaf repos can pull just the matrix; nornir re-exports it and implementsTestSinkover 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.
Architecture — the two DAGs
nornir has exactly two graphs; keep them distinct.
- Component dependency DAG — orders N workspace components into one build/release in dependency order.
WorkspaceGraph::build_orderinsrc/warehouse/dep_graph.rs; persisted asdep_graph_edges/dep_graph_snapshots. The release pipeline walks it. - Ideas→plan DAG — the agentic planning funnel (
src/funnel/): ideas → triage → plans → nodes+edges → runs, with atopo_readydispatcher, event-sourced over thefunnel_eventsIceberg table. A plan node carries a free-formkind+targetsand 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:
- Single-pass resolution. Each file is walked once, calling the cached
goto_definitionperNameRef(ra_ingest::ingest_in_process_single_pass), not afind_all_refsper symbol. The file fan-out rides the rayon-freegatling_map_owned(one ownedAnalysissnapshot per chunk, work-stolen, with a per-file panic-guard). A real scoping bug —vfs_rel_pathwas indexing the whole 1342-crate dep closure instead of the workspace — was fixed with astrip_prefix?that restores workspace-only scope (2 M → 293 k defs). Together withprefill_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 againstfind_all_refs(defs identical, refs a superset). - Consumers prefer the resolved map.
query::load_preferredis the single chokepoint: it returns the resolved SCIP view (built fromscip_occurrences, edges by containment viascip::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 routecallers_of/callees_of/call_paththrough it — so the resolved edges close the bin→lib call paths the name-based syncall_edgesmiss 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 (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:
/cache— a transparent crates.io mirror/proxy. Point cargo's[source.crates-io]at it (cache upprints 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 bydwarves release <workspace> [--execute]. Thenornir release …verbs are still-working shims over it. nornir RETAINS its own quality-gate + lineage code undersrc/release/(doctor.rs,gate.rs,pipeline.rs,progress.rs,regression.rs,stage.rs), reading the Mimir warehouse; the engine modules named below with adwarves::…path live in dwarves.
-
release doctor(advisory, read-only) — dirty trees, external-dependency version skew (target = highest declared;--forbid arrow=56bans 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
behindon a crate isn't always a free bump.doctorreads each repo'sCargo.lockand, for a behind repo whose lock ALREADY resolves the target major alongside the old one (a dual-major tree), marks itheld_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 howarrowwas held at 57 byiceberg 0.9before the fork, and howtomlis held at 0.8 in nornir today. - Cycle-break advisor. When the publish graph isn't a DAG,
doctorruns 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()indoctor.rs.
- Transitive-pin detection (⛔ "held"). Being
-
release stage(rehearse) — the brave-but-safe tier.plan_stageis pure (topo order + per-repo verify = direct dependents);--executebrings the embedded/sparringup,cargo publishes each repo into it in dependency order, thencargo builds each dependent against/sparringto prove the cascade resolves from a registry. holger is torn down on return; the registry is ephemeral.src/release/stage.rs. -
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 declaresedition = "2024"andrust-version >= 1.85(edition 2024's MSRV floor), and the workspace setsresolver = "3".publish = falsecrates are skipped (they never ship, so they don't gate). The purestatic_findings()is unit-tested;static_findings_only()is cheap enough the viz Gate pane runs it on load. - Dynamic (one
cargo check --message-format=jsonwithRUSTFLAGS=--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 = falserather 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 · 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:
- Embedded (default). The CLI opens the warehouse in-process (
IcebergWarehouse::open) — single-tenant, no daemon. This is the everyday local mode;nornir-mcpworks the same way today. -
Client. A long-running
nornir-serverowns 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 reusableon_server(server, f)helper (connect + bearer + service-client call)server_target()selector route every everyday verb to the server whenNORNIR_SERVERis set — repos, guard, bench, index (incl.index upload, which client-streams a locally-built index up viaIndex.UploadSnapshot), knowledge, release gates, introspect, docs. (nornir-mcpand thenornir-uiviz 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.
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:
- Fat (embedded) — no server; a human's local checkout (pristine or dirty); compute + warehouse in-process. The everyday local mode.
- Server-monitored (
norniruser) — 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. - 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):
- HTTPS (
gix+rustls) — full clone/fetch; materializes worktrees. - SSH (
crates/nornir-git/src/ssh.rs,russh) — full clone/fetch:ls-remotefor cheap change-detection, thengit-upload-packpack transfer driven by gix over the russh channel (ed25519 deploy-key auth), and the worktree materialized from the fetched tree. Onlygit-receive-pack(push) is still pending. gix's own SSH transport execsssh— hence the russh path (full pipeline in "Git transport" below). Thenornir keyed25519 identity (created byinstall 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 --userunit that runs as the invoking user, execs that user's own~/.cargo/bin/nornir-serverin 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
norniruser; binaries copied to/usr/local/bin(thenorniruser 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.
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 ispath = …orgit = …+branch = …. The descriptor'sbranchis 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].remoteis 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/workspacesfor the systemd service) — no path env-var; never a per-workspacenornir.tomlkey.
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_fetchcoerces everyhttp(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 — publichttps://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://…):russhrunsgit-upload-packover a key-authenticated channel andgixdrives 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.
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 isgo; clicking a node scopes the release to that repo and lights its downstream blast radius; a🕘 <repo> — release historysection 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 runsstage --executeinto/sparring. Promote is IRREVERSIBLE and double-gated: rehearsal green and the operator types the literalPROMOTEconfirm phrase. TheWizardModelis 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; enginecrates/nornir-saga, warehousesrc/warehouse/saga_events.rs). Recorded user journeys (saga_eventstable: nav/click/log/error/state, keyed by session + git SHA) listed as rows; ▶ Replay turns a session into aReplayPlan(only nav/click events become steps), then a robotScenariodriven headless — each step fills one matrix row (aspectrobot-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 intests/saga_roundtrip.rs. - 📦 Holger (
src/viz/holger_tab.rs, featureviz+embed-holger). The married, in-process dev pair viewed through holger's functional API (read_registry()— no gRPC/HTTP, the same backendscache upserves):/cache(crates.io mirror) +/sparring(rehearsal registry) +/crates-io(upstream), each with its crate/version counts and the recent/sparringpublishes.▶ bring up /cache + /sparringstarts 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 → calleefromcall_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.
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.