# Airgap (Skíðblaðnir) — reasoning
> **Class:** `reasoning`. This file holds the *why*, the trade-offs, and **every design we rejected**. The descriptive shape we actually run is in [`airgap-design.md`](airgap-design.md), which by rule mentions none of this; how to drive it is [`airgap-guide.md`](airgap-guide.md). If you want "why not X?" — X is here.
## Why a sealed bundle at all (the motif)
The name is the argument. *Skíðblaðnir* is the Norse ship large enough to carry every god and all their war-gear, yet folds up like cloth into a pouch. That is exactly the job: fold the **entire** stack — binaries, all target-alike deps, even the "digger and cement-mixer" tooling — into one portable archive, carry it across the gap once, unfold it on the far side, and raise the house. The moving-house analogy (from the `airgap-kubernetes-main` prior art) is load the truck with your belongings *and* the tools to build the house, drive across, dig the hole, pour the foundation, *then* unpack the belongings.
The hard constraint that forces this shape: **an air-gapped machine has no network after the initial staging step.** Every byte that will ever run on it must arrive before the gap closes. This is not an edge case — it is the x14-datalabb default. So the design cannot lean on any run-time fetch; the bundle must be *complete* and *self-describing*. A single signed archive + two sidecars (`.manifest.json`, `VERSION`) is the smallest thing that is both.
## Why verify-on-receipt, both ways
The airgap law is: every byte that crosses the gap is verified before it is trusted. The transfer channel (USB, one-time relay, age courier) is out-of-band and *untrusted* — a stick can flip bits, a courier can be swapped. So verification recomputes the manifest's own root hash (the packing list wasn't edited) *and* every entry's content hash *and* rejects both directions of set-mismatch: a manifest entry missing from the bundle (a truncated transfer) and an archive entry the manifest never listed (a smuggled file). A one-directional check (`manifest ⊆ archive`) would let an attacker append an unlisted binary that `install` then extracts — so the set must match exactly. `fetch_and_verify` refuses to hand back a `Bundle` on any failure because "verify then maybe install anyway" is not a security boundary.
The out-of-band `--sha256` is optional on purpose: with no secure side-channel you still get tamper/truncation detection from the internal chain; *with* one you also pin the whole file byte-for-byte. We did not make it mandatory because requiring a second channel would block the common "verified USB in a locked room" case.
## Why 100% pure-Rust, no shell, no network at build
The LAW: containers and installers are controlled in Rust — no `*_up.sh`, no ansible, no `setenforce`/`systemctl`/`virsh` subprocesses where a library call exists. Reasons:
- **Auditability on a sealed box.** On the far side there is no package manager to trust and no way to `curl | sh` a fix. A single statically-analysable Rust binary whose every side effect is a typed call (systemd over D-Bus, firewalld over D-Bus, SELinux via `/sys/fs/selinux/enforce`) is far easier to certify for a locked environment than a web of shell glue.
- **It always builds offline.** The lean core pulls only always-on crates, so the airgap framework compiles with `net-scan` off and no registry. Heavier capabilities (the znippy engine, Vault PKI, KVM/matrix, Redfish/Terraform) sit behind features precisely so a leaf consumer — holger, tunnr — can adopt the installer without dragging in nornir's whole dep stack.
- **Dep capture belongs on the connected host, never the target.** `dist`'s reason for building in a container is that a component's runtime deps must be 100% target-alike — the base image *is* the target's userland. We kept that (`PodmanBuild`) but bounded it to the build host: the target never runs podman, never needs a runtime. Absent podman is an honest error, not a faked capture, because a silently-empty capture would ship a broken bundle across a one-way gap.
### Rejected: replace the airgap installer with a private registry mirror
The obvious alternative to a sealed bundle is: stand up a private crates.io / OCI / apt mirror inside the enclave and let the target "just install normally." We rejected it:
1. **It reintroduces a network the enclave forbids.** A mirror is still a service the target must reach; the whole premise is *zero* network after staging. A mirror moves the trust boundary inside the wire but does not remove the wire.
2. **A mirror is an ongoing liability, a bundle is a one-shot artifact.** A registry must be run, patched, backed up, and secured *inside* the air gap forever. A bundle is inert: verify it, install it, done — nothing to keep alive.
3. **Mirrors don't capture target-alike native deps.** A crate/package mirror gives you *sources or generic binaries*, not the exact `libssl.so.3` the target's userland links. `PodmanBuild` captures the real target ABI; a mirror punts that problem to the target, which has no toolchain.
4. **Completeness is unprovable across a mirror.** With a bundle, `verify_entries` proves the delivered set is exactly the intended set. With a mirror, "did every transitive dep resolve offline?" is discovered at install time on the sealed box — the worst place to find a gap.
So: sealed, signed, complete bundle — not a mirror.
## Why version-dated dirs + an atomic symlink swap
Software lives at `software/<YYYYMMDD>/<product>` and the running service resolves through a `current` symlink. Two reasons:
- **Upgrades and rollback without downtime windows.** A new generation unpacks into a fresh dated dir while the old one keeps serving; activation is a single `rename(2)` of the symlink, which is atomic on one filesystem — a reader sees either the old target or the new, never a half-written tree. The prior generation stays on disk as the rollback target.
- **No NTP, so no clock-sensitive layout.** The date stamp is a *generation label*, not a validity check. This pairs with the PKI decision below.
## Why SelfGenerated PKI with a 10-year window
An air-gapped target has no time server. Certificate validity that relies on accurate wall-clock at install time is a foot-gun: a target whose clock is wrong rejects its own freshly-issued cert. So `SelfGenerated` mints a real ed25519 self-signed CA + SAN leaf with a deliberately wide 10-year window (effectively monotonic-only). We kept `ExistingVault` / `TempVault` as explicit seams rather than deleting them because some enclaves *do* run a Vault and want their own chain of custody — but the *default* must work with nothing, offline, which only `SelfGenerated` does.
### Rejected: a hand-rolled certificate format
An earlier shape issued a bespoke, hand-rolled cert blob. Rejected: rustls / x509-parser could not load it, so nothing downstream could actually *use* the identity — it verified in our own code and nowhere else. Real X.509 via `rcgen` (ring backend, no C/cmake, matching the workspace's rustls-via-ring posture) loads straight into the TLS stack the products already speak. An identity only the issuer understands is not an identity.
## Why re-implement topo sort instead of reusing nornir's dep graph
Products in a lake point at each other, so bring-up must be dependency-ordered — nornir already owns dep-graph machinery (`dep_graph.rs` / `mimir.rs`). We deliberately re-implemented a small Kahn topological sort inside `skidbladnir::orchestrate` instead of depending on nornir's, because that machinery lives behind nornir's heavy deps and the whole point of the crate is that a leaf product can adopt the installer *without* nornir's stack. The algorithm is a few dozen lines and the seam is just `&[Product]`; paying that duplication buys the lean-core independence. A cycle is a hard error (an install that can never converge), and waves (not just a flat order) are returned so a caller can start independent products concurrently — with `std::thread::scope`, not rayon (the lean budget forbids the rayon dep).
## Why describe holger/tunnr in a registry instead of linking them
`ComponentSpec::holger()` / `tunnr()` live in `skidbladnir`'s registry, not in each product's crate, and nornir does **not** link holger or tunnr as code deps. If we linked them, nornir's build would drag in their entire trees and the "one lean installer any product can use" property would collapse. Describing a product as data (a spec) means `nornir airgap component add holger` renders holger's unit and installs it end-to-end while nornir stays lean and holger can *also* self-install by driving the same `skidbladnir::systemd` engine itself. One install system, two entry points, zero cross-links.
### Rejected: a second `nornir-install` crate
The install system was first sketched as its own `nornir-install` crate in the nornir workspace (see the historical `install-system.md`). We folded it into `skidbladnir` instead of shipping two overlapping installers, because "the airgap bundler" and "the service installer" are the same job at different scopes — a lake install *is* N component installs in dependency order. Two crates would have meant two `ComponentSpec` types, two systemd renderers, and two places for the contract to drift. nornir's own server keeps a bespoke `nornir install add/update/remove` fast path, but it renders the *same* `nornir-server.service` unit as `airgap component … nornir-server` — one unit name, one engine.
## Why znippy delta is a feature, not the default
The always-offline default is plain `tar + zstd`, which needs nothing exotic and always works. The znippy engine (content cache + delta + O(1) selective read) is the airgap-kubernetes speed story — the *next* version ships only the blobs that changed — but it is gated behind a feature because (a) the lean core must build with zero extra deps, and (b) a delta is only a win when you already have the old bundle on the target. `delta_supported()` is honest: without the feature, `upgrade --delta` prints a clear note and falls back to a full re-install rather than silently pretending. "No full decompress" (seek to one entry, stream it out, never touch the Windows MSI beside it) matters most exactly where airgap hurts: a slow USB stick and a target with little `/tmp`.
## How we got here (the road, briefly)
The design was mined from six prior-art repos, each contributing one durable idea: `dist` (the 3-phase install, container dep-capture, VERSION-outside), `airgap-kubernetes-main` (pack-everything + cache/delta speed + the redfish/terraform/kvm unfold trio), `airgap_llm` (the `prepare`/`verify` workflow, ported to Rust `pack`/`fetch`), `tunnr` (Rust-as-PID1, the KVM boot argv, the RDP/container target breadth), `msi-agent` (the Windows MSI/WiX target), and `bifrost` (the pure-Rust deploy/rollback precedent). AIRGAP1–9 built the pure-Rust offline core first and left the heavy external integrations (Vault, Redfish/Terraform provisioning, the WiX-in-podman MSI build) as *honest trait seams with rendered bodies* rather than hollow fakes — a clean seam beats a fake, because on a sealed box a fake that silently no-ops is undetectable. The crate was then extracted out of `nornir/crates` into its own sibling repo (`Skidbladnir`, published as `skidbladnir`) so it can release on its own cadence without a nornir release-cascade, and so holger/tunnr can consume it directly.