nornir 0.1.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation

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].forbidden in nornir.toml lists paths that must never be edited by anyone other than the human (bench histories, SPEC.md, CHANGELOG.md, nornir.toml itself).
  • guard::applychmod -w every existing forbidden path.
  • guard::release — restore write permissions.
  • guard::status — report exists / writable / changed-from-baseline for each path. Drives both nornir guard status and the MCP guard_status tool.

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 the cargo_metadata crate (pure-Rust API, no manual subprocess). Rendered as a static SVG via the typst engine (see docs::svg) into .nornir/cache/svg/depgraph.svg and linked from the README — zero JavaScript, works in any markdown viewer or PDF export.
  • callgraph, api — stubs; will use syn and 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 if Cargo.toml has any path = "..." 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 invokes cargo test --test roundtrip_<kind> --release per label in [repo.<name>].gates.integration_roundtrip. Consumer repos define each as a regular Rust #[test] in tests/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, invokes cargo publish -p <crate> per leaf (the one allowed cargo subprocess — cargo owns crate packaging+upload).
  • release::tag — creates an annotated vX.Y.Z tag locally via the pure-Rust gix crate (no git shellout) and prints the exact git push origin <tag> invocation. Network push via gix is a follow-up so we don't pull reqwest+tokio just 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::introspect so 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 (feature mcp) — 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:

<!-- ⚠ GENERATED by nornir from .nornir/README.md — DO NOT EDIT this file -->

Section markers

Inside any source file, dynamic content is delimited by paired comments:

## Benchmarks

<!-- nornir:gen:start:bench -->
<!-- nornir:gen:end:bench -->

## Dependency graph

<!-- nornir:gen:start:depgraph -->
<!-- nornir:gen:end:depgraph -->

## Public symbols

<!-- nornir:gen:start:symbols binary=target/release/myapp limit=50 -->
<!-- nornir:gen:end:symbols -->

## Hot call edges

<!-- nornir:gen:start:callgraph binary=target/release/myapp limit=30 -->
<!-- nornir:gen:end:callgraph -->

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.
nornir docs init <repo>

# After editing .nornir/README.md, regenerate the artifact at repo root.
nornir docs render <repo>

# CI gate — fails on any drift between source and artifact.
nornir docs check <repo>

# Bundle the whole README into PDF / HTML (typst engine, pure Rust).
# Auto-runs `render` first so the export is always up-to-date.
nornir docs export <repo> --format pdf
nornir docs export <repo> --format html --out site/index.html

How images are handled

The PDF/HTML exporter is fully offline-capable. When a source markdown references a remote image (![alt](https://...)):

  1. The first run downloads it with ureq (rustls, pure Rust) into .nornir/cache/images/<hash(url)>.<ext>. The Accept header asks for WebP first, then falls back to PNG/JPEG.
  2. If the response is a PNG or GIF and a WebP re-encode would be smaller, it is transcoded losslessly via the image crate (pure Rust — no libwebp, no C deps).
  3. 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:

export NORNIR_SERVER_TOKEN=$(openssl rand -hex 32)   # ≥ 16 chars
export NORNIR_SERVER_ADDR=127.0.0.1:7878             # default
nornir-server

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

workspace 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. nornir and nornir-mcp are siblings; neither owns logic the other doesn't.
  • No shellout, no JavaScript, no C deps. 100% Rust everywhere. HTTP is ureq (rustls); git is gix; cargo metadata is the cargo_metadata crate; image transcode is the image crate; PDF/HTML rendering is typst as a library. The two intentional exceptions are cargo publish (cargo owns crate packaging+upload) and cargo test --test roundtrip_<kind> (the consumer's own test runner). Both are isolated, single-call sites, and clearly annotated. No bash scripts, no curl/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.