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

nordisk/holger

.nornir/static-dynamic-profile-design.md

Static vs dynamic holger — the pluggable profile (design, for review)

Status: DESIGN, awaiting Rickard's evaluation. Concretises PLAN §spine ("two binaries, not one with a switch"). The seam is a trait; the guarantee is the crate boundary. Grounded in current code paths.

The ask (Rickard)

What "airgap" means here (precise — two independent axes)

Airgap is conflated in everyday use; pull it apart:

  1. Egress — does the binary make outbound calls? Airgap = no egress: no upstream fetch (crates.io / PyPI / …), no external OIDC /userinfo, no JWKS fetch, no general network calls.
  2. Serving — does it accept inbound clients? Always yes — it is a server. An airgapped embedded box in a red zone still serves developers inside that zone.

So airgap ≠ offline. Airgap = serves inbound, zero outbound. This is exactly the cargo tree property: if no egress crate is linked, the binary cannot phone home — provably, not by config.

Use cases: embedded devices; bank zones; army zones; an airgapped embedded device serving developers in a red zone (egress-isolated from the outside while serving the inside). Same profile in every case: read-only from sealed .znippy, no egress, serving inbound.

The principle: trait for the seam, crate boundary for the guarantee

A trait alone does not make the embedded binary minimal or airgap-safe — if both impls live in one crate they both compile in, and Cargo features unify (PLAN §spine). What makes "no dynamic / no egress code in this binary" provable from cargo tree is the crate boundary. So: trait + crate split, together.

traits/                     trait BackendProvider (tiny, shared)
   fn build(&self, repo: &RepoCfg) -> Result<Arc<dyn RepositoryBackendTrait>>

holger-core-serve/  (lean)  FastRoutes dispatch + gRPC/HTTP fetch.
   StaticProvider           Depends ONLY on traits + the read-only znippy backend
                            crates. NO reqwest, NO iceberg, NO proxy/import/promote,
                            NO writable backends. Read-only by construction.

holger-dynamic/  (full)     DynamicProvider: writable file backends, ProxyBackend,
                            remote upstreams, import, promotion, OIDC(reqwest).
                            Depends on core-serve + the world.

bin static-holger = traits + core-serve + StaticProvider   → embedded, airgap, stripped
bin holger        = traits + core-serve + holger-dynamic    → default, full

RepositoryBackendTrait (traits/src/lib.rs) is already the per-repo plugin seam. BackendProvider is the new factory seam: "config node → which backend", deciding what is even constructible. The shared serve engine never names a concrete backend, so the static binary links only what StaticProvider touches.

Why the crate split is the embedded + airgap guarantee

Profile differences (each is a crate-level fact, not a runtime branch)

Concern static-holger (embedded / airgap) holger (full)
Backends read-only znippy only + writable file-rust/file-oci, proxy, remote
Writes absent (read-only by construction) mTLS/OIDC-gated put, promotion
Auth offline verification only — mTLS/PKI cert-chain, or OIDC JWT signature verified against an offline-provisioned JWKS (no /userinfo, no reqwest) + online OIDC /userinfo / live JWKS fetch (reqwest)
Upstreams / proxy / import none (internet-side = agent/dynamic) full
Config source tiny RON, or compiled-in, or read from the sealed .znippy full RON config-graph + wiring engine
Network egress zero egress crate linked as configured

Auth is split by egress, not by "mTLS vs OIDC". The dividing line is whether validation needs a network call:

Method Validates by Egress Profile
mTLS / PKI offline cert-chain signature verify none static + dynamic
OIDC offline JWT signature + claims vs a JWKS provisioned offline (config / sealed .znippy) — the token is verified like a PKI signature none static + dynamic
OIDC online /userinfo round-trip / live JWKS fetch yes dynamic only

So airgap-static is not mTLS-only — it can do OIDC, as long as the token is signature-verified offline (pure crypto, no reqwest). The W1 token-cache work (server/lib/src/auth.rs, validate_oidc_token → /userinfo) is the online path = dynamic-only; the offline JWT+JWKS verifier is the airgap path and is net-new (see auth-rbac-design.md W1-airgap). Same egress logic put RBAC policy in RON — declarative config crosses the airgap as reviewed bytes, no runtime policy store.

Open questions for eval

  1. core-serve extraction. Today serve + backends live together in server/lib. Splitting out a lean core-serve (FastRoutes + gRPC fetch, no concrete backends) is the main refactor. Worth it for the provable strip — but it's the big lift. Agree?
  2. How "embedded" is embedded? Small-Linux-SBC (std + tokio + tonic, just a lean binary) is straightforward. no_std / no async runtime is a different, much larger project (tonic/tokio assume std+async) — propose deferring.
  3. Static config source. Tiny RON file vs compiled-in vs sourced from the sealed archive itself. Leaning: read a minimal descriptor from the .znippy so the device carries one immutable artifact.
  4. Auth for airgap. mTLS/PKI is the clean default; offline OIDC (JWT signature verified against a JWKS provisioned in the sealed .znippy/config) is also airgap-safe and likely wanted where an IdP already issues tokens. Both are net of reqwest. Confirm: support offline-OIDC in static, or mTLS-only?

Testing this — mannequin in-process (light) + containers (heavy, gated)

Sequencing

After the gem/conda slices + the batched znippy publish/de-patch/merge (those edit server/lib). Then: (1) define BackendProvider in traits, (2) extract core-serve + StaticProvider, (3) move dynamic backends behind DynamicProvider, (4) add the static-holger binary crate, (5) gate: cargo tree -p static-holger shows no reqwest/remote/iceberg.