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

nordisk/dwarves

.nornir/dwarves-parity-spec.md

dwarves — the parity spec (server · thin/fat client · UI · funnel)

What this is. The concrete, buildable spec that turned dwarves from "a forge library + a stub CLI + a demo window" into a first-class nordisk app at parity with nornir. As of 2026-07-13 the server core has LANDED: its own dwarf.v1 gRPC server (D1 ✅), a thin/fat client (D2 ✅), the shared jera ledger (D3 ✅), its own repo (D4 ✅), and the skidbladnir systemd seam (L1 ✅ for serve/daemonize; the deploy/rollback RPCs remain open). The live funnel is wired for read and write. Remaining: promote the demo window into the real UI, and the L1 lifecycle RPCs. It consolidates the decisions already filed and makes them actionable:

Naming: the project is dwarves everywhere (crate, lib, dwarves command, repo, docs). Only the physical host is still called dwarfs. DWARF debug-format code (Dwarf* gRPC service in nornir) is unrelated — never touch it here.

Suite map — shared framework, nornir menu inventory, and the edda extraction

The suite (JetBrains-style apps, not libraries)

nornir · dwarves · holger · korp · ordning — five apps, each with a CLI and an egui UI (native + wasm). facett is the shared UI platform (not an app). Everything else (skade, znippy, znippy-zoomies, tunnr, draupnir, edda, modgunn, Skidbladnir, knut) is library/infra. Njord ⛔ off-limits.

What the suite apps MUST share (the "JetBrains common framework")

One crate per concern — never re-implemented per app:

Concern THE crate / seam note
thin/fat client --server/--token/--workspace; fat=in-proc, thin=gRPC copy nornir/.nornir/cli-client-modes.md verbatim
service + systemd lifecycle skidbladnir (ServiceSpec/Lifecycle) no hand-rolled daemon
jobs (process/VM/container), multi-machine jera (edda) — jobs cross machines over gRPC (worker groups) shared by nornir+dwarves+holger
job-list pane facett-jobview Windows-Task-Manager-style; agent-aware
About pane shared version/git-sha/host/endpoint
Tools menu demos launched as jera jobs (bidirectional kill)
CLI mirrors UI every UI action has a CLI verb (text-UI) same gRPC underneath
cross-app panes facett-<guest> component + <guest>-embed adapter two surface modes below

Cross-app panes — the two surface modes (the structure you described)

An app can show/drive another app's functionality in a pane, either compiled-in or over the wire — this goes both ways:

Host → guest crate surface mode
nornir hosts holger nornir/src/holger_embed.rs compiled-in / direct Rust (in-process, read_registry — no gRPC server started)
dwarves hosts nornir's funnel dwarves/crates/nornir-funnel-embed thin gRPC (Funnel.Show → reconstruct client-side)

Rule: facett-<guest> holds the pane's Elm/Facet body once; the <guest>-embed adapter wires data either direct (fat) or gRPC (thin). CLI verbs ride the same seam.

⚠️ nornir is being ripped into nordisk/edda (dedup — first-class fact)

Functionality is actively moving OUT of nornir into the shared edda crate collection so all apps consume one copy. edda crates today: jera · nornir-testmatrix · nornir-robotui · nornir-bench · autonom · nornir-coverage · nornir-git · nornir-cli-outcome · nornir-saga. Left in nornir: nornir-build-thing · nornir-guard · nornir-introspect · nornir-jobs (the internal ledger — superseded by edda jera; dwarves uses jera) · nornir-selftest · nornir-viz-web. Implication for dwarves: depend on the edda crates, never on nornir-internal twins.

nornir viz — top-level tabs (the "menus") + ownership

nornir's UI is a flat tab bar; the tabs ARE the top menus. Ownership: nornir (warehouse/introspection) · ⚒ dwarves (CI/CD forge) · ⇄ shared/cross-app · edda-backed (engine moved to edda).

Tab Owner Cross-app / note
Nornir (root/server ops) ⇄ shared workspace + server lifecycle → shared framework
Timeline · DepGraph · CallGraph · Architecture · Metro · Helix · Bloodstream nornir warehouse/introspection views
Funnel ⇄ shared already embedded in dwarves (nornir-funnel-embed, thin gRPC)
TimeTravel · Knowledge · Warehouse · WarehouseDeck · Search · Manual nornir
LiveRun · Release · Gates nornir (LiveRun/Release candidates to share w/ dwarves)
Bench · Test · Leaderboard edda-backed engines in edda (nornir-bench/nornir-testmatrix); tab stays nornir
Mcp nornir MCP server status
Security modgunn hosts modgunn VERDICT (cross-app candidate)
⚒ Dwarves (build + brokkr forge) ⚒ dwarves THIS is the pipeline — belongs to dwarves; nornir should embed it (thin gRPC to dwarves-server) once dwarves daemonizes
📦 Holger 📦 holger nornir hosts holger — compiled-in/direct (holger_embed.rs)
🤖 Claude ⇄ shared direct Claude prompt — needs facett-claude extraction to embed in dwarves
🧠 Models nornir/shared LLM/embedding model download (as jobs)

dwarves' three panes = ⚒ Pipeline (owns it) · Funnel (guest of nornir) · 🤖 Claude (shared, needs facett-claude). Small on purpose.

nornir keeps vs dwarves takes — the CI/CD partition

The principle. nornir = the warehouse — code · time · space, time-travelled: you read and understand what exists. dwarves = the CI/CD that ACTS on the warehouse — its runs produce warehouse rows, which nornir then shows. So partition every surface by read/understand (stays nornir) vs run/act (moves to dwarves).

One-code-place still holds (Law #1): a surface "moves" by dwarves owning it and nornir embedding the view via the <guest>-embed adapter (thin gRPC), not by copying code. And dwarves gets no warehouse of its own — it reads nornir's over thin gRPC, and writes its run results back into nornir's warehouse (bench_runs · test_results · release_events · brokkr_runs/steps).

⬛ STAY in nornir (warehouse / introspection / time-travel)

⚒ MOVE → dwarves (CI/CD run surfaces — the act)

⇄ SHARED / cross-embed (view in nornir, act in dwarves)

Super-graph name "Yggdrasil" is a (working name) — Rickard is not settled on it.

0. North star

dwarves is the constellation's TeamCity-in-Rust: a CI/CD forge you drive from a UI and a CLI, backed by a long-running server. It must feel like the same app family as nornir/holger/korp — the JetBrains-style shared framework (prompt §13): one lifecycle, one thin/fat rule, an About, a Tools menu whose demos launch as jera jobs, shared facett panels. No re-implementation: reuse the crate that already owns each concern.

Acyclicity (the load-bearing constraint). dwarves is a leaf that nornir depends on. So dwarves must never crate-depend on nornir. Every dwarves→nornir need (e.g. reading/submitting the funnel) is a runtime gRPC call, compiling only a trimmed proto — exactly the pattern nornir-funnel-embed already proves. Runtime gRPC ≠ compile cycle.

1. Current state (2026-07-13, post-rename)

Piece State
brokkr forge (descriptor→DAG→plan→engine, 5 Elm facets) ✅ in crates/dwarves/src/brokkr/
sindri scheduler (tick: cron→poll→drain→dispatch; serve sleep-loop) ✅ src/sindri.rs
host backends (RealRunner/DemoRunner, action cache, incremental, JSONL store) ✅
job ledger ✅ via jera (edda), not a local twin
dwarves CLI ✅ real clap verbs — validate / plan / run / trigger / history / serve / agent / doctor / pipelines (fat + thin, --server)
gRPC server (dwarves-server) ✅ dwarf.v1 service (crates/dwarves/proto/dwarf.proto): Health / Show / History / RegisterPipeline / Trigger / Webhook / RunStatus / GetRunReport / Badge; serve is now the sindri loop and the tonic surface; bearer auth via shared nornir_service::auth_interceptor (constant-time). Handler-level tests cover the whole surface (server::rpc_handlers)
thin client (--server) ✅ every verb routes to dwarves-server over gRPC when --server <url> / $DWARVES_SERVER is set; --token→$DWARVES_SERVER_TOKEN→~/.dwarves/token; else FAT against a local brokkr.toml
systemd / lifecycle ✅ skidbladnir ServiceSpec + Lifecycle seam (dwarves serve daemonizes; durable redb-backed server-side cron); L1 deploy/rollback RPCs still open
Funnel panel ✅ nornir-funnel-embed — thin gRPC to nornir Funnel.Show (read) + Funnel.SubmitIdea (write); renders facett-plandag DAG, carries a submit strip (idea|error → grow the DAG); mounted in dwarves-gui (native + wasm)
CI results panel ✅ facett-brokkrview; neutral facett-cicd component exists
Jobs pane ✅ shared facett-jobview over a jera-shaped agent-aware fixture (dwarves-gui/src/jobs.rs, jobs-pane.md); robot+testmatrix in code; live-data pull tracked (D1/D3)
UI host ⚠️ dwarves-gui only (tabbed Forge | Funnel | Claude | Jobs on facett-wasm-demo); not the "real" app

2. Target architecture — the D/L points, made concrete

D1 — dwarf.proto + dwarves-server ✅ LANDED

A gRPC service mirroring nornir-server, exposing the sindri/brokkr core. Shipped surface: Health / Show / History / RegisterPipeline / Trigger / Webhook / RunStatus / GetRunReport / Badge / AgentPool (DrainStatus from the original wishlist remains open — see the backlog). Bearer auth is the shared constant-time nornir_service::auth_interceptor (no local != twin). Original target:

D2 — thin/fat dwarves CLI ✅ LANDED

Mirrors cli-client-modes.md:

D3 — shared fat/thin job ledger

dwarves already uses jera for CI_BUILD jobs. Mirror nornir's jobs.redb fat/thin split: FAT writes the local jobs.redb; THIN submits lifecycle records to dwarves-server and reads them back paginated. One ledger, one widget (facett-jobview) in the UI.

D4 — own repo ✅ DONE

codeberg.org/nordisk/dwarves; nornir path-deps the crate. Nothing else depends on dwarves (leaf).

L1 — lifecycle / systemd (shared, also missing in nornir)

Reuse skidbladnir (ServiceSpec + Lifecycle, already green) — no hand-rolled daemon/systemd. Add the lifecycle-RPC family to both servers:

Funnel — read → read/write (the SUPER-PRIO)

The funnel data lives in nornir's warehouse; dwarves is its client over gRPC (no cycle).

3. The shared framework (the "JetBrains common framework" law)

Every nordisk app — dwarves included — reuses ONE crate per concern (no duplicates, even thin). This is the consolidation law:

Concern THE crate dwarves adopts by
service + systemd lifecycle skidbladnir declaring a ServiceSpec, driving a Lifecycle (L1)
jobs (process/VM/container) + ledger jera (edda) already; route all real work + Tool-menu demos through it
boot / provision draupnir only via jera — never directly
UI panes facett-* component + <guest>-embed adapter facett-brokkrview/facett-cicd + nornir-funnel-embed (native, no wasm for cross-embed)

Plus the per-app UI conventions the whole fleet shares: thin/fat toggle, an About, a Tools menu whose demo entries start containers/VMs as jera jobs, and a job list (facett-jobview). wasm is the browser delivery target, not the cross-embed mechanism (wasm-ui-panels-design.md).

4. The CI/CD model — Rickard's headlines

Captured live. Each headline is a top-level product feature; the UI shell (§4b) and phasing (§5) follow from these.

H1 — Pipelines are Rust xtasks, the pipeline is a DAG, jobs run distributed on many agent machines

The identity: a build/test/release forge, driven from UI + CLI, backed by the server. Three commitments:

The "few good ones" borrowed (Rickard: pick from TeamCity / GitLab / GitHub):

Borrowed idea From In dwarves
Pipeline-as-code, no YAML/shell (all — inverted: they use YAML; we use Rust) the pipeline is a Rust xtask
DAG needs: — out-of-order, run the ready frontier GitLab CI, GHA steps declare upstream deps; brokkr::dag + sindri drain
Matrix fan-out over params GitHub Actions, TeamCity one step → target×feature×profile (BuildVariantSet)
Distributed agents + capability/tag matching TeamCity agent requirements · GitLab runner tags · GHA runner labels steps declare required caps; jera dispatches to a matching remote agent
Build chains — snapshot + artifact deps between pipelines TeamCity, GitLab artifacts cross-pipeline [[pipeline.depends]]; artifacts flow along DAG edges, content-addressed
Dynamic / generated pipelines GitLab parent-child dynamic the xtask emits the DAG at runtime
Content-addressed cache + incremental skip GHA / GitLab cache already: ActionCache + incremental (skip unchanged inputs)
Pre-tested / personal builds (green-gate before merge) TeamCity run the DAG on a worktree/branch before landing

Distributed agents + worker groups — the jera work this requires:

Rollout = LAZY (Rickard). Ship single-machine first (one local agent, in-process), but design the seam now so distributed-over-gRPC is a clean drop-in later. Distributed multi-machine execution is a tracked to-develop feature, not day-1 — it must not block the funnel/server/CLI work.

SHARED, not dwarves-only (Rickard). Multi-machine awareness is a common jera feature (in edda): nornir, dwarves, AND holger all get "jobs can run across multiple machines over gRPC" from the one jera crate — never re-implemented per app. So the Agent trait + GrpcAgent transport + worker-groups below live in jera, and each app consumes them (consolidation law).

H2 … (next headlines — Rickard, keep them coming)

4b. The UI / client shell (follows from the headlines)

Fleet-consistent shared-framework scaffold (adjusted as headlines land):

The three headline panes (Rickard): dwarves must carry Pipeline · Funnel · direct Claude prompt.

Pane Component State today
Pipeline (CI/CD) facett-brokkrview (results) + facett-brokkr (builder) over dwarves::brokkr ✅ mounted (dwarves-gui Forge tab)
Funnel (plan-DAG) nornir-funnel-embed → facett-plandag, thin gRPC to nornir Funnel.Show + Funnel.SubmitIdea ✅ mounted, read + write (submit strip: idea|error → DAG grows); LLM-decompose planner is server-side next
Claude prompt (direct chat) facett-claude — DOES NOT EXIST YET ⚠️ lives only in nornir/src/viz/claude_tab.rs (nornir-native ClaudeTab, not a facett component)

The one extraction to close it: pull the conversation pane out of nornir/src/viz/claude_tab.rs into a reusable facett-claude component (Elm/Facet body + state_json), mirroring how facett-brokkrview was extracted from the brokkr Elm core. The Anthropic client (ClaudeTransport, POST /v1/messages, claude-opus-4-8, $ANTHROPIC_API_KEY) is already reusable in nornir/src/viz/claude.rs (and dwarves already has a ClaudeTransport twin in brokkr/notify.rs). Once facett-claude exists, dwarves mounts three tabs: Pipeline | Funnel | Claude.

5. Phasing (each phase ends green: builds + one robot scenario + one testmatrix row, wire-once/verified-in-both-hosts)

  1. P1 — funnel write-path ✅ DONE (SUPER-PRIO): submit_idea (idea|error) added to nornir-funnel-embed over Funnel.SubmitIdea; a submit strip in the pane (kind combo + field + Submit/Enter); post-submit DAG refresh; offline-safe (clean error, draft kept). Robot scenario (funnel_submit_leg_drivable) + testmatrix (funnel_write_submit_idea_offline_safe) drive submit headless. (Remaining P1 tail: the History side-list — server-side already.)
  2. P2 — dwarf.proto + dwarves serve gRPC (D1): read surface first (Show/History/Health), then write (Trigger/RegisterPipeline).
  3. P3 — thin/fat CLI (D2 + D3): global flags, *_remote verbs, shared jobs.redb split.
  4. P4 — lifecycle (L1): skidbladnir ServiceSpec; server deploy/health/rollback.
  5. P5 — real UI app: promote the shell (§4) with Rickard's feature list; wasm build + Pages deploy at /dwarves.
  6. P6 — shared server scaffold: fold the interim server into the edda nornir-service crate; nornir consumes it too.

6. Laws (verbatim in every subagent prompt)

Planner / Executor split — Claude drives the plan-DAG

Rickard's decision: two SEPARATE Claude roles — one builds the plan-DAG, one drains it. Kept separate until Rickard says to unify.

Role Job Status Machinery
Planner Claude idea → plan-DAG ✅ DONE nornir funnel prompt --llm → funnel::decompose::{llm_decompose_prompt, decomposition_from_llm_reply} → funnel::run_prompt_with_decomposition
Executor Claude "sucks off" the grand plan-DAG and executes it ❌ NOT BUILT (next, gated on Rickard's go) reuses funnel::run_subtask_loop (below)

Executor loop (the drain)

  1. Ready frontier — Funnel::promote_ready() (funnel-core) promotes nodes whose deps are all Done.
  2. For each ready node → an executor agent (Claude) does that one node's task.
  3. Oracle checks it — the existing run_subtask_loop(task, sub, generator, judge, max_rounds) already runs generate → judge → retry with the compiler-as-oracle: AnswerJudge trait, CodegenJudge impl (apply patch → cargo build+test → accept/reject). The executor Claude simply replaces the mock Generator in that loop; the compiler-judge oracle stays. A separate verifier Claude can be layered on later.
  4. Record — Funnel.SetStatus RPC (thin) / NodeStatusChanged event (fat) → Done | Failed.
  5. Re-promote → repeat until the DAG drains (all Done) or blocks (Blocked/Failed) — never a silent stall (RAGNARÖK).

Why separate (until told otherwise)

Distribution

Each node execution = a jera job → executors fan out across worker machines (H1 distributed agents). One planner, N executors on the fleet.

Symbol note: the judge trait is AnswerJudge (CodegenJudge is its impl); all other cited symbols verified present (promote_ready, run_subtask_loop, Generator seam, SetStatus RPC, NodeStatusChanged event, the three planner fns).