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

nordisk/edda

crates/storage-git-gix/parked-gix/.nornir/appliance-design.md

The appliance — design

Class: design · Function axis: f0 the no-OS server · f1 the one-line test · f2 the feature split · f3 the trait seam · f4 secrets and bootstrap · f5 the dependency budget · f6 the optional systemd feature. Strict: this file states what the appliance case IS and what it currently costs. What Gunnar is: design.md. The storage seam: storage-reasoning.md.

The Gunnar server is built to run where there is no operating system under it: a single Rust binary as PID 1 in a distroless image. The consumer that forces it is mikronova/tunnr, the bare-metal appliance framework. It takes the Gunnar server and makes hardware out of it. That is not a future port. It is why the server's storage layer is a trait.

The client carries none of this. It runs on a laptop, reads ~/.gitconfig, honours ~/.ssh/config and writes files to disk. Conflating the two makes the client needlessly hard and the server accidentally easy.

The one-line test

Does this still work when there is no filesystem, no shell, no user database, and this binary is PID 1?

It applies to gunnar-store, gunnar-core, gunnar-pack, gunnar-wire, gunnar-policy, gunnar-ssh and gunnar-grpc. If the answer is no, the code is wrong, or it belongs behind a trait as a backend. It does not apply to gunnar-client or to the client verbs of gunnar-cli.

Five consequences follow, and each is a property of the code rather than a rule somebody remembers:

The build

cargo build -p gunnar-cli --no-default-features --features server

server and client are both on by default. The appliance build drops client, and with it gunnar-client and gix's porcelain-driven client verbs.

gunnar-cli/tests/feature_split.rs asserts that from a resolved cargo tree -e normal, not from declared features, and in both directions: gunnar-client must be absent from the appliance set and present in the default build. Without the second half, a cargo tree invocation that silently produced no output would pass. The test also asserts the tree is non-empty before concluding anything from a "not found", because a malformed invocation exits zero with empty output and empty output is shaped exactly like a clean result.

-e normal matters: gunnar-policy and others carry gix as a dev-dependency for fixtures, and dev-dependencies never reach an image. A guard reading raw cargo metadata would trip on them.

Measured, 2026-08-03, late

tree crates
gunnar-cli --no-default-features --features server, normal edges 277, 24 of them gix-*
gunnar-cli default (server + client), normal edges 366

Two of the 277 are C: aws-lc-rs and aws-lc-sys. See below.

The repository is under concurrent work and the tree moved during the day. Earlier the same day the appliance set was 350 crates with 47 gix-*; the drop is the fs backend rewrite described next.

The zero-shell guard, and the guard that could not see it

The appliance build used to link gix's porcelain: gunnar-store's fs backend opened gix::ThreadSafeRepository, and that alone pulled in every crate §0 lists as unnecessary — gix-worktree, gix-index, gix-status, gix-filter, gix-attributes, gix-ignore, gix-config, gix-discover — plus the gix-protocol/gix-transport client stack and gix-command, whose purpose is to spawn subprocesses, in the build whose specification opens "Zero shell".

tests/feature_split.rs was green throughout. It checks for rayon, libgit2, git2 and openssl and for a gunnar-client edge, all of which were genuinely absent, so it asserted true things while the constraint it exists to protect was broken. A guard that cannot fail is worse than no guard, because it is trusted.

tests/zero_shell_guard.rs was the missing half, written as a separate file so the two failures read differently: feature_split.rs answers "did client code leak into the server build", the zero-shell guard answers "can this build shell out or touch a worktree". It names the banned crates individually rather than matching the gix-* family, because gix-hash, gix-object, gix-pack and gix-features are plumbing the server legitimately needs. A second check asserts every banned name is found in the default build, so an entry that was a typo or an upstream rename cannot sit there reporting "absent" from every build forever.

The fix was to take fs through git's plumbing instead: gix-odb for objects, gix-ref for references, gix-lock for atomicity.

That file is gone as of 2026-08-03, and its rules live in xtask/src/guard.rs (LAW 5). It was a second copy of the same rule table with the opposite policy on the fs deviation: committed red so the fix had something to satisfy, while the xtask guard recorded the deviation and stayed green. Nothing could then tell a regression from the known state. Both properties worth keeping moved with the rules: the banned names and their reasons are guard::FORBIDDEN_IN_SERVER_GRAPH, and the anti-phantom check is guard::check_rule_names_can_fire, run against the default-build graph on every cargo xtask guard. tests/feature_split.rs stays, for the systemd feature assertions the xtask guard does not model.

⚠ What the appliance build is still not

1. arrow costs about a fifth of gunnar-store's tree, and the fs configuration does not need it. Measured 2026-08-03: 19 of 81 crates in gunnar-store's default normal-edge tree are reachable only through arrow. Refs and secrets are an Arrow IPC log (D18), which is right, but on the fs path gix-ref already handles refs and arrow arrives only because fs.rs reuses the arrow-log secret store. In the znippy configuration the cost is zero, because znippy's own index is Arrow IPC. A plain-file SecretStore for fs would confine arrow to the configuration that has already paid for it.

2. There is no off-filesystem object store. fs assumes a real filesystem, and it is the only backend that survives a restart. The znippy cold tier is not built, and mem would lose every repository on reboot. So a PID-1 appliance today needs a filesystem, and tmpfs plus fs is the honest fast path: a backend, not the design.

3. repack() does not exist. There is no cold tier to fold into. gunnar gc lists the repositories it would touch and then fails with that fact, rather than exiting zero over a no-op.

None of this stops a server running.

The C-toolchain ban was unachievable

The rule used to read "no C toolchain anywhere in the build graph". It was violated the moment SSH was linked. russh/src/lib.rs:90 is a compile_error! demanding either the ring or the aws-lc-rs feature, and both are C or assembly. On gunnar-ssh's normal-edge tree the specification records 196 crates, 2 of them C; re-measured 2026-08-03 it is 190, and the two C crates are the same two: aws-lc-rs and aws-lc-sys, the latter built with cmake.

The honest rule is no unvendored C and no system libraries. Both backends vendor their source, so the build stays hermetic and needs no distribution packages. That is what the appliance case actually requires.

The alternative was dropping russh, which would take nornir-git with it and mean writing an SSH implementation. Not worth it. ring is the smaller of the two backends if the crate count is ever worth trimming.

Secrets and first boot

An appliance has no home directory, so the two questions "where does the SSH host key live" and "how does the first admin key get in on a box with no shell" are the same question. One trait answers both: SecretStore, backed by the Arrow IPC log described in storage-reasoning.md.

Host key. get_or_create("ssh/host_ed25519"), minted in process with ssh-key. It must be stable across reboots: an appliance that regenerates its host key gives every client a host-key-changed warning, which on this kind of product is the wrong first impression. A test asserts the key is byte-identical across a simulated restart, and get_or_create is atomic so a race cannot mint two.

Admin bootstrap. On an empty identity table, gunnar serve --admin-key installs the supplied public key as the first Admin principal. If none is supplied and the table is empty, Gunnar logs loudly and refuses to serve rather than starting a server nobody can reach. Scope::Server exists so that first admin has something to hold: with only Namespace | Repo, there is nothing to scope a grant to before the first namespace exists.

The lock. The Arrow log takes an O_EXCL lock at open, so a second Gunnar refuses to start rather than corrupting the log. The file itself does not self-clear after a hard kill, and flock was unusable (unsafe_code = "forbid", no libc, and File::try_lock is Rust 1.89 against a 1.85 MSRV), so open clears it instead (S-019, 2026-08-03). The lock records <pid> <start-time>, the start time being field 22 of /proc/<pid>/stat, and a lock whose owner is provably gone is reclaimed with a warn. A live owner, a recycled pid, no /proc, or an owner line that cannot be read all still refuse: reclaiming a lock that is genuinely held lets two writers interleave frames. ArrowLogStore::force_unlock remains, for exactly those undecidable cases. An unattended box now restarts after a power cut without one.

Listeners

0.0.0.0:2222     SSH    git-upload-pack / git-receive-pack.  publickey auth, always.
127.0.0.1:50051  gRPC   the control plane.  sshsig credential + §5 grant, always.

Both listeners authenticate against the same identity table (D9 closed, 2026-08-03). SSH authenticates a key on every connection; gRPC requires a single-use sshsig signed with a key from that same table, and then a grant from the same AuthTable. There is one identity model and one ACL, so revoking a key stops both at once.

gRPC nevertheless stays on loopback by default, because an appliance is a network device: defence in depth is free and the control plane is reached over an SSH tunnel or from the same host anyway. Moving it off loopback while the development escape hatch is on is logged at error! on every start, and gunnar-cli/tests/serve_wiring.rs goes red if the default moves.

Attestation without a warehouse

At receive-pack time Gunnar knows who pushed, with which key, into which repository, which refs moved and which objects are new. It emits a PushAttestation on the gRPC event stream and appends one JSON line to an attestation log in its own store. It links no warehouse, no analytics stack, no arrow-based catalog beyond the ref log.

The dependency budget is not the whole reason. Nothing else survives the appliance case, where none of those crates could be linked at all. Anything that wants a warehouse row subscribes and writes the row itself; the coupling is a proto message, not a dependency edge.

The systemd feature, for the non-appliance case

Skidbladnir's systemd installer, optional and off by default, pinned to the newest publishable version:

skidbladnir = { version = "0.1.12", default-features = false, optional = true }

[features]
systemd = ["dep:skidbladnir"]

It uses service::Lifecycle and install_systemd_scoped, the Layout-free API. product::SystemdInstaller is the wrong layer: it demands a version-dated layout and a verified airgap bundle, and its registry hardcodes other products. What the feature buys is unit rendering plus pure-Rust D-Bus control of org.freedesktop.systemd1, with no systemctl subprocess, which is the only reason it is admissible under the zero-shell rule at all.

Measured cost, and why the appliance never pays it

Counted 2026-08-02, recorded in the specification §13.1. Not re-measured since, and the tree has moved a little; the ratio is what the decision rests on.

tree crates delta
Gunnar server dep set 247 —
+ skidbladnir, default-features = false 296 +49 (+19.8%)
+ skidbladnir with default pki 318 +71 (+28.7%)

With optional = true and nothing enabling it, Cargo does not resolve, download or compile skidbladnir or any of its 48 transitive crates. --no-default-features --features server links zero bytes of it. That is a Cargo guarantee, not a convention.

Three costs recorded honestly:

  1. A second async runtime. zbus is non-optional in skidbladnir and its default resolves async-io, not tokio, so the smol stack sits beside Gunnar's tokio: 32 of the 48 new crates. zbus has a tokio feature that would collapse this, but skidbladnir declares zbus = "5" with defaults on and exposes no knob.
  2. 16 crates of dead weight. tar, zstd*, ron, blake3, chrono, uuid, xattr, filetime are the airgap packer, which Gunnar never calls. The crate is monolithic, so they cannot be declined. zstd-sys also means this feature needs a C compiler and pkg-config at build time, which is exactly the unvendored-C case the corrected rule above excludes, and another reason the feature stays off in an image.
  3. Tension with Brokkr's Software Criteria (D17, minimal dependencies): about a fifth of the tree for a convenience. Justified only because it is default-off and the appliance never pays it. If that stops being true, drop the feature.

0.1.12 has install_systemd_scoped, service::Lifecycle and Systemd::{enable,start,stop,is_active}. It has no uninstall.rs. The DataRoot / UninstallReport guarded-delete law exists only in the unpublished 0.1.13, whose Lifecycle::uninstall() returns a bare Result<()>. Accept the thinner uninstall for now. Gunnar is meant to build standalone from a fresh clone, so a path dependency on a sibling Skidbladnir checkout is not allowed, even though every other consumer uses one. Gunnar is the first registry-only consumer and that path has never been exercised.

That rule is currently suspended for a different crate: Cargo.toml carries a [patch.crates-io] pointing nornir-git at ../edda until 0.1.6 is published, so a fresh clone does not build today. See design.md. It is marked for deletion in the manifest and does not license a second exception here.

MEASURED: the container leg, 2026-08-03

Until this run, §0's claim was an argument. cargo xtask images built six images, started each one and drove stock git against it. All six serve. Every number below is podman image inspect, podman export and a wall clock, on oden, at e7fb12b; none of it is inferred.

variant libc size files shell pkg ready rss at rest clone
scratch musl 15,000,524 B 1 none none 348 ms 4.5 MB yes
distroless musl 18,151,773 B 1333 none none 618 ms 4.7 MB yes
alpine musl 23,122,451 B 424 YES YES 305 ms 3.6 MB yes
debian-slim glibc 95,046,830 B 2846 YES YES 344 ms 3.3 MB yes
ubuntu glibc 125,557,184 B 5834 YES YES 183 ms 4.4 MB yes
fat (fixture) glibc 269,319,949 B 8993 YES YES 333 ms 5.0 MB yes

scratch is the appliance shape and it is one file: /gunnar, 15,061,984 bytes, static-pie linked, 0 PT_INTERP segments and 0 DT_NEEDED entries. Nothing has to exist under it. The glibc arm of the same source is the control: 1 PT_INTERP and four NEEDED (libgcc_s, libm, libc, ld-linux), which is why its floor is a base image with a libc in it and scratch is not available to it.

The C dependency does not force a libc base. aws-lc-sys is compiled and linked in, 123 AWS_LC strings in the stripped binary. It is simply static.

The guard, run against what the image actually builds

containers/Containerfile.bin-musl runs cargo build -p gunnar-cli --no-default-features --features server, and xtask/src/guard.rs's appliance profile resolves that exact argument list. Resolved for the image's own target triple:

cargo tree -p gunnar-cli --no-default-features --features server -e normal \
           --target x86_64-unknown-linux-musl
  → 314 crates, 0 of the 15 banned names
cargo tree -p gunnar-cli                              -e normal \
           --target x86_64-unknown-linux-musl
  → 406 crates, ALL 15 present: gix, gix-attributes, gix-command, gix-config,
    gix-credentials, gix-discover, gix-filter, gix-glob, gix-ignore, gix-index,
    gix-prompt, gix-status, gix-worktree, gunnar-client, nornir-git

Same grep, same target, one flag apart, so the zero is a zero and not an empty pipeline. The appliance tree is not vacuous either: gunnar-cli, gunnar-ssh, russh, tonic, tokio, gix-odb, gix-ref and gix-pack are all in it.

What "serves" means here

An image that starts is not an image that serves. Against scratch, distroless and alpine separately: bootstrap through the image's own verbs, then stock git push of a graph with depth (2 commits, 3 trees, 3 blobs, 1 annotated tag), then stock git clone, then every object id compared on both sides.

objects pushed : 9        objects cloned : 9
HEAD source    : 6e5bc38b197678f588c64ed85b6e4bfffef95bfa
HEAD clone     : 6e5bc38b197678f588c64ed85b6e4bfffef95bfa
object types   : 3 blob  2 commit  1 tag  3 tree
RESULT: GREEN — the cloned object graph is identical to the pushed one

And the same proof seen red, three ways: a port with nothing listening (Connection refused, exit 128, no directory); a container that is not Gunnar (alpine:3 running sleep 120, giving kex_exchange_identification: read: Connection reset by peer); and the service killed under a working proof, where the identical clone command drops from 9 objects to 0 and exit 0 to exit 128.

Not proven

Not booted on metal, and not booted in KVM; this is the container leg only. Not run for longer than a single push/clone, so nothing here says anything about the O_EXCL lock that S-019 says will not self-clear after a kill: every measurement above starts from a fresh volume. sha256 repositories are covered by ssh_end_to_end.rs, not by this run, which pins sha1 so stock git needs no object-format negotiation.

Verification

The appliance case is asserted, not asserted about:

Still not asserted, and named in the build order as wave 10: Gunnar on real metal. The container is the rehearsal; the boot is not done.

Source map

See also: design.md · storage-reasoning.md · TOTAL-PROMPT.md §0 and §13.1.