nornir
nornir is a project-agnostic companion to cargo. It is a library
plus three thin presentation layers — a CLI (nornir), an MCP server
(nornir-mcp), and a gRPC server (nornir-server, tonic) — that
together carry a Rust workspace from "edited" to "published" along
three strict, declarative threads:
Pour features into the funnel. Skuld weaves them into the release DAG (the previous plan slides into Urðr's warehouse). Verðandi executes — and what ships becomes Urðr's past: the future, fulfilled.
The nornir are the three sisters who sit at the foot of Yggdrasil and spin the thread of every fate. Urðr remembers what has been, Verðandi watches what is, and Skuld weighs what shall be.
| Norn | Tense | What it owns in nornir |
|---|---|---|
| Urðr | past | the warehouse — bench history, prior release tags, regression baselines, archived plans |
| Verðandi | present | execution — guard locks, current build, the DAG step being run right now |
| Skuld | future | the release DAG — feature funnel, release gates, publish-order walker, generated docs |
Everything nornir does fits under one of these three.
Urðr — the past
The fate already woven. nornir treats history as immutable: append-only, read-only, never edited or reordered.
nornir::warehouse — the canonical store. One trait, two impls
behind it, chosen by [storage].kind in nornir.toml:
kind |
Impl | Layout |
|---|---|---|
local |
LocalWarehouse |
Parquet files + SQLite catalog under .nornir/warehouse/ |
iceberg |
IcebergWarehouse |
Apache Iceberg tables + redb catalog (catalog.redb) |
remote |
(Phase 5) | gRPC client of nornir-server (tonic), warehouse calls forwarded as RPCs |
Both file-backed impls write to the same .nornir/warehouse/ directory
and expose the same Warehouse trait — so callers, the CLI, and the
MCP server never know which one is underneath.
The Iceberg backend uses nornir-catalog — a sibling crate, single-file ACID catalog, 100% Rust, no SQL server, no JVM. Tables follow the Apache Iceberg spec: pyiceberg / Spark / DuckDB pointed at the same warehouse can read every row (once they speak the redb catalog protocol; for now it's nornir-only).
Three tables auto-created on first open, with stable Iceberg field IDs:
bench_runs— one row per benchmark invocation (run_id,repo,ts_micros,date,version,machine,cores).bench_results— long-format metrics (run_id,result_name,metric_name,metric_value).test_outcomes—(run_id, test_name, passed, duration_ms, message).
nornir::bench
BenchRun— one bench result:{ date, timestamp, version, machine, cores, results, tests }.assets::MavenAsset::ensure— deterministic Maven Central fetcher with on-disk cache (NORNIR_BENCH_CACHE), so the same JAR set is benchmarked everywhere.- Import path for legacy JSONL:
nornir warehouse import-jsonl.
nornir::release::regression (planned)
- Queries
warehouse.bench_runs+bench_results, compares the current run against the last N runs on the same machine, fails the release if throughput drops past a configured threshold.
A repository can have many machines and many tools in the same warehouse — Urðr does not insist on a single thread, only that every thread is append-only.
Verðandi — the present
What is, right now, in the working tree. nornir clamps the present shut while a release is in flight so that AI agents, scripts, or stray fingers cannot mutate the things a release depends on.
nornir::guard
[guard].forbiddeninnornir.tomllists paths that must never be edited by anyone other than the human (bench histories,SPEC.md,CHANGELOG.md,nornir.tomlitself).guard::apply—chmod -wevery existing forbidden path.guard::release— restore write permissions.guard::status— report exists / writable / changed-from-baseline for each path. Drives bothnornir guard statusand the MCPguard_statustool.
nornir::config
- Loads
workspace_holger/release/nornir.toml(autodiscovered by walking up from cwd, or specified with--config). - One config carries
[guard]+ every[repo.<name>]recipe. - The workspace root is "the directory containing the config's parent"; all paths are resolved relative to it.
nornir::introspect (present-tense observation of the built code)
depgraph— real, uses thecargo_metadatacrate (pure-Rust API, no manual subprocess). Rendered as a static SVG via the typst engine (seedocs::svg) into.nornir/cache/svg/depgraph.svgand linked from the README — zero JavaScript, works in any markdown viewer or PDF export.callgraph,api— stubs; will usesynand rustdoc-JSON respectively. Output feeds into Skuld's generated docs so the README cannot drift from the code it describes.
Skuld — what shall be
The thread not yet woven. Skuld decides whether a release may proceed and, if so, in what order.
nornir::release::gate
no_path_patches— fail ifCargo.tomlhas anypath = "..."dep overrides in[patch.*]or top-level dependency tables.nexus_floor— fail if the version about to be published is ≤ the highest version already on the configured registry.no_regression— see Urðr above.integration_roundtrip— fail if the consumer's roundtrip tests do not pass. nornir invokescargo test --test roundtrip_<kind> --releaseper label in[repo.<name>].gates.integration_roundtrip. Consumer repos define each as a regular Rust#[test]intests/roundtrip_<kind>.rs— labels are opaque to nornir.
nornir::release::publish
- Reads
[repo.<name>].publish_order: Vec<Vec<String>>— outer vec is sequential phases, inner vec is crates safe to publish in parallel within a phase. Walks the order, invokescargo publish -p <crate>per leaf (the one allowed cargo subprocess — cargo owns crate packaging+upload). release::tag— creates an annotatedvX.Y.Ztag locally via the pure-Rustgixcrate (nogitshellout) and prints the exactgit push origin <tag>invocation. Network push via gix is a follow-up so we don't pullreqwest+tokiojust for tagging.
nornir::docs
render_readme/append_changelog/assemble_and_check.- Sections enclosed by
<!-- nornir:generated -->markers are owned by nornir; humans edit around them, nornir overwrites within them. - Pulls structured input from
nornir::introspectso generated dep graphs / API tables stay in sync with the source code that will be published.
Surface
Two binaries, same library underneath:
nornir— human-facing CLI. Subcommands:repos,guard {status,apply,release},bench history-show <repo>,release {gate-path-patches,publish} <repo>,docs check <repo>,introspect depgraph <repo>.nornir-mcp(featuremcp) — Model Context Protocol server over stdio. Each library API is registered as an MCP tool (repos_list,guard_status,guard_apply,guard_release,bench_history,release_gate_path_patches, …) so an LLM agent can drive the same pipeline a human would.
Build the CLI with cargo build. Build both with
cargo build --features mcp.
User manual
The .nornir/ directory
Every repo nornir manages has a .nornir/ directory at its root. It is
the source of truth for any file nornir generates. The contract is:
your-project/
├── .nornir/
│ ├── README.md ← SOURCE — you edit this
│ ├── CHANGELOG.md ← SOURCE — you edit this
│ ├── warehouse/ ← bench history (Parquet+SQLite or Iceberg+redb) and historical doc exports
│ ├── cache/ ← image cache, transient fetches (git-ignored)
│ └── .gitignore
├── README.md ← GENERATED — DO NOT EDIT (chmod -w when guard is active)
├── CHANGELOG.md ← GENERATED — DO NOT EDIT
└── ...
The artifact files (README.md, CHANGELOG.md at the repo root) are
re-rendered from .nornir/<file> on every nornir docs render. Each
artifact gets a loud header prepended so anyone opening it knows where
the source lives:
Section markers
Inside any source file, dynamic content is delimited by paired comments:
Available renderers:
| Marker name | Content filled in |
|---|---|
bench |
Throughput / latency table from the latest BenchRun for this repo |
depgraph |
Mermaid graph from cargo metadata (workspace deps) |
symbols |
Public symbols extracted from a built binary (DWARF), demangled |
callgraph |
Most-called callees from a built binary (DWARF inline + LLVM direct) |
Anything outside the markers is preserved verbatim. Anything inside is overwritten on every render — never edit between the markers; the change will be lost.
Daily workflow
# One time per project — scaffolds .nornir/ and migrates an existing
# README.md into it as the source. Lossless: re-running is a no-op.
# After editing .nornir/README.md, regenerate the artifact at repo root.
# CI gate — fails on any drift between source and artifact.
# Bundle the whole README into PDF / HTML (typst engine, pure Rust).
# Auto-runs `render` first so the export is always up-to-date.
How images are handled
The PDF/HTML exporter is fully offline-capable. When a source markdown
references a remote image ():
- The first run downloads it with
ureq(rustls, pure Rust) into.nornir/cache/images/<hash(url)>.<ext>. TheAcceptheader asks for WebP first, then falls back to PNG/JPEG. - If the response is a PNG or GIF and a WebP re-encode would be
smaller, it is transcoded losslessly via the
imagecrate (pure Rust — nolibwebp, no C deps). - Subsequent runs read from the cache. Add
.nornir/cache/to.gitignore(the scaffold does this for you).
JPEG photos are kept as JPEG: the pure-Rust WebP encoder is lossless-only, so re-encoding a photo would inflate the file. The day a pure-Rust lossy WebP encoder ships, this layer will pick it up transparently.
Generated artifacts are read-only
nornir guard apply runs chmod -w on every artifact listed in
[guard].forbidden. The renderer is aware: when it goes to rewrite a
locked file, it clears the read-only bit, writes atomically (tmp +
rename), then restores the bit. Humans get a Permission denied if
they try to edit by hand. Run nornir guard release before doing git
operations that touch those files.
Optional features
| Feature | Provides |
|---|---|
fixtures-maven |
Canonical Maven Central JAR set (guava, ecj, scala, kotlin) as bench inputs |
mcp |
Builds the nornir-mcp binary (rmcp, tokio, schemars) |
server |
Builds the nornir-server binary — gRPC server (tonic, tokio) mirroring every CLI/MCP tool one-for-one |
nornir-server — thin remote layer
Built with --features server. A gRPC server (tonic) that mirrors
every CLI/MCP tool one-for-one as a unary RPC; no business logic lives
in the server. The traffic profile is overwhelmingly control RPCs
(gate, build, publish, guard, status); bench-history reads return a
few KB at a time. Plain protobuf carries it all — no Arrow Flight,
no JSON. Required env:
# ≥ 16 chars
# default
Every RPC must carry authorization: Bearer $NORNIR_SERVER_TOKEN in
its gRPC metadata. A single health RPC is the only unauthenticated
entry point.
Auth roadmap
-
Today: single shared bearer token in
NORNIR_SERVER_TOKEN. Fine for a workstation-private constellation; not for multi-tenant use. -
Planned: mutual TLS with the client certificate's
CN=<username>mapped to a per-user ACL declared in nornir.toml, e.g.# [server] # bind = "0.0.0.0:7878" # tls_cert = "/etc/nornir/server.crt" # tls_key = "/etc/nornir/server.key" # client_ca = "/etc/nornir/clients-ca.crt" # # [server.acl] # # CN of the client cert → list of allowed route prefixes # rickard = ["*"] # ci-bot = ["/release/", "/docs/check-sections", "/search"] # readonly = ["/healthz", "/repos/list", "/search", "/bench/history", # "/introspect/", "/release/gate/"]Until the mTLS layer lands, the bearer token is the only line of defence — keep it in a secret manager and rotate.
Live snapshot
The blocks below are filled in by nornir docs render from this
repo's own bench history and built binary. If you see empty markers,
it just means a release-mode binary hasn't been built yet on this
machine.
Dependency graph
Hot call edges (own binary)
187 inline edges in target/debug/nornir; top 25 callees:
| callee | inline sites |
|---|---|
::default |
22 |
::index |
19 |
core::hint::must_use |
17 |
core::slice::index::::index |
16 |
core::num::::checked_sub |
12 |
alloc::boxed::Box::into_raw_with_allocator |
10 |
alloc::boxed::Box::new_uninit |
10 |
alloc::boxed::box_assume_init_into_vec_unsafe |
10 |
alloc::raw_vec::new_cap |
10 |
alloc::slice::::into_vec |
10 |
core::ptr::mut_ptr::::cast |
10 |
::from |
6 |
::saturating_sub |
3 |
core::ptr::mut_ptr::::add |
3 |
::len |
2 |
alloc::boxed::Box::new |
2 |
core::array::::try_from |
2 |
core::ptr::const_ptr::::offset_from_unsigned |
2 |
core::ptr::mut_ptr::::offset_from_unsigned |
2 |
tantivy_stacker::fastcmp::double_check_trick |
2 |
::as_bytes |
1 |
::branch |
1 |
::from_residual |
1 |
::next |
1 |
::next::{{closure}} |
1 |
Top public symbols (own binary)
top 20 symbols by size from target/debug/nornir
| symbol | size | file |
|---|---|---|
<parquet::format::ColumnMetaData as parquet::thrift::TSerializable>::write_to_out_protocol |
16955 | nornir/src/lib.rs |
<parquet::format::LogicalType as parquet::thrift::TSerializable>::write_to_out_protocol |
12383 | nornir/src/lib.rs |
<parquet::format::FileMetaData as parquet::thrift::TSerializable>::write_to_out_protocol |
11143 | nornir/src/lib.rs |
<parquet::format::ColumnIndex as parquet::thrift::TSerializable>::write_to_out_protocol |
10795 | nornir/src/lib.rs |
nornir::docs::export::md_to_typst_with |
10642 | nornir/src/lib.rs |
<parquet::format::SchemaElement as parquet::thrift::TSerializable>::write_to_out_protocol |
8922 | nornir/src/lib.rs |
<parquet::format::ColumnChunk as parquet::thrift::TSerializable>::write_to_out_protocol |
8252 | nornir/src/lib.rs |
<parquet::format::Statistics as parquet::thrift::TSerializable>::write_to_out_protocol |
7789 | nornir/src/lib.rs |
<parquet::format::RowGroup as parquet::thrift::TSerializable>::write_to_out_protocol |
7779 | nornir/src/lib.rs |
<nornir::config::_::<impl serde_core::de::Deserialize for nornir::config::Repo>::deserialize::__Visitor as serde_core::de::Visitor>::visit_map |
7347 | nornir/src/lib.rs |
<nornir::config::_::<impl serde_core::de::Deserialize for nornir::config::Repo>::deserialize::__Visitor as serde_core::de::Visitor>::visit_map |
7347 | nornir/src/lib.rs |
<nornir::config::_::<impl serde_core::de::Deserialize for nornir::config::Repo>::deserialize::__Visitor as serde_core::de::Visitor>::visit_map |
7306 | nornir/src/lib.rs |
<nornir::bench::_::<impl serde_core::de::Deserialize for nornir::bench::BenchRun>::deserialize::__Visitor as serde_core::de::Visitor>::visit_map |
7091 | nornir/src/lib.rs |
<nornir::warehouse::local::LocalWarehouse as nornir::warehouse::Warehouse>::query_bench_runs |
5343 | nornir/src/lib.rs |
nornir::warehouse::local::build_bench_runs_batch |
4952 | nornir/src/lib.rs |
<parquet::format::SizeStatistics as parquet::thrift::TSerializable>::write_to_out_protocol |
4534 | nornir/src/lib.rs |
<nornir::config::_::<impl serde_core::de::Deserialize for nornir::config::Gates>::deserialize::__Visitor as serde_core::de::Visitor>::visit_map |
4496 | nornir/src/lib.rs |
<nornir::config::_::<impl serde_core::de::Deserialize for nornir::config::Gates>::deserialize::__Visitor as serde_core::de::Visitor>::visit_map |
4496 | nornir/src/lib.rs |
<nornir::config::_::<impl serde_core::de::Deserialize for nornir::config::Gates>::deserialize::__Visitor as serde_core::de::Visitor>::visit_map |
4458 | nornir/src/lib.rs |
nornir::introspect::artifact::extract_one |
4319 | nornir/src/lib.rs |
Design rules
- No project assumptions in the library. Everything specific
to your workspace lives in
nornir.toml. - Three threads, never crossed. Urðr is read/append only. Verðandi observes and clamps, never publishes. Skuld decides what shall be, never edits what already was.
- Same library, two presentations.
nornirandnornir-mcpare siblings; neither owns logic the other doesn't. - No shellout, no JavaScript, no C deps. 100% Rust everywhere.
HTTP is
ureq(rustls); git isgix; cargo metadata is thecargo_metadatacrate; image transcode is theimagecrate; PDF/HTML rendering istypstas a library. The two intentional exceptions arecargo publish(cargo owns crate packaging+upload) andcargo test --test roundtrip_<kind>(the consumer's own test runner). Both are isolated, single-call sites, and clearly annotated. No bash scripts, nocurl/git/make/just.
Status
Early but working: guard, config, bench history, depgraph, the CLI
and MCP server are real. Several Skuld gates and the introspect
stubs (api, callgraph) are planned next.