nordisk/holger
README.md
<!-- ⚠ GENERATED by nornir from .nornir/README.md — DO NOT EDIT this file -->
<p align="center">
<img src=".nornir/assets/holger-znippy-logo.png" alt="holger" width="320" />
</p>
# Holger guards your artifacts at rest
**Immutable artifact repository in pure Rust.**
⚡ **GATLING** — one all-core engine · zero rayon · zero-copy · ~11/12 cores
- **Airgap**: transfer from internet, Nexus, Artifactory → immutable znippy archive
- **Versioned**: atomic transactions, content-addressed (Blake3), O(1) file random access
- **Extreme performance**: Apache Arrow zero-copy, multi-core parallel I/O
- **Pure Rust**: no OS packages, no shell, no Python (even decompressors rewritten from C)
- **Embeddable**: runs on bare-metal/OS-free devices, 2 MB RSS
## Build and install
Anyone can clone the repository read-only, with no registered SSH key:
`git clone ssh://anonymous@git.gunnar.rs:2222/nordisk/holger.git`. The commands below use
the `git@` address, which needs a registered key; the anonymous address works the same way.
```bash
git clone ssh://git@git.gunnar.rs:2222/nordisk/holger.git && cd holger && cargo build --release
target/release/holger
```
A plain `cargo build --release` at the root builds the server package only
(`default-members`) and links one binary, `target/release/holger`: the small
`server-local` build with the intake chain (the steps and their scanners), and no
bench, no wasm plugins, no `online` and no metered. No Build Thing and no xtask are needed, and nothing has to sit beside the
clone: the root workspace is the open core, so a lone clone resolves every crate from
crates.io, and `cargo install --path server/cli` installs the same server
(`cargo xtask lone-clone` proves both end to end with `--locked`, `/healthz` included; until
the open sibling crates it lists are on crates.io, `--stage` packages them from the siblings).
The estate, appliance and build-description crates (holger-fleet, holger-live, the build
declaration, xtask, the monetize guest and the estate tests) are members of the separate
`estate/` workspace (`cargo test --manifest-path estate/Cargo.toml --workspace`), which
builds only in an estate checkout.
In an estate checkout, with `../edda`, `../Skidbladnir`, `../monetize`, `../tunnr`,
`../draupnir`, `../modgunn` and `../facett` beside it, run `cargo xtask siblings`
once after the clone (every other `cargo xtask` verb refreshes it too): it copies
`.cargo/estate-siblings.toml` to the uncommitted `.cargo/siblings.toml`, which
`.cargo/config.toml` includes when it is there, and switches the sibling patches on.
`cargo xtask` starts in any checkout: its launcher (`xtask/boot`) is a detached
workspace with no dependency. The tests against the real ONE box are
`estate-tests/`, an `estate/` member (`cargo test --manifest-path estate-tests/Cargo.toml`),
which needs those siblings.
Install it instead:
```bash
cargo install --path server/cli
cargo install --git ssh://git@git.gunnar.rs:2222/nordisk/holger.git holger
cargo install holger
holger
```
The last `cargo install` line installs the release from crates.io (Holger 2.0.0 and later).
The on-prem downstream gateway (Holger v2.0 H3) is the same package with its own feature
set and binary, exactly the Build Thing's `downstream` row:
`cargo install --locked --path server/cli --no-default-features --features downstream --bin holger-downstream`. It takes packages only from its one upstream Holger (a signed
bundle on a stick, or a pull), verifies them, serves Maven, cargo, Python (PEP 503/691),
OCI and Go, blocks what its upstream invalidates, and dials nothing else.
The first `holger` creates `~/.holger/holger.ron` (or `$HOLGER_DATA/holger.ron`) and
starts the server on loopback:
- health is `http://127.0.0.1:18444/healthz`,
- cargo reads `sparse+http://127.0.0.1:18444/cache/index/` and publishes to
`/sparring` (with `--features online`, `/cache` is a caching mirror of crates.io),
- `http://127.0.0.1:18444/` is the front page with `--features front`.
To run it as a service: `sudo ~/.cargo/bin/holger install add` (systemd), or the Helm
chart with its scan-worker sidecar (Kubernetes). The exact commands for each flavour are
in **[Holger v2.0 — Run it: three flavours](docs/HOLGER-V2.md#run-it-three-flavours)**.
The default build is small on purpose: the server, HTTPS (rustls; set `ron_tls` on
an endpoint), the static bearer token and mTLS, the raw and rust (cargo)
repositories, and the intake chain (`secure_update`, which carries `supply-chain`). `online`, `auth-oidc`, `auth-oauth2`, `auth-passkey`, `front`, `sign`
and the other formats (`fmt-all`, `fmt-npm`, …) are opt-in features.
The next start keeps your edited `holger.ron`. `holger start --config <file>` runs any
other config; `holger --help` lists every verb. `--features holger-server-alias` also
builds the same program under its old name, `holger-server`. The default build links no
monetize crate (a test runs `cargo tree -p holger -e normal` to prove it).
### The server variants
Every server variant row (`holger-recipe/src/variants.rs`) is a cargo feature set
on the same `holger` package. Metered and shop variants are opt-in only.
| variant row | install line |
|---|---|
| `server-local` (the default) | `cargo install holger` |
| `upstream` (the OPEN delivery's default) | `cargo install holger` |
| `downstream` (the on-prem gateway alone) | `cargo install holger --no-default-features --features downstream --bin holger-downstream` |
| `embedded` | `cargo install holger --no-default-features --features embedded,sign,fmt-all,jobs,supply-chain,admission,mirror --bin holger-embedded` |
| `mini` | `cargo install holger --no-default-features --features mini,appliance,sign,fmt-all,jobs,supply-chain,admission,mirror --bin holger-mikro` |
| `full` | `cargo install holger --no-default-features --features full,front,sign,fmt-all,jobs --bin holger-full` |
| `mvp-server` | `cargo install holger --no-default-features --features mvp-server,appliance,paired,auth-passkey,sign,allow-meter,fmt-raw,fmt-rust,fmt-npm,jobs,supply-chain,admission,mirror --bin holger-mvp` |
| `mvp-server-musl` | `cargo install holger --no-default-features --features mvp-server,appliance,paired,auth-passkey,sign,allow-meter,fmt-raw,fmt-rust,fmt-npm,jobs,supply-chain,admission,mirror --bin holger-mvp --target x86_64-unknown-linux-musl` |
| `mvp-server-metered` (opt-in, metered) | `cargo install holger --no-default-features --features mvp-server,appliance,paired,auth-passkey,sign,metered,admit-share,front,wasm-plugins,meter-guests,fmt-raw,fmt-rust,fmt-npm,jobs,supply-chain,admission,intel-fetch,mirror --bin holger-mvp-metered` |
| `mvp-server-musl-metered` (opt-in, metered) | `cargo install holger --no-default-features --features mvp-server,appliance,paired,auth-passkey,sign,metered,admit-share,front,wasm-plugins,meter-guests,fmt-raw,fmt-rust,fmt-npm,jobs,supply-chain,admission,intel-fetch,mirror --bin holger-mvp-metered --target x86_64-unknown-linux-musl` |
| `mvp-server-musl-metered-narrate` (opt-in, metered, DEV boot log) | `cargo install holger --no-default-features --features mvp-server,appliance,paired,auth-passkey,sign,metered,admit-share,front,wasm-plugins,meter-guests,fmt-raw,fmt-rust,fmt-npm,jobs,supply-chain,admission,intel-fetch,mirror,dev-narrate --bin holger-mvp-metered --target x86_64-unknown-linux-musl` |
| `downstream-musl` (a downstream's medium, OX-WGDS3) | `cargo install holger --no-default-features --features downstream,downstream-wg,paired --bin holger-downstream --target x86_64-unknown-linux-musl` |
The client side is not in this package: `holger-agent-cli` and `holger-update-bundle`.
### The open rows: the Build Thing builds the `cargo install` line (Holger v2.0 H5)
The Build Thing rows of the open products build exactly what one plain `cargo install`
line builds: the same package, bin, features, default switch, target and release profile
(`holger-recipe/src/twins.rs`, held by `holger-recipe/tests/rows_equal_cargo_install.rs`).
`cargo xtask twin` builds each row and its line in the pinned toolchain image and compares
the two binaries: byte-identical for all three. holger-ui's `ui` row is
`cargo install --locked --path crates/holger-ui-server` in holger-ui.
| product | Build Thing row | `cargo install` line | crates | binary | adds over the default |
|---|---|---|---|---|---|
| upstream | `upstream` | `cargo install --locked --path server/cli` | 332 | 50.4 MB | nothing: it is the default |
| downstream | `downstream` | `cargo install --locked --path server/cli --no-default-features --features downstream --bin holger-downstream` | 329 | 50.1 MB | the gateway rules, sync and the Maven, Python, OCI and Go formats; it drops the scanners and the export |
| suite-mvp | `mvp-server` | `cargo install --locked --path server/cli --no-default-features --features mvp-server,appliance,paired,auth-passkey,sign,allow-meter,fmt-raw,fmt-rust,fmt-npm,jobs,supply-chain,admission,mirror --bin holger-mvp` | 408 | 61.2 MB | the estate: pairing, the appliance ceremony, passkeys, the built-in allow-meter, npm, the job queue, admission and the mirror |
| cli | `cli` | `cargo install --locked --path holger-cli` | 84 | 9.8 MB | the thin gRPC client: every console action as a command; no server, it dials only --at |
<!-- Static snapshot (v0.1.0, oden). The nornir "mashup" docs-render section is
newer than the installed nornir (0.4.25), which errors on it; this table is
kept static instead. Restore the auto-rendered mashup gen-block once a
mashup-capable nornir is on PATH. -->
> **Biggest gap — `p50_ms`: holger leads naive by 785.3×** (0.15 vs 118.82).
**v0.1.0 · oden · 32 cores · 2026-07-23**
| workload | ops_per_sec | plans_per_sec | records_per_sec | p50_ms | p99_ms | p999_ms | iters | metadata_mbs | served_mbs | mean_ms |
|---|---|---|---|---|---|---|---|---|---|---|
| holger | **181863** | **761.95** | **3.8097M** | **0.15** | **0.46** | **1.10** | 2000 | **348.79** | **108.43** | **0.17** |
| artifactory | 20578 | | | 1.10 | 6.11 | 37.63 | | | 12.24 | 1.53 |
| dryrun | | | | | | | 100 | | | |
| naive | | 8.38 | 0.0419M | 118.82 | 131.88 | | 24 | 3.84 | | 119.35 |
| nexus | 15832 | | | 1.40 | 7.96 | 62.64 | | | 10.60 | 2.00 |
<sub>holger vs **Nexus** vs **Artifactory**, served over HTTP on all sides, measured
identically. **Fair hardware — no throttled rival:** each JVM competitor gets **every
core on the host** and ~75 % of its RAM as a fair JVM heap; holger runs in-process on
the same cores. The host and core count these numbers were taken on are stamped in the
table header above and in the provenance block below — never assume a number from one
host is comparable with one from another. Full method + every metric:
[`README-full`](.nornir/README-full.md).</sub>
**Which rival versions these are, and what they can serve.** The numbers above
were taken against **Sonatype Nexus 3.72.0** (released 2024-09-04) and **JFrog
Artifactory OSS 6.23.13** (released 2021-02-21). Two limits follow from that and
are stated here rather than left for the reader to discover:
* **Artifactory OSS serves Maven/Gradle/Ivy/SBT and generic repositories only —
no Cargo, no PyPI, no npm, in any free JFrog edition.** No version fixes this.
Every Artifactory row is a maven/generic data point.
* **Nexus 3.72.0 predates Cargo support**, which arrived in 3.73.0 (2024-10-10).
A 3.72.0 server cannot serve Rust crates at all, so no row above supports a
claim about holger versus Nexus on Rust crates.
The bench suite now also runs both rivals at their **current** tags
(Nexus 3.96.2, Artifactory OSS 7.161.26) as separate arms, so the modern
comparison exists beside the historical series rather than replacing it. Method
and every version fact: [`10-benchmark-baseline`](.nornir/stinky/10-benchmark-baseline.md).
### Where these numbers come from
<!-- Static snapshot (v0.1.0, oden) — see the note on the `mashup` block above;
`bench_status` is likewise newer than the installed nornir. -->
**v0.1.0 · oden · 32 cores · 2026-07-23**
| | |
|---|---|
| host | `oden` · 32 cores |
| version | `0.1.0` |
| measured | 2026-07-23 |
| arms | 16 |
| tests | 16/16 passed |
| publishable | yes |
The `mashup` and `bench_status` tables above are a static snapshot of the v0.1.0
run (the installed nornir predates those two `docs render` sections). The rest of
this page is still rendered by `nornir docs render holger`; refresh these two by
hand at release time, or restore their `nornir:gen` blocks once a newer nornir is
on PATH.
## Release provenance (SLSA Build L3)
Releases are built by dwarves, the build server, in its isolated release-build mode and carry signed SLSA v1 provenance:
one in-toto statement per release, every artifact by its sha256, signed on the owner's
YubiKey. The bundles are in [`attestations/`](attestations/), the release key in
[`slsa/`](slsa/); how to verify (cosign, dwarves or Holger itself) and what still keeps us below
L3: [docs/SLSA.md](docs/SLSA.md).
## Tier 1 in five minutes
Tier 1 is the server from a plain `cargo build` plus one RON file that says where
packages come from (`sources:`), what they pass on the way in (`steps:`) and the
rules that hold for every source (`rules:`). There is no UI and no SVG in tier 1.
**1. Clone and build.** The plain build carries the intake chain (the steps, the
scanners and the offline advisory import). It does not carry `online` or monetize.
```bash
git clone ssh://git@git.gunnar.rs:2222/nordisk/holger.git && cd holger
cargo build --release
```
Every scan runs in a container, never in the server (`rules: (sandbox: required)`, the
default): mint the scan worker image once. It needs rootless podman; without it Holger
refuses every scan by name (`holger-sandbox-1`).
```bash
cargo xtask scan-worker image # localhost/holger-scan-worker:0.1.0, FROM scratch
```
**2. Write `holger.ron`.** Four raw repositories hold the steps, one upload source
brings packages in, and four tokens hold the duties (an Importer, an approver, a
repository admin and an auditor).
```ron
(
exposed_endpoints: [ (ron_name: "door", ron_url: "127.0.0.1:50051", ron_http_url: Some("127.0.0.1:8080")) ],
storage_endpoints: [ (ron_name: "disk", ron_storage_type: "znippy", ron_path: "/tmp/holger/store") ],
repositories: [
(ron_name: "incoming", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: Some((ron_storage_endpoint: "disk", ron_exposed_endpoint: "door")), ron_data_dir: Some("/tmp/holger/incoming")),
(ron_name: "quarantine", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: Some((ron_storage_endpoint: "disk", ron_exposed_endpoint: "door")), ron_data_dir: Some("/tmp/holger/quarantine")),
(ron_name: "approved", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: Some((ron_storage_endpoint: "disk", ron_exposed_endpoint: "door")), ron_data_dir: Some("/tmp/holger/approved")),
(ron_name: "production", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: Some((ron_storage_endpoint: "disk", ron_exposed_endpoint: "door")), ron_data_dir: Some("/tmp/holger/production")),
],
audit: (dir: "/tmp/holger/audit", enabled: true),
auth: Some((
methods: [
StaticToken(subject: "ida", token: Some("ida-token-of-sufficient-length")),
StaticToken(subject: "bob", token: Some("bob-token-of-sufficient-length")),
StaticToken(subject: "adm", token: Some("adm-token-of-sufficient-length")),
StaticToken(subject: "aud", token: Some("aud-token-of-sufficient-length")),
],
duties: { "ida": [Importer], "bob": [SecurityApprover], "adm": [RepositoryAdmin], "aud": [Auditor] },
)),
sources: [
(name: "team", kind: upload, gates: (license: (deny: ["AGPL-3.0-only"]), cve: (max: medium))),
],
steps: (approvals: 1, unknown_license: Pass),
rules: (require_signed: false, require_provenance: false, deny_licenses: ["GPL-3.0-only"]),
)
```
Two lines loosen the gateway's defaults for this demo, and both say so in the file:
`require_signed: false, require_provenance: false` lets an unsigned file in (the default
floor refuses anything without a verified signature), and `unknown_license: Pass` lets
a file that declares no licence through (the default holds it for review).
**3. Start it.**
```bash
target/release/holger start --config holger.ron
```
**4. Upload a package as the Importer.**
```bash
echo hello > demo-1.0.txt
curl -X PUT -H "Authorization: Bearer ida-token-of-sufficient-length" \
--data-binary @demo-1.0.txt http://127.0.0.1:8080/incoming/demo-1.0.txt # 201 INGESTED
```
**5. See its state**, with its source and its effective policy, over HTTP and with the CLI:
```bash
curl -H "Authorization: Bearer aud-token-of-sufficient-length" \
"http://127.0.0.1:8080/-/intake/state?name=demo-1.0.txt"
HOLGER_TOKEN=aud-token-of-sufficient-length \
target/release/holger intake state --server 127.0.0.1:50051 --name demo-1.0.txt
```
Walk it through the steps: `POST /-/intake/advance?name=demo-1.0.txt&verb=advance`
as `bob` to SCANNED, POLICY_CHECKED and APPROVED, then as `adm` to SEALED, as `bob`
to PROMOTED and as `adm` to AVAILABLE. Production then serves it at
`/production/demo-1.0.txt`. A refusal names the rule and who set it, for example
``rule `deny_licenses` (source 'team') for source 'team': licence AGPL-3.0-only is denied``.
### The RON shape
- **`sources:`** is a list. Each entry has a `name`, a `kind` and the fields its kind takes:
| kind | what it is | build | notes |
|---|---|---|---|
| `upload` | packages pushed into the `incoming` repository (PUT, twine, npm publish) | default | `importers: [...]` limits who may upload as it. With several upload sources, the first path segment names the source: `PUT /incoming/<source>/<file>`. No upload source declared = one implicit source named `upload`. |
| `holger` | an upstream Holger, mirrored on a timer | default | `url`, `repositories: [(ron_from, ron_to)]`, `credential`, `every_secs`. Its bytes skip the steps, so the floor must say `allow_unchecked_sources: true`. |
| `crates` with `serves` | crates.io as a pull-through for the repositories in `serves` | `--features online` | The default build refuses it by name. Its bytes skip the steps (as above). |
| `python`, `oci` | a PEP 503/691 index (PyPI, Chainguard Libraries) or an OCI registry (`cgr.dev`), pulled THROUGH the steps | `--features online,jobs` | `url`, `credential: (username, password_env)`, `select`, `max_files` (python), `platforms` (oci), `max_bytes`, `refresh_secs`, `auto_approve: off \| when(provenance: require, cve_max: medium)`. A fetched package is INGESTED as `pull-through:<name>` and walks the steps under its source's policy. |
| `npm`, `maven`, `crates` with `url` | an npm registry (registry.npmjs.org, Chainguard Libraries for JavaScript at `libraries.cgr.dev/javascript/`), a Maven 2 repository (Maven Central, Chainguard Libraries for Java at `libraries.cgr.dev/java/`) or a cargo sparse index (`sparse+https://index.crates.io/`), pulled THROUGH the steps | `--features online,jobs` | As `python`, plus `wait_secs` (a request waits that long for its pull; default 30 s for `maven`, whose client does not retry a 503). npm: the tarball against `dist.integrity` (sha512); `dist.signatures` and the `dist.attestations` (SLSA provenance, publish attestation) go to the provenance verifier, the registry's keys in `rules.keys`. Maven: the POM and the JAR together, each against the `.sha512`/`.sha256`/`.sha1` beside it; Chainguard's `.slsa-attestation.json` beside the JAR goes to the verifier; PGP `.asc` is not checked. crates: the `.crate` against its index `cksum`; crates.io publishes no signature, so a floor with `require_signed: true` refuses every crate. Clients: `npm --registry <holger>/production/<name>/`, a Maven `<repository>` at `<holger>/production/<name>/`, cargo `registry = "sparse+<holger>/production/<name>/"`. The packument, `maven-metadata.xml` and the index list only AVAILABLE versions. |
- **`gates:`** on an upload source: `provenance: require(identity: (issuer, subject), slsa_min: 2) | prefer | off`,
`sbom: require | off`, `malware: on | off`, `license: (deny: [...])`, `cve: (max: info|low|medium|high|critical)`,
`block_formats: [...]`, `secrets: block | warn | off`. Every field is optional.
- **`steps:`** is the new spelling of `promotion:` with its `intake:` block: `incoming`,
`quarantine`, `approved` and `production` (those are the defaults), `scan: [...]`,
`approvals`, `transitions`, `inspect`, `advisory_db`, `allow_licenses`,
`unknown_license`, `trust_policy`, `npm`, `secrets: (rules: "/path.ron")` (the secrets
scanner's rule file; absent, the built-in set), and `gates: (...)`, the defaults every source starts from.
- **`rules:`** is the global floor: `require_signed`, `require_provenance`, `slsa_min`,
`identities`, `builders` (`"id"` or `(id: "…", level: N)`), `keys`, `trust_root`,
`require_sbom`, `malware`, `cve_max`, `secrets`, `deny_licenses`, `block_formats`,
`allow_auto_approve` and `allow_unchecked_sources`. The provenance fields are the
verifier's own (`holger-provenance`), with one parser.
A source's `gates` (and `steps.gates`) may only **tighten** the floor. A gate that would
loosen it, such as `provenance: off` under `require_signed: true` or `cve: (max: critical)`
under `cve_max: high`, stops the load by name, with the rule it breaks. An artifact's
effective policy is the floor plus its source's gates. It is fixed at ingest, kept on
every line of its intake record, and shown by `GetArtifactState`, `/-/intake/state` and
the audit trail. Every block refuses an unknown key by name, and so does the `auth` block.
The old shape (`promotion:` with `intake:`, `mirror:`, `rust-remote` repositories) still
loads unchanged; it has no floor. Naming `steps:` and `promotion:` together is refused.
**The default floor** (`rules:` absent, or a rule left out) is a security gateway's
default: it fails closed on what this build can check.
- `malware: on`: file inspection (archive bombs, path traversal, nesting, executables)
and any configured ClamAV or YARA engine must pass.
- `cve_max: high`: an advisory rated critical is refused.
- `secrets: block`: the secrets scanner (in the scan sandbox) walks every package and
image layer for private keys (PEM, OpenSSH, PGP, PuTTY), cloud credentials (AWS, GCP,
Azure), tokens (GitHub, GitLab, Slack, Stripe, npm, PyPI, crates.io), `.env` files,
kubeconfigs with credentials and high-entropy strings next to a secret-like name. A
high or critical finding is refused by name; medium findings warn. A source may write
`gates: (secrets: block)` over a `warn` floor, never the other way. The evidence names
the rule (`secret:<id>`), the path and the line, never the secret. The rules are data:
a `holger.secret-rules/v1` RON file (`steps.secrets.rules`), carried over the airgap
like the YARA rules; the built-in set is `server/lib/src/scanner/secrets-rules.ron`.
- `block_formats: ["pickle"]`: the pickle family (`.pkl`, `.pickle`, `.pt`, `.pth`, `.joblib`) is refused at ingest.
- `allow_unchecked_sources: false`: a `holger` or `crates` source is refused at load.
- `require_signed: true` and `require_provenance: true`: the provenance verifier
(`holger-provenance`: DSSE, in-toto, SLSA, Sigstore, PEP 740, npm, cosign) is in the
build, so nothing without a verified signature and provenance comes in. The material
waits beside the artifact by its sha256 (`<data_root>/holger-provenance/<sha256>/`).
`trust_root` names the signed Sigstore trust bundle for keyless signatures.
APK packages (Wolfi, Chainguard, Alpine) come from a `kind: apk` source: their
provenance is the signed `APKINDEX.tar.gz` in `<data_root>/holger-provenance/apk/<source>/`,
checked against the apk keys the same trust bundle pins (`apk-keys/<name>`); a gate may
narrow them with `provenance: require(apk_keys: ["wolfi-signing.rsa.pub"])`.
- `allow_auto_approve: false`: nothing is approved automatically. A source's
`auto_approve: when(...)` works only when the floor allows it, decides only the
approval vote (`auto-approve:<source>`, never the importer), and counts as at most one
of `approvals: 2`.
## Read the real docs
- 🧭 **[Holger v2.0 — the plan: internet → upstream → USB → on-prem downstream](docs/HOLGER-V2.md)** — the products, what each does, the phases H1–H6, and what is built through the Build Thing.
- 🗒️ **[Features — `docs/FEATURES.md`](docs/FEATURES.md)** — what is new in Holger, with status, date, source path and commit.
- 📖 **[The complete manual — `docs/manual.pdf`](docs/manual.pdf)** — every subsystem chapter (server, agent, storage, auth, release & history).
- 📄 **[Full overview — `.nornir/README-full.md`](.nornir/README-full.md)** — the detailed pitch, quickstart, design, the full competitive matrix, and all measured benchmarks.
## Development setup
The generated docs in this repo are kept in sync by git hooks in `.githooks/`.
Point git at them **once per clone**:
```bash
git config core.hooksPath .githooks
```
On every commit that stages Rust source, a `Cargo.toml`, or
`.nornir/ai-context.toml`, the `pre-commit` hook regenerates the AI-context packs
(`docs/ai-context.md` + `docs/ai-context-full.md`) with
[kvasir](https://github.com/hentorp/kvasir) and stages them, so they never drift;
it also re-renders README/CHANGELOG via nornir. A missing tool warns and skips; a
kvasir validation error aborts the commit. kvasir is taken from `PATH` (install it
with `cargo install --path ../kvasir`) or a sibling `../kvasir` release build.
## Contributing, security and changes
- [CONTRIBUTING.md](CONTRIBUTING.md): how to get the code, report a bug and propose a change.
- [CLA.md](CLA.md): the Contributor License Agreement (version 1.1) that every outside contribution needs; accept it with one line in [CLA-SIGNATURES.md](CLA-SIGNATURES.md).
- [SECURITY.md](SECURITY.md): how to report a vulnerability privately (holger@holger.rs).
- [CHANGELOG.md](CHANGELOG.md): what changed in each release, and the vulnerabilities each one fixed.
## License
AGPL-3.0-only, © 2026 Vetra Automation AB and Ignalina ApS (Rickard Ernst Björn Lundin) (jointly owned in equal
shares, 50/50). Commercial terms are available from both co-owners jointly; write to
gunnar@gunnar.rs. Versions published to crates.io on or before 2026-10-02 keep the
license they were published with (mostly MIT). See `LICENSE`, `NOTICE`,
`COMMERCIAL-LICENSING.md`, `TRADEMARKS.md` and `THIRD_PARTY_LICENSES.md`.