The three modes — capability & test matrix
nornir runs in one of three deployment modes. The verbs are identical in all three (see running-as-a-server.md); what differs is where the source lives, where git operations run, where the heavy compute runs, and who pulls the trigger. This doc is the contract: every everyday verb must be tested in all three modes.
Status legend: ✅ works today · 🔜 designed, not yet built.
The modes
| Source of truth | gitx (clone/tag/publish) | Compute (index/knowledge/gates/docs) | Warehouse | Trigger | Status | |
| 1. Fat (embedded) | human's local checkout (pristine or dirty) | local | local, in-process | local, embedded (CLI owns redb lock) | human CLI | ✅ |
2. Server-monitored (nornir user) |
member git URLs, cloned by the server | server (clones member remote, into the <root>/<name>/ cache dir) |
server, under its write lock | server-side, one per workspace | the server's poll loop invokes the same verbs | 🔜 |
| 3. Thin client | human's local working tree, pushed to the server | client (it owns the repo + creds) | server, on the pushed source | server-side, the client's own tenant warehouse | human CLI → server | 🔜 (today: sources must be pre-present, no source crosses the wire) |
Key identities:
- Fat and server-monitored read the same path —
<workspace_root>/<name>/(theconfig.rsrepo_dirconvention). Fat = a human put a checkout there; server-monitored = the server clonedremotethere. Downstream code is unchanged; only "how the checkout arrived" differs. - Server-monitored = the server playing thin client against itself, fed by pull instead of push. It is just another tenant, owned by the single server-user
nornir, one warehouse per monitored workspace. - Thin = source is pushed in; server-monitored = source is pulled in. Same publish path, two ingestion edges.
Source provenance, per mode
- Fat / thin may carry dirty sources (locally-edited, uncommitted). Facts are keyed by working-tree content, not just a git SHA.
- Server-monitored is always clean — it only ever sees a committed ref (
ref, defaultmain), because it reaches the source bygit fetch. There is no "dirty" state the server can observe. This is the safety property that makes auto-publish sane: the server only ever republishes committed history.
Verb × mode test matrix
Every verb must pass in every applicable mode. n/a = the verb is meaningless in that mode by design.
| Verb | Fat | Server-monitored | Thin (client→server) | Notes |
repos |
✅ | 🔜 | ✅ | config-only; works even when warehouse is locked |
guard {status,apply,verify,release} |
✅ | 🔜 | ✅ | guard paths are workspace-relative on the server's fs in client modes |
release gate {…,all} |
✅ | 🔜 | ✅ | gates compute on the source's host (server in modes 2/3) |
release publish / tag |
✅ local | 🔜 server (needs push creds) | ✅ client does the gitx; gates ran server-side | the privileged credential surface |
bench run |
✅ | 🔜 client-submits | ✅ | always client/local hardware; ships BenchRun via Bench.Submit |
index {build,stats,search,snapshot,upload} |
✅ | 🔜 server build-by-SHA | partial: upload ✅ |
producers write a warehouse → today only embedded or via Index.UploadSnapshot |
vector {index,search} |
✅ | 🔜 | 🔜 | needs embedder feature; producer, same constraint as index |
knowledge {scan,query} |
✅ | 🔜 | query ✅ / scan 🔜 (CLI) · viz map ✅ | scan is a producer; query reads the built warehouse. The viz 🗺 Knowledge map reads a server-side scan over Viz.Knowledge (server scans its git/<member> checkouts), so the map is non-empty thin even though a client-triggered knowledge scan producer RPC is still 🔜 |
introspect {symbols,lookup,defined-in,callers,callees,path} |
✅ | 🔜 | ✅ | binary/source paths resolve on the server's fs in client modes |
docs {init,render,check,history} |
✅ | 🔜 | ✅ | rendered on the server's fs in client modes |
map <repo> |
✅ | 🔜 | 🔜 | the whole index pipeline; producer |
warehouse … |
✅ | 🔜 | n/a | low-level local store |
funnel … |
✅ | ✅ | ✅ | planning DAG; not source-dependent |
workspace {ls,show,register,rm,fetch} |
✅ embedded | ✅ (fetch HTTPS+SSH) |
✅ gRPC | registry at <root>/registry.redb; Workspaces.* RPC routes the verb to the server when NORNIR_SERVER is set; fetch clones members pure-Rust over HTTPS or SSH (russh + gix, deploy key) |
key {show,path,generate} |
✅ | ✅ | n/a | the server's git SSH identity |
serve / install add |
n/a | ✅ | n/a | the server itself |
Fat ↔ Thin parity — live source audit (2026-06-16, 0.4.44)
The table above is the design intent; this is the current code truth, audited from src/bin/nornir.rs dispatch (server_target() → *_remote). Law: thin and fat must be identical in ~99% of cases. Markers below flag the rare, justified exceptions — GAP(thin) = works fat, thin path not built yet; GAP(fat) = intentionally thin-only with a fat alternative. The goal is to make both shine, not pick a winner.
- Parity ✅ (both fat + thin, same data):
repos,guard,release gate-all,bench run,test run/all,docs {init,render,check,history,export},introspect {symbols,lookup,defined-in,callers,callees,path},index {search,stats,snapshot,upload},knowledge query,mimir {deps-of, dependents-of,affected,build-order,dep-path,external-users,svg,overview},diagram,workspace {ls,show,register,add,populate,rm,fetch}.workspace populateworks in BOTH (fat builds inline; thin clones + builds in background) — the reported "fat can't populate" was a docs gap, not a code gap (the fat recipe + the missingnornir-workspace.fat.tomlfor nordisk). GAP(thin)— producers (run fat, client-triggered build RPC still 🔜):knowledge scan,index build/restore,vector {index,search},map,arch generate. These WRITE a warehouse, so they can't target a running server's locked warehouse from a client — they run where the source is (fat in-process; thin needsIndex.Build/Vector.Index/Knowledge.Scan@sha RPCs).GAP(fat)— intentionally thin-only, with a fat alternative:index upload→ fatbail!s, useindex snapshot;mimir changed→ fatbail!s, userelease trace/impacted-crates.- Fat-only by design (local warehouse reads / privileged / local hardware):
release {trace,events,publish,changelog,impacted-crates,bump-version,…},test {history,coverage},warehouse,bakeoff,security,key,install.
Enforcement: AUT4 (thin==fat parity assertion) + the surface_coverage gate make this provable going forward; the 🏛 Architecture tab visualizes per-marker coverage.
The rule the matrix enforces: producers (index build, vector index, knowledge scan, map, dwarf persist) write a warehouse, so they cannot target a running server's locked warehouse from the client. They run where the source is. Mode 2 (server-monitored) already does server-side production: the poll loop clones each member (HTTPS/SSH) and runs knowledge scan + index + republish itself. What's still 🔜 is exposing that on demand for mode 3 — client-triggered build <repo>@<sha> producer RPCs (Index.Build / Vector.Index / Knowledge.Scan / Introspect.Persist) so a thin client can request any SHA. Bench stays client-side forever (it needs real hardware).
Self-sync (mode 2) — the loop
The single server-user nornir may monitor many workspaces. Per workspace:
- Seed: registered with a descriptor — a
nornir-workspace.tomlpath or a git URL.nornir workspace register <name> --descriptor … --monitored. ✅ - Fetch:
nornir workspace fetch <name>— clone each git member into<root>/<name>/git/<member>/, else fetch; compare HEAD to the registry'slast_seen_sha; write backlast_synced/sync_state; count changes. ✅ (gitio::clone_or_fetch, pure-Rust: HTTPS via gix+rustls, SSH viarusshrunninggit-upload-pack+ gix pack transfer with the deploy key — nogit/sshsubprocess. See "Git transport" in design.md.) - Poll loop ✅: a tokio interval task in
nornir-server(src/monitor.rs, spawned when the home-derived registry root<home>/.nornir/workspacesexists) calls the fetch primitive for every monitored workspace each tick (the monitor loop ticks at a fixed 60s default; per-workspace cadence is the registry row'spollfield); fetches run on a blocking thread so gix I/O never stalls the runtime. (SSH polling uses cheapls-remotechange-detection — HEAD only, no pack — and pulls a pack only on a HEAD change; the HTTPS path fetches each tick.) - Republish on change ✅: when any member's HEAD moved, rebuild the workspace warehouse — knowledge scan + member-scoped code index + per-member Iceberg snapshot + a cross-repo dep-graph snapshot (
cargo metadata --no-deps, offline) — all under<root>/<ws>/builds/, written through the server's served handle, and record the snapshot id on the registry row (monitor::republish_with). Verified end-to-end over HTTPS. (Cross-repo graph is empty for a single-repo workspace; rich for multi-repo. The dep-graph step needscargoon the server's PATH; if absent it is skipped with a log line and the rest of the republish still proceeds. Verified over HTTPS and SSH.) - Recompute incrementally 🔜:
affected_by_change(Mímir) → rebuild only the moved subset, not the whole workspace index. Today it rebuilds all members. - Coalesce / atomic pointer-swap 🔜: one republish per tick already (the sweep is sequential); a Ragnar snapshot-pointer flip with rollback to last-good is the remaining atomicity refinement.
- Isolate failure ✅ (per-member): one dead/auth-failing remote doesn't stall the run — its error lands in that member's
sync_stateand the others proceed. Backoff + staleness surfacing inrepos/workspace lsis 🔜.
Server on-disk layout
The nornir server has one root dir holding one subdir per workspace:
<home>/.nornir/workspaces/ # the registry root (server user: /home/nornir/.nornir/workspaces)
<workspace-a>/
git/ # cloned member checkouts (the workspace_root)
builds/ # warehouse + build artifacts
<workspace-b>/
git/ # MAY BE EMPTY — sources live outside (fat-style
builds/ # external checkout); only builds/ is server-owned
git/ is the per-workspace workspace_root the existing config.rs repo_dir convention resolves against, so members land at git/<member>/. When a workspace sources from outside (the fat case pointed at an external tree), git/ stays empty and only builds/ is populated. One server-user nornir owns many such workspaces.
Workspace registry
The server tracks its workspaces in one redb file at <home>/.nornir/workspaces/registry.redb (the nornir::registry module). redb stores bytes, not structs, so each row is workspace_name → serde_json(Workspace). A row records: mode (monitored | pushed | external), the seed descriptor (local path or git URL to the nornir-workspace.toml), poll, the current Iceberg snapshot id, and per-member {remote, ref, last_seen_sha, last_synced, sync_state} — exactly what the poll loop writes and what repos/viz read. The server is the sole writer (same single-writer discipline as the warehouse).
CLI (the registry root is home-derived — <home>/.nornir/workspaces; nornir root prints <home>/.nornir. To relocate, change the running user's $HOME):
nornir workspace ls # name · mode · members · last synced
nornir workspace show <name> # the full record (JSON)
nornir workspace register <name> --descriptor <path|git-url> [--monitored|--external] [--poll 60s]
nornir workspace rm <name>
Registering with a local descriptor seeds members from it (each repo's git URL + branch → remote/ref) and stores the descriptor's raw TOML content in the registry row (descriptor_content) — there is no descriptors/*.toml sibling file; the row is self-contained, so a re-add re-populates from its own git URLs. A git-URL descriptor stays member-empty until first fetch. The Workspaces.{List,Get,Register,Remove,Fetch} gRPC service (src/bin/nornir-server.rs) sits over this same registry, so every workspace verb works both embedded (fat, own warehouse) and remote (NORNIR_SERVER set) — the same dual path as every other verb. The server and its poll loop share one Registry handle (redb is single-writer). For live viewing, the server also serves each monitored workspace's builds/ warehouse as a gRPC workspace and republishes through that served handle, so the nornir-ui viz reads it live: it sends nornir-workspace: <name> (from the top-bar workspace picker, populated by Workspaces.List) on Viz.Timeline / Viz.{ReleaseEvents,BakeoffResults,Knowledge} / Warehouse.{Tables,Scan} / Funnel.Show and auto-refreshes (the live toggle, ~5s) — switching the picker re-scopes every tab live. The per-tab data plane (which tab → which RPC → which warehouse table) is documented in viz-remote-rpcs.md. The server seeds no workspaces at boot — it has no config ingress (only --token + --addr); every workspace is added at runtime via nornir workspace register (Workspaces.Register, an idempotent upsert) and re-served from the registry on the next boot. Worked example: workspace_njord/MONITOR-DEMO.md.
Config — one job per file (no overlap)
Source, monitored-ness, and recipes live in three distinct places, all keyed by member name (see design.md §"Two config files, one job each"):
# nornir-workspace.toml — SOURCE (authoritative for monitored fetch)
[workspace]
name = "holger-suite"
[repos.holger]
git = "https://codeberg.org/nordisk/holger"
branch = "main" # the tracked ref
# nornir.toml — RECIPES only (gates / bench / publish_order)
[repo.holger]
remote = "https://codeberg.org/nordisk/holger" # doc-link metadata only
# gates / bench / publish_order …
Monitored-ness + poll + sync-state live in the registry row, not a toml (nornir workspace register <name> --descriptor <nornir-workspace.toml|git-url> --monitored --poll 60s). Server root is server-global and home-derived: <home>/.nornir/workspaces (for the systemd nornir user, whose HOME is /home/nornir, that's /home/nornir/.nornir/workspaces).
Registering a monitored workspace is privileged (it makes the server fetch arbitrary URLs with credentials — SSRF/credential surface); registering a pushed one is open.
Git access — SSH identity
Server-monitored fetch/push authenticates with an ed25519 keypair the server owns (generated in pure Rust — no ssh-keygen subprocess). nornir install systemd creates it at /home/nornir/.ssh/id_ed25519 (mode 600, owned by the nornir user), idempotent and preserved across re-installs like the bearer token. Register the public half as a deploy key on each monitored repo:
nornir key show # prints the public key to paste as a deploy key
nornir key path # path to the public key
nornir key generate # create one for non-systemd/dev (writes ~/.nornir/ssh, never ~/.ssh)
Transport reality (today):
- HTTPS — full clone/fetch, pure-Rust (
gix+rustls). - SSH (
src/ssh.rs, pure-Rustrussh, nosshsubprocess) — full clone/fetch:ls-remotefor cheap change-detection, thengit-upload-packpack transfer driven by gix over the russh channel (ed25519 deploy-key auth, protocol v2), and the worktree materialized from the fetched tree. Verified live against private repos (njord/holger over SSH). Onlygit-receive-pack(push) is still pending.
So the ed25519 deploy key now drives SSH clone/fetch for private repos; its one remaining consumer is release push (git-receive-pack) — gated on the open decision of whether nornir may publish at all. Read-only deploy keys suffice for clone/fetch.