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)
static holgermust run on embedded devices and airgapped — minimal binary, no network-egress machinery, read-only from sealed.znippy.holger(normal) can carry everything — writable backends, proxy, import, promotion, external-IdP auth.- Make the split pluggable: a trait + two implementations.
What "airgap" means here (precise — two independent axes)
Airgap is conflated in everyday use; pull it apart:
- 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. - 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
- Binary size = the dep tree. OIDC pulls
reqwest+rustls; proxy/import/remote pull more; iceberg/testcontainers are huge. Only a crate boundary keeps them out of the embedded image. - Airgap is provable, not promised. If no egress crate (
reqwest, remote-upstream, world-connector) is linked, the static binary cannot phone home — auditcargo tree, don't trust a runtime flag. Airgap-compliance becomes a property of the dependency graph. - Smaller attack surface. No
put/is_writable/promotion code present in the drift server at all — the security win the "static/drift" flavor exists for.
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
core-serveextraction. Today serve + backends live together inserver/lib. Splitting out a leancore-serve(FastRoutes + gRPC fetch, no concrete backends) is the main refactor. Worth it for the provable strip — but it's the big lift. Agree?- 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.
- Static config source. Tiny RON file vs compiled-in vs sourced from the sealed archive itself. Leaning: read a minimal descriptor from the
.znippyso the device carries one immutable artifact. - 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 ofreqwest. Confirm: support offline-OIDC in static, or mTLS-only?
Testing this — mannequin in-process (light) + containers (heavy, gated)
- mannequin already mocks the whole external world in-process (PKI + OIDC + Nexus + auth) — deterministic, network-free. It is the right tool for the auth matrix: issue a cert → mTLS verify; issue an OIDC token → offline JWT verify; assert an unauthorized identity is denied. Runs in the light matrix pass, no Docker. This covers the airgap auth paths without containers.
- Containers (testcontainers) are for conformance against a real third party — a real Keycloak for online OIDC, a real Nexus for proxy/import parity. The bencher already fires up a Sonatype Nexus 3 container, fixture-gated to SKIP when no Docker/Podman (
bench-scenarios/src/nexus.rs::is_skip). The test matrix can run containers too — it is acargo testmeta-runner; a container-backed integration test is just an#[ignore]'d heavy cell run in the--ignoredpass with the same SKIP gate (mirrors themannequin::bootcell). - Airgap egress gate (no container, strongest guarantee): a matrix cell that runs
cargo tree -p static-holgerand asserts noreqwest/ remote-upstream / world-connector crate is linked — proves "cannot phone home" from the dep tree.
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.