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

nordisk/nornir

.nornir/mode-matrix.md

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:

Source provenance, per mode

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.

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:

  1. Seed: registered with a descriptor — a nornir-workspace.toml path or a git URL. nornir workspace register <name> --descriptor … --monitored. ✅
  2. Fetch: nornir workspace fetch <name> — clone each git member into <root>/<name>/git/<member>/, else fetch; compare HEAD to the registry's last_seen_sha; write back last_synced/sync_state; count changes. ✅ (gitio::clone_or_fetch, pure-Rust: HTTPS via gix+rustls, SSH via russh running git-upload-pack + gix pack transfer with the deploy key — no git/ssh subprocess. See "Git transport" in design.md.)
  3. Poll loop ✅: a tokio interval task in nornir-server (src/monitor.rs, spawned when the home-derived registry root <home>/.nornir/workspaces exists) 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's poll field); fetches run on a blocking thread so gix I/O never stalls the runtime. (SSH polling uses cheap ls-remote change-detection — HEAD only, no pack — and pulls a pack only on a HEAD change; the HTTPS path fetches each tick.)
  4. 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 needs cargo on 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.)
  5. Recompute incrementally 🔜: affected_by_change (Mímir) → rebuild only the moved subset, not the whole workspace index. Today it rebuilds all members.
  6. 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.
  7. Isolate failure ✅ (per-member): one dead/auth-failing remote doesn't stall the run — its error lands in that member's sync_state and the others proceed. Backoff + staleness surfacing in repos/workspace ls is 🔜.

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

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.