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/TOTAL-PROMPT.md

gunnar: the total prompt

Status: brief → build. This file is the whole specification; an agent picking it up should be able to build gunnar from this and nothing else. Date: 2026-08-02 · Repo: github.com/vetra-automation/gunnar (private) · License: MIT OR Apache-2.0 (matches garmr)

Decisions log, 2026-08-02

Every one of these is settled. Do not relitigate them; build them.

# decision
D1 Org/repo vetra-automation/gunnar, private. License MIT OR Apache-2.0.
D2 Client is normal-OS. Only the server carries the no-OS rule. (§0)
D3 nornir-git 0.1.5 published; plain dep, no patch block. (§3)
D4 Server storage = two traits, ObjectStore + RefStore. Never gix::Repository. (§4)
D5 Objects: incoming packs → cold znippy, folded by repack(). No database. (§4)
D22 git:// is never built. Smart HTTPS is wanted, but after P-001. (§6.1) — the deferral is discharged: see D24.
D21 Anything that belongs upstream is built as a standalone, donation-shaped crate from the first line. No gunnar dependencies, gix's own conventions. First candidate: delta computation. (§2.1)
D19 gunnar-ssh depends on gunnar-core; the sandbox, fingerprint, host key and auth lookup exist ONCE. Nested namespaces are permitted and grants do NOT inherit. (§5.2)
D20 Key fingerprints are computed over BYTES, not over an ssh-key type. russh pins ssh-key =0.7.0-rc.10; the workspace pins 0.6. Two incompatible copies are in the graph and neither crate can hand the other a key. (§5.2)
D18 redb is DROPPED. Refs and secrets are Arrow IPC streams (__gunnar_refs__, __gunnar_secrets__) in the gunnar znippy package. One storage technology. (§4.5)
D6 oid index is stree, not the fst trie. (§4.1)
D7 All three git indexes in scope: __gunnar_oid__, __gunnar_graph__, __gunnar_reach__. (§4.2)
D8 git is a native znippy package format, in the znippy repo. (§4)
D9 gRPC authentication is SSH-key-derived: an sshsig credential over the §5 key table, authorised by AuthTable. Supersedes the v1 allow-all hole, closed 2026-08-03. Deny by default; one documented local-development escape hatch. (§8.2)
D10 Secrets (host key, admin bootstrap) behind a SecretStore trait; internal impl now, vault impl later, noted but not built. (§5.1)
D11 sha256 is the default hash kind for new repos. sha1 stays supported.
D12 One .znippy per git repo. (§4.3)
D13 SSH and gRPC are separate listeners. No SSH-over-gRPC bridge. (§8.2)
D14 repack() writes a new archive and atomically swaps; never mutates in place. (§4.4)
D15 gunnar→gunnar replication to an external mirror is a planned feature. (§11.1)
D16 Take Skidbladnir's systemd installer as an optional, default-OFF systemd feature, pinned to the published 0.1.12. Measured cost below. OFF in an appliance build. (§13.1)
D23 Repo.visibility is honoured: a Public repository is readable with NO key. An unauthenticated caller arrives via auth_none on the reserved SSH username anonymous, is served as the reserved principal anonymous, and is answered by AuthTable::may like everybody else: Read, on Public, nothing anywhere else. Never receive-pack, never gRPC. (§5.3)
D24 Smart HTTP is BUILT (gunnar-http, feature http, default OFF). It authenticates with a principal-bound access token presented as the HTTP Basic password or as Bearer, and every authorisation question is still AuthTable::may on that principal's grants. There is no HTTP ACL. Anonymous read obeys the same allow_anonymous field as SSH, from one place. (§6.1)
D25 gunnar terminates no TLS. A TLS terminator in front is expected, and gunnar refuses to read a credential off a plain connection unless that terminator attests it with X-Forwarded-Proto: https (or the operator passes --http-allow-cleartext-credentials). If TLS is ever terminated in-process it is rustls on aws-lc-rs, never ring: gunnar already links aws-lc statically and one appliance gets one crypto backend. (§6.1)
D26 Secrets are sealed at rest with AES-256-GCM (aws-lc-rs), and the key-encrypting key is machine-local by default or externally supplied. SealedSecretStore is a decorator over the SecretStore trait, so mem, arrow_log and fs all seal through one implementation. The KEK lives in secrets.kek (0600) beside the control store — outside the Arrow secret log and outside anything FoldRequest reaches — or comes from GUNNAR_SECRET_KEK and is never written. This defeats exfiltration of the data without the disk (a cold-tier archive copied to object storage, a backup, a replica's secrets.arrow); it does not defeat possession of the disk, root on the running appliance, or its memory. Domain separation between the portable artifact and the machine — not confidentiality against physical possession. See the full reasoning below.
D17 gunnar is built to Brokkr's Software Criteria: baremetal Rust, minimal dependencies, no OS/Kubernetes/cloud lock-in, zero-copy · multi-core · SIMD. The dependency budget in §2 IS that criterion made concrete.

0. What gunnar is

A git client and a git server, in Rust. Both speak SSH. The client lives on a normal OS; the server does not require one.

The asymmetry: read this first

The two halves have different constraints, and conflating them makes the client needlessly hard and the server accidentally easy.

gunnar-client gunnar-server
runs on a normal OS. Always. A developer laptop, oden, CI. a normal OS or bare metal with no OS under it
filesystem assumed, freely. Real working tree, real .git, real config not assumed. One trait seam (§4)
gix::Repository use it directly. Worktrees, checkout, index, status, config never. Plumbing crates only
~/.ssh, ssh-agent, known_hosts fine. Use them; users expect it not present, not referenced
repos it handles working repos, with a checkout bare only (see below)

The client is a normal git client and should feel like one. It may read ~/.gitconfig, honour ~/.ssh/config, prompt on an unknown host key, and check files out onto disk. Do not abstract any of that; abstracting it buys nothing.

The one-line test, SERVER crates only

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

Applies to gunnar-store, gunnar-core, gunnar-pack, gunnar-wire, gunnar-policy, gunnar-ssh, gunnar-http, gunnar-grpc. If the answer is no, it is wrong, or it is a backend behind a trait. It does not apply to gunnar-client or gunnar-cli's client verbs.

The consumer that forces it is mikronova/tunnr — the bare-metal appliance framework where a single Rust binary is PID 1 in a distroless image. tunnr takes the gunnar server and makes hardware out of it. That is not a future port; it is the reason the server's storage layer is a trait (§4).

What that buys: the server is worktree-free

A git server never checks anything out. It serves and receives bare repositories: objects and refs, nothing else. So the server needs none of:

gix-worktree · gix-status · gix-index · gix-filter (no smudge/clean, no CRLF) · gix-attributes · gix-ignore · gix-glob for pathspecs · gix-config (server config is gunnar's own, not .git/config) · gix-discover (repos are registered, not discovered) · gix-credentials · gix-prompt

That is the majority of what makes gix filesystem-bound. What is left to abstract is exactly two things, objects and refs (§4), and one of them already has a trait. The no-OS server is a much smaller job than it first looks.

Not in v1

Smart-HTTP (built, D24: feature http, default OFF, so an appliance still compiles none of it) · web UI · pull requests / merge requests · issues · CI · LFS (see §12) · any porcelain that belongs in a gRPC client rather than the server.


1. Laws this repo inherits

These are not suggestions. See /home/rickard/git/CLAUDE.md.

law what it means here
−1 NO AI attribution. Not in a commit, tag, PR body, README, code comment, or generated doc. Ever. Non-negotiable.
0 A denied tool call is a denied call, not a denied task. Use the sanctioned tool, reshape, do everything else, note it in one line, keep going. Never escalate mid-flow.
1 Do not ask permission for reversible work. Act, then report.
2 Never accept a green you have not seen red. Assert applied output, not a state round-trip. Beware the identity value. Your own new guard can be blind.
3 Concurrency is gatling. rayon is FORBIDDEN. gunnar is tokio-async, so this mostly means: never reach for rayon when a fan-out is needed.
5 Reuse, do not twin. >10 duplicated lines ⇒ share the code. Prefer fixing by construction over a guard watching two copies agree.
6 Never git stash. Never git add -A. Branch from origin/<branch>, never local HEAD. Build scratch on md1 (/home/rickard/scratch/cargo/gunnar), never /tmp (that is RAM).
7 Report ratios, not raw counts. Say what was measured vs inferred. State what you could not determine.

Keep the documentation technical. No motive, no positioning, no commentary on other systems or their operators. Describe what gunnar is and how it works. The work is the argument.


2. Crate layout

Crates are marked S (server: the no-OS rule of §0 applies) or C (client: a normal OS is assumed and may be used freely). gunnar-pktline is shared and is constrained by being a leaf, not by the OS rule.

gunnar/
├── Cargo.toml                        workspace
├── crates/
│   ├── gunnar-pktline        ·  pkt-line framing. ZERO dependencies.  (shared)
│   ├── gunnar-store          S  the storage TRAITS + fs/mem/arrow-log backends
│   ├── gunnar-store-znippy   S  znippy COLD tier              (feature)
│   ├── gunnar-store-iceberg  S  iceberg backend               (feature, OFF)
│   ├── gunnar-core           S  repo model, auth table, ACL, namespaces
│   ├── gunnar-pack           ·  pack write + pack index       (shared — see below)
│   ├── gunnar-wire           ·  protocol state machines       (shared — see below)
│   ├── gunnar-policy         S  pre-receive gate: hooks, protection, ssh-sig
│   ├── gunnar-ssh            S  russh SERVER — auth, exec dispatch, sandbox
│   ├── gunnar-http           S  smart HTTP over the same seam    (feature, OFF)
│   ├── gunnar-proto          ·  .proto + tonic-build                  (shared)
│   ├── gunnar-grpc           S  tonic service impl + event bus
│   ├── gunnar-client         C  clone/fetch/push/mirror + full gix porcelain
│   └── gunnar-cli            ·  the `gunnar` binary — server + client verbs
└── xtask/                       docs generation — NEVER a build dependency

Dependency direction is strictly downward. pktline and store depend on nothing of gunnar's. cli depends on everything. No cycles, checked in CI.

gunnar-client must not be a dependency of any S crate. That edge is what would quietly drag gix's porcelain — and with it the whole filesystem assumption — into the appliance build. The CI dependency guard asserts its absence, and must be seen to go red when the edge is deliberately added.

Two binaries' worth of features in one CLI. gunnar serve needs only the S crates; gunnar clone|push|mirror needs only C. Feature-gate them (server, client, both default) so an appliance image builds --no-default-features --features server and links no client code at all.

Why gunnar-pack and gunnar-wire are shared, not server-only

gix cannot push. Verified in gix 0.86: gix-protocol/src/ contains fetch/, ls_refs.rs and handshake/. There is no send-pack module. Direction::Push exists only as a refspec direction. nornir-git's own header says the same: "Not yet: git-receive-pack (SSH push)."

So gunnar-client has to implement send-pack itself. But send-pack is receive-pack seen from the other end, and the server needs a pack writer for upload-pack regardless. The client's push therefore costs almost nothing extra: it reuses gunnar-pktline and gunnar-pack and the command-list encoding in gunnar-wire. That is LAW 5 paying out, and it is why those two crates are ·.

Consequence for the dependency guard: gunnar-pack and gunnar-wire are on both sides, so they inherit the server's constraint: no gix porcelain, no worktree features. They must stay plumbing-only.

Dependency budget

The budget is tight on the server and generous on the client. A dependency on the server is a line item on an appliance image; on the client it is just a dependency. Do not spend effort minimising the client.

Server (S), the constrained set:

dep why
gix-object, gix-hash, gix-pack, gix-features git plumbing, the trait-generic layer
gix (porcelain) fs backend ONLY, behind the fs feature. Off in an appliance build.
russh, ssh-key the SSH server + ssh-sig verification
tokio, tokio-util async runtime
tonic, prost, prost-types gRPC
flate2 zlib, git's object encoding. Non-negotiable.
arrow-ipc, arrow-array, arrow-schema the ref/secret log (§4.5). No database. Free only where znippy is linked, which is NOT the fs path. See §4's correction and §16 item 6.
anyhow, thiserror, serde, serde_json, tracing, hex plumbing
znippy-common ZnippyReader / ArchiveMetaSink, store-znippy feature
skade / znippy-iceberg store-iceberg feature, OFF by default

Client (C), normal and unconstrained:

dep why
gix with worktree/status/config/checkout features it is a git client; use the porcelain
nornir-git the SSH bridge, reused not twinned (§3)
russh, ssh-key, rand_core transport + key minting
anything else a good CLI wants clap, indicatif, whatever earns its place

Forbidden anywhere in the build graph, both halves: rayon · libgit2 / git2 · openssl · nornir (the server crate) · typst · any HTTP server.

⚠ CORRECTION, measured 2026-08-03. This list said "any C toolchain requirement" and that is not achievable with russh, so the rule was being violated the moment SSH was linked. russh/src/lib.rs:90 is a hard compile_error! demanding either the ring or the aws-lc-rs feature; both are C/asm. Measured on gunnar-ssh's normal-edge tree: 196 crates, 2 of them C — 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 distro 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.

Forbidden in the S graph specifically: gunnar-client · gix with any worktree/index/status/filter/attributes feature · skade, arrow or parquet outside the store-iceberg feature.

A CI guard asserts both sets from cargo metadata, and must be seen to go red on a deliberately added rayon dep and on a deliberately added S → gunnar-client edge before it is trusted (LAW 2).


2.1 Donation-shaped crates (D21)

When gunnar has to write something that plainly belongs in gitoxide, it is built as a standalone crate from the first line, never extracted later. Extraction after the fact never happens; by then the code has grown gunnar's types through it.

Rules for such a crate:

Naming: keep it gunnar-* in-tree; do NOT publish under gix-*. That namespace is GitoxideLabs', and publishing gix-delta ourselves would be squatting on it. Build gunnar-delta, offer the code, and let them name it if they take it.

Current candidates

what why it belongs upstream status
delta computation gix-pack's entry::iter_from_counts has exactly one Mode: PackCopyAndBaseObjects. It reuses deltas already in a pack and treats everything else as a base object. There is no delta encoder in gitoxide. not started
send-pack gix-protocol 0.64 has fetch/, ls_refs.rs, handshake/ and no push. Ours is proven against real git receive-pack. Upstream's crate-status now describes send-pack plumbing, so check before donating; this may already be landing. built, in gunnar-client (not yet donation-shaped)
pure-Rust SSH transport gix's own SSH transport execs ssh. The russh↔gix bridge removes the subprocess entirely. Lives in nornir-git, not gunnar — Rickard's call whether to offer it. exists upstream of us

Do not confuse "gix lacks it" with "gix wants it". Ask before building to donate; build for gunnar's need first and shape it so donation stays possible.

3. What is reused, not written

The client half of git-over-SSH already exists and is proven against codeberg/Forgejo and GitHub. nornir-git (in edda/crates/nornir-git) carries:

gunnar depends on this. It does not copy it. LAW 5.

⚠ One copy is forced, and the fix is upstream. nornir-git 0.1.5 exposes only connect_upload_pack, with git-upload-pack '<path>' baked in. Push needs git-receive-pack, so ~40 lines of connect / auth / exec / SyncIoBridge / in-runtime-Drop are duplicated in gunnar-client/src/ssh.rs.

Upstream fix: nornir-git should expose connect_exec(loc, key, cmd) and define connect_upload_pack as a one-line call to it. Then the copy deletes itself.

Two smaller upstream items found the same way: the connector takes a key path, so fetch_ssh cannot use ssh-agent while push can, an asymmetry with no good reason; and gitio::porcelain_status is private, while its two public derivatives drop the per-path state a status verb has to print, forcing ~35 lines to be re-walked. Making it pub deletes them.

⚠ Two gix behaviours that bite silently. Found by interop testing, not by reading.

  1. Reference::peel_to_id peels annotated tags. Pushing refs/tags/v1 with it sends the commit, so the tag silently arrives lightweight. Nothing fails: fsck --strict passes on both sides. Follow symrefs without peeling instead.

  2. PreviousValue::MustNotExist is not a create guard on its own. gix's file ref store short-circuits an edit whose new value equals the current one before evaluating the precondition, so re-creating a branch at the same commit silently succeeds. Explicit existence checks are required.

This needs checking against gunnar-store's fs backend, which delegates ref edits to the same gix machinery, so the conformance suite may have the same hole.

Because gunnar-client runs on a normal OS, it uses nornir-git and gix's porcelain freely: clone into a real working tree, check out, read ~/.gitconfig. None of §4 constrains it.

Client scope: whatever gix's porcelain supports. commit is mandatory — a git client without commit is not a client. Verified present in gix 0.86: Repository::commit / commit_as, write_object, index_from_tree, merge_trees, merge_commits, merge_base. So commit, log, diff, status, branch, checkout, tag and merge are all cheap. Implement them, do not reimplement them.

The one gap: gix has no push. See §2. gunnar implements send-pack itself on top of gunnar-pktline + gunnar-pack, which the server needs anyway.

Version status: RESOLVED 2026-08-02. nornir-git 0.1.5 is published on crates.io.

The dependency is therefore plain, with no [patch.crates-io] block and no sibling checkout required:

[dependencies]
nornir-git = "0.1.5"

gunnar builds standalone from a fresh clone. Do not reintroduce a path dependency on ../edda; vetra and nordisk are separate trees, and the coupling was only ever a pre-publication stopgap.

What was verified, in order:

What does NOT exist anywhere in the constellation, and is therefore gunnar's real content: the server side. git-upload-pack and git-receive-pack as things gunnar answers, an SSH server, a pack writer, a policy gate, and a gRPC control plane. That is the work.


4. Storage: the decision that shapes the SERVER

Scope: this entire section is about gunnar-server. gunnar-client opens gix::Repository on a real path and is not affected by any of it.

The finding

gix is filesystem-dependent in exactly one place, and it is not the place you would guess:

layer crate seam verdict
objects gix-object pub trait Find / Exists / Header / Write abstractable today. Everything generic over them (gix-pack counting, gix-traverse, gix-revwalk, gix-diff) is storage-agnostic already.
refs gix-ref none. One store: store::file::Store { git_dir: PathBuf, … }, loose files + packed-refs, atomicity via gix-lock rename not abstractable. Must be reimplemented.
porcelain gix::Repository gix::open discovers .git via gix-fs/gix-discover unusable off-filesystem.

Keys were never the problem. Refs are. Key material is bytes and goes in the store like anything else. But every ref update in git is atomically replace this small file, and gix implements that once, against POSIX.

And because the server is worktree-free (§0), refs and objects are the only two things to abstract. No index, no checkout, no filters, no attributes. Two traits is the entire surface.

The consequence

The gunnar server does not go through gix::Repository. It goes through the plumbing crates and its own two traits. (The client does the opposite, and should.)

/// Immutable, content-addressed, write-once, read-random.
/// Exactly znippy's shape.
pub trait ObjectStore: Send + Sync {
    fn contains(&self, id: &gix_hash::oid) -> bool;
    fn read(&self, id: &gix_hash::oid, buf: &mut Vec<u8>) -> Result<Option<Object<'_>>>;
    fn header(&self, id: &gix_hash::oid) -> Result<Option<Header>>;
    fn write(&self, kind: Kind, bytes: &[u8]) -> Result<ObjectId>;
    fn hash_kind(&self) -> gix_hash::Kind;   // sha1 AND sha256, per repo
}

/// Small, mutable, needs compare-and-swap. NOT an archive's shape.
pub trait RefStore: Send + Sync {
    fn read(&self, name: &FullName) -> Result<Option<Target>>;
    fn iter(&self, prefix: Option<&str>) -> Result<Vec<(FullName, Target)>>;
    /// ALL-OR-NOTHING across the batch, with per-ref old-value CAS.
    /// This is what makes a push atomic. It is the hard one.
    fn transaction(&self, edits: Vec<RefEdit>) -> Result<Vec<RefEdit>>;
    fn head(&self) -> Result<Option<Target>>;
    fn set_head(&self, target: Target) -> Result<()>;
}

ObjectStore gets a blanket impl of gix_object::{Find, Exists, Header}. That one adapter is what makes every gix plumbing algorithm work over znippy unmodified.

Backends

backend ObjectStore RefStore notes
fs (default) loose + packs on disk gix-ref file store the normal-OS path; delegate to gix, do not reinvent
znippy cold tier, oid-keyed, written by repack() __gunnar_refs__, an Arrow IPC log (§4.5) the off-filesystem answer, for BOTH
pack incoming tier, the pushed packfile, kept as-is — already built: gunnar-pack::index_pack
iceberg via IcebergZnippyReader possible via catalog OFF by default; drags skade
mem HashMap HashMap tests

The default off-filesystem configuration is TieredObjectStore { incoming: packs, cold: znippy } + ArrowLogRefStore, over one .znippy per repo plus a live refs.arrow sidecar. No database.

redb was in this table and is gone (D18). It was carrying two jobs, and neither survived scrutiny. The "hot object tier" was a database inserted between two archive formats. A push arrives as a packfile, already an immutable indexed container, so converting it into key-values only to convert it back at repack() was work for its own sake. Git's own answer is many immutable containers plus a periodic merge, and that is now the design. The ref store then turned out to be a better fit for an Arrow IPC log than for a B-tree (§4.5).

⚠ CORRECTION, measured 2026-08-03. An earlier version of this paragraph claimed the change left "one storage technology, no libc". That was wrong on both counts and the dependency arithmetic went the other way. libc already arrives via gix-features/gix-utils regardless of storage. And on gunnar-store's tree, 21 of 87 crates exist only because of arrow (chrono, iana-time-zone, num-bigint, num-complex, half, flatbuffers among them), against redb's closure of one.

The design reasons for D18 stand — batch atomicity from the IPC frame instead of a transaction manager, and the reflog for free (§4.5). The dependency justification does not. Keep the decision; drop the bad argument.

The cost is only acceptable because arrow is already paid for by the cold tier: znippy's index is Arrow IPC, so any build with znippy has arrow regardless, and the ref log's marginal cost there is zero. That is true of the appliance configuration. It is not true of the plain fs configuration, where gix-ref handles refs and arrow arrives only because fs.rs reuses the arrow-log secret store. See the follow-up in §16.

Corrected: an earlier version ended "no C toolchain is required either way, so the hard constraint holds". §2's correction (above) retracts that rule as unachievable: russh forces ring or aws-lc-rs, both C. What holds is the amended rule: no unvendored C and no system libraries. Neither storage choice changes that either way.

znippy: RESOLVED 2026-08-02. Objects are keyed by oid, in two tiers.

Decision. ObjectStore is tiered:

TieredObjectStore { incoming: PackObjects, cold: ZnippyObjects }

This block used to read { hot: RedbObjects, … }. It survived D18 by being a code block rather than prose, and every doc written from it inherited redb months after the decision to drop it. If a builder copies one line from §4, it is this one. Keep it correct.

Reads check hot first, then cold. Writes always go hot.

This is git's own loose → pack → repack design. That the shape falls out identically from znippy's constraints is the strongest available signal it is right.

Why oid-as-relative_path, and why NOT packs-in-znippy

Three properties of znippy's index are made for git object storage:

  1. A sorted, seekable keyspace. The lookup sub-index is the base columns re-sorted by (relative_path, chunk_seq): a file's chunks land contiguous, keys land in byte-lexicographic order. That ordering is the substrate both index structures below stand on. Which index to build over it is settled in §4.1, and it is not the built-in fst trie.
  2. Per-chunk blake3 verification on every read. get_file verifies each chunk against its stored 32-byte checksum before returning bytes. Git object integrity checking, always on, at no cost.
  3. Objects become queryable from outside. The lookup sub-index is a valid Arrow IPC stream; slice it by the manifest's index_offset/index_len and hand it to DuckDB / Polars / DataFusion. Zero gunnar code.

Packs-in-znippy is rejected. It discards the sorted lookup, the SQL surface and the per-chunk checksums, and forces a second hand-written index layer on top. znippy is a better packfile than a packfile; wrapping one in the other is strictly worse.

✔ RESOLVED 2026-08-04 — was: ⚠ UNRESOLVED, this rejection and P-005 cannot both stand (2026-08-03)

The answer is (b): the packfile is the cold tier, and znippy is not adopted. Argued in cold-tier-decision.md and settled here, so this block stops being an open question the rest of the document has to route around. The paragraph below is kept as the reasoning; only its verdict has changed.

Implementing repack() forced the question and it had no answer inside this document.

P-005 says the cold tier must store the deflated/packed representation. If it stored inflated content, then every object that ages out of the incoming tier could never be pack-copied again, and since nearly every object eventually ages out, that forecloses P-001, the largest single measured win in the performance analysis (28.49 ms → 0.91 ms per MiB per core).

But storing pack entries — type/size varint, delta base reference, zlib stream — is storing a packfile. Which is precisely what the paragraph above rejects.

So §4 and P-005 are in direct conflict, and two further findings push the same way. P-006: znippy's OpenZL codec cmake-builds C/C++ and fetches from GitHub during cargo build, against §2's appliance rule. P-014: znippy has no writer lock, so gunnar would have to supply the single-writer guarantee znippy does not.

Resolved in part, and only in part. repack() is built over a consolidated packfile, which is representation (b) by construction, is git's own cold tier, and needs no new dependency. The §4.4/D14 swap protocol is written against the tier as a seam, so it does not change when the tier does. P-001 is unblocked today: the generator runs in PackCopyAndBaseObjects mode and RepackOutcome reports objects_copied_from_pack against objects_recompressed, because a byte count and a wall clock cannot tell a pack-copy from a re-deflate. Measured on the sha256 fixture: 11 copied, 1 recompressed, the one a loose object with no pack residency.

Answered, and the answer is no. ZnippyObjects is not written; §4's TieredObjectStore { incoming: packs, cold: ZnippyObjects } is { incoming: packs, cold: PackObjects }, and znippy's row moves from backend to export tier. It is not a preference — two root rules break, and neither can be selected away. Both re-verified against the published crates with cargo tree -e normal on a scratch crate, oden, 2026-08-04:

  • LAW 3 (rayon is forbidden). znippy-common 0.9.12 → sysinfo 0.39.6 → rayon 1.12.0 + rayon-core 1.13.0. The edge is not incidental: znippy-common/Cargo.toml:220-222 enables sysinfo's multithread feature explicitly. gunnar's Cargo.lock holds 0 rayon rows today, and LAW 3 is ROOT LAW #0. Adopting znippy means adopting rayon.
  • §0's "zero shell" and §2's appliance rule. openzl-sys-rs 0.2.0 is a plain, unconditional [dependencies] entry of znippy-common — no optional, no feature gate. Its build.rs:6 pins https://github.com/facebook/openzl/archive/ca28e4bc….tar.gz, :76 fetches it with Command::new("curl"), :29 cmake-builds it and :57 links stdc++. S-007 spent a wave removing gix-command to make "no subprocess" true; this puts curl and tar back, at build time rather than run time.

The change-conditions that would reopen it are listed in cold-tier-decision.md ("What would have to be true to change the answer"). Until all four land, the seam stays — RepackOptions::cold_tier() and the §4.4/D14 swap protocol are written against the tier as a seam, so nothing above it changes when the tier does.

Chunking is handled: files over file_split_block_size (10 MB) span several rows sharing a relative_path, ordered by chunk_seq and placed at fdata_offset. Large blobs need no special handling.

4.1 The oid index: stree, NOT the fst trie

znippy's built-in fst::Map trie is the wrong structure for git oids, and the right one is already in the tree.

To be precise about what the trie is: the sorted lookup sub-index and the fst trie are core znippy, built for every archive by meta_sink.rs::write_lookup_and_trie at finalize. No plugin involved. They index relative_path, generically.

The mismatch is not with znippy's design, it is with git oids specifically: they are fixed-width and uniformly random, so they share no prefixes. An fst over such keys gets no prefix compression and still walks its automaton byte by byte per lookup. And there is no batch path.

Use stree, in znippy-zoomies/src/stree.rs: STree64 / STree64Mmap. A static search tree over sorted i64 keys, one cache-line node, branchless SIMD compare (_mm256_cmpgt_epi64), prefetched batch traversal.

⚠ MEASURED 2026-08-03. The choice survives, the argument for it does not. This section quoted znippy's own "≈40× binary search". That figure does not apply here. It is raw i64 keys with no verification step; a git lookup also memcmps the full oid out of a second, large, randomly-accessed array, and that access dominates.

objects binsearch stree serial stree batch batch vs binsearch
100k 147.7 ns 98.3 97.9 1.51×
1M 260.8 ns 262.3 146.8 1.78×
8M 522.0 ns 363.6 199.1 2.62×

Two things to take from it. stree's serial path is a wash with binary search — 0.99× at 1M — so anyone assuming stree is faster per-lookup is wrong. The batch path is the entire win, at 1.5–2.6× rather than 40×, and the margin grows with size exactly as the memory-latency-overlap argument predicts. The fst rejection is confirmed independently: fst loses ~4× and costs ~1.8× more to build.

Verified by two independent runs at full size. Keep stree; stop quoting 40×.

Key derivation: first 8 bytes of the oid → order-preserving i64 → find_exact → verify the full oid on the candidate.

⚠ The sign bit, corrected 2026-08-02. An earlier draft of this spec was wrong. i64::from_be_bytes(oid[..8]) is NOT order-preserving with respect to oid byte order: any oid whose first byte is 0x80 or higher becomes negative, so it sorts before every oid starting 0x00..0x7f. Measured:

first 4 bytes i64::from_be_bytes
00112233 4822677042757632
7fffffff 9223372032559808512
80000000 −9223372036854775808

Since roughly half of all oids start with a high bit set, half the keyspace lands on the wrong side. Flip the top bit: (u64::from_be_bytes(first8) ^ (1 << 63)) as i64 maps unsigned order onto signed order exactly. With that, the stree key rank equals the oid-lexicographic rank.

⚠ What the flip actually buys, corrected 2026-08-03 against the implementation. An earlier version of this paragraph said the flip means "no parallel rows/ordinals array is needed and the section shrinks". That is right about ordinals and wrong about rows:

The znippy implementation now uses the XOR key (__gunnar_oid__ section version 2; the reader requires an exact version match, because a v1 section has the same bytes in the same places under a different sort order and a permissive reader would hand back wrong rows instead of failing). Guard: entry_order_is_oid_lexicographic_across_the_sign_boundary, seen RED with the flip removed. Every other test in that crate stayed green through it; they were all built from low-first-byte oids.

Two further stree facts the API does not advertise, learned by building against it:

8-byte collisions are vanishingly rare but are not impossible and must be handled as a short candidate run, never assumed away. A test must construct a deliberate 8-byte-prefix collision and be seen to fail before the verify step is trusted (LAW 2).

The batch path is what actually decides it. Serving a pack means hundreds to thousands of object lookups at once, and stree has lookup_batch_pipeline::<P> / batch_stream::<P>, software-pipelined and prefetched, for precisely that shape. fst has no batch path at all. Git's hot path is a batch path.

access index why
oid → object: pack serving, connectivity check, have/want stree fixed-width keys, SIMD batch lookup, mmap-able, static
abbreviated oid, git show 7fd1a60 sorted lookup column, or the stock trie prefix scan. Never on the wire: upload-pack/receive-pack always carry full oids. A gRPC/UI convenience only.
path → file, znippy's own job stock fst trie, untouched not git's access pattern

stree cannot do inserts. That is not a limitation here: the cold tier is immutable and written once by repack(). A static index for a static store — the same reason the tiering works at all.

4.2 The git znippy package: three reserved indexes

A custom znippy package for git, carrying three reserved modules. Base columns already supply object type and size, so listing, quota gates and type filtering need no object reads at all.

# module contents buys cost
1 __gunnar_oid__ stree over first-8-bytes-of-oid → lookup row required. Every have/want, connectivity check and object read. The batch path (lookup_batch_pipeline::<P>) is the real win: one pack served is thousands of lookups. low
2 __gunnar_graph__ oid, parents[], tree, committer_time, generation as Arrow merge-base, ancestry tests and negotiation become integer comparisons on generation numbers, not object walks. Refs.Log becomes a scan. Queryable from DuckDB for free. low once Arrow is there
3 __gunnar_reach__ reachability bitmaps (roaring or EWAH) per selected commit where the actual pack-serving performance is. want − have becomes a bitmap ANDNOT instead of a graph traversal, the technique behind fast clones at scale. high

All three are in scope (decided 2026-08-02). 1 and 2 land in wave 9b; 3 gets its own wave (10b) because of its cost, and no speedup is claimed for it until measured on a real repository with real merge history (LAW 7). In scope ≠ number earned.

This is what gix-commitgraph and git's .bitmap files are, done natively. Module 2 is queryable by anything that reads Arrow on top of that.

The write cost that forces the tiering

meta_sink_append.rs does support append. ArrowIpcSinkAppend::open_existing reopens a sealed archive, computes blob_end as the lowest index_offset over all sub-index entries, truncates the rebuildable metadata tail, and accepts new blobs at that offset.

But finish() re-sorts the merged old+new row set and rebuilds the whole fst trie, manifest and footer. That is O(total objects), not O(new objects). A three-object push into a two-million-object repo would rebuild a two-million-key trie.

Therefore: never append the OBJECT archive per push. Pushed packs are kept as packs; the object archive is written by repack(), where the O(total) cost is amortised and expected. (The tiny __gunnar_refs__ log is a different matter: hundreds of rows, not millions, so appending it per push is cheap. §4.5.)

4.3 Granularity: one .znippy per git repo (D12)

znippy can hold many repos in one archive: GroupKey { pkg_type, repo, module_name } carries a repo field and the manifest is one row per (pkg_type, repo, module_name) section, so sub-repos are native. P-1 ("one archive = one ecosystem", selected at write time by --format) is about the ecosystem, not the repo count; a namespace-wide git archive would be legal.

We still take one archive per repo, for one concrete reason: finish() is O(total rows in the archive) (§ above). A namespace-wide archive would make every repo's repack pay for every other repo in that namespace. Per-repo also makes repo deletion a file unlink and keeps one repo's corruption from touching its neighbours.

The multi-repo capability is retained as a later option for archives of many small, rarely-written repos, where the per-archive overhead dominates. Not now.

4.5 Refs as an Arrow IPC log: __gunnar_refs__ (D18)

Refs are a log-structured table, not a B-tree. One RecordBatch per push, appended to an Arrow IPC stream.

schema: refname: Utf8 · old_oid: FixedSizeBinary · new_oid: FixedSizeBinary(nullable)
      · actor: Utf8 · at_unix_ms: Int64
property how it is obtained
append is O(new) one batch per push. No re-seal, no rewrite of prior batches.
batch atomicity one push = one RecordBatch = one framed IPC message.
torn-write recovery read messages until one is short or malformed; truncate there.
current state replay, last-wins per refname. Refs are few, so this is instant on open.
reflog the log of (ref, old, new, actor, at) is the reflog.
on fs true since 2026-08-04, and it was not before. FsRefStore journals every committed transaction into this log (fs.rs, after gix's commit). gix-ref remains the single authority for a reference; the log is a record of what it did, not a second opinion, so the journal takes the applied edits — which carry the resolved previous value — and never re-runs the CAS. A journal failure is an error! and not a failed push: the reference has already moved and been fsynced.
readable gunnar_store::history::History::read + gunnar reflog. Takes no lock and opens no catalog, so it answers while the server holds the store's O_EXCL lock — which is the only state a repository worth asking about is ever in.
sealable History::seal: one Arrow stream, one batch, push boundary as a push_seq column not a frame. 99.7 B/push vs the live log's 666.2 = 6.68×, 201 pushes, oden 2026-08-04. Framing buys independent parseability only while a writer can be interrupted mid-frame; a sealed artifact is past that point.
queryable DuckDB over the stream. "who moved main last week" is SQL.
compaction rewrite as one batch at repack().
Why atomicity is free here

An Arrow IPC stream frames every message as CONTINUATION_MARKER (0xFFFFFFFF) plus a 4-byte length, then metadata and body; end-of-stream is 0xFFFFFFFF 0x00000000 (verified against arrow-ipc 58). So a push that updates three refs writes one length-prefixed message. Either that message is complete on disk or it is not.

All-or-nothing is a property of the framing, not of a transaction manager. This is a fix by construction, not a guard — the same move as making the gRPC Refs service read-only. It is why no crash-injection proof of a bespoke transaction layer is needed: there is no transaction layer.

The single-writer requirement, and why it is not an Arrow limitation

Compare-and-swap means "read current, compare, write if unchanged", which is only correct if nothing writes in between. redb had the same constraint (one writer, many readers), so nothing was lost by dropping it. Git works this way too: <ref>.lock plus rename, packed-refs.lock. Lock-then-CAS is git's own ref model.

Three things that must be got right

These are small, testable, and must each be seen to fail before being trusted:

  1. fsync before acknowledging a push. A frame is atomic on disk only once it is on disk. A test must kill the process between write and fsync and assert the push was not reported as accepted.
  2. The cross-process lock is load-bearing. A test must start a second writer and assert it is refused, not merely that the first still works.
  3. A partial trailing message is TRUNCATED on open, never merely skipped. If it is only skipped, the next append lands after garbage and every later read stops there. That is silent data loss that looks exactly like an empty repo. Assert the file length afterwards, not just that reads succeed.
Secrets ride the same mechanism

__gunnar_secrets__ is the same shape: a small Arrow IPC log, last-wins per key, holding the SSH host key and the bootstrap admin keys (§5.1). The SecretStore trait is unchanged; only its backend is.

D26 — sealing them, and where the key-encrypting key lives

The format reserved __gunnar_secrets__ for already-encrypted material, and nothing encrypted anything. FoldRequest therefore deliberately carried no secrets at all, so the gap could not be closed by accident with a plaintext key. SealedSecretStore (gunnar_core::seal) is what had to exist first.

Shape. A decorator over the SecretStore trait, not a fourth backend, so mem, arrow_log and fs all seal through one implementation and a backend cannot forget to seal — the backend is not what seals (LAW 5). AES-256-GCM out of aws-lc-rs, which russh already links statically (123 AWS_LC strings in the stripped 15 MB appliance binary, 0 PT_INTERP, 0 DT_NEEDED). One appliance gets one crypto backend; not ring, not a pure-Rust AEAD sitting beside the AES that is already there. The AAD is the secret's key name, so a sealed ssh/host_ed25519 cannot be lifted out of the log and replayed into another slot.

The hard question is custody, not the cipher. An appliance boots with no operator to type a passphrase. Four places a KEK can live; gunnar takes two:

  1. Beside the data, on the appliance's own disk — secrets.kek, 32 bytes, mode 0600, in the control directory, outside the Arrow secret log and outside anything a FoldRequest reaches. The default.
  2. Handed in from outside the machine — GUNNAR_SECRET_KEK, never written to disk. The seam for a KMS, a Kubernetes secret, or an initramfs that unwraps against a TPM before execing gunnar.
  3. An operator passphrase at boot — rejected, it contradicts unattended boot.
  4. A key derived from machine identity (machine-id, disk serial) — rejected, that is obfuscation wearing a KDF, and calling it encryption is worse than not encrypting.

What (1) defeats. Every path by which the data leaves without the disk: a cold-tier .znippy copied to object storage, a backup, an archive handed to a customer, a secrets.arrow pulled out of a replica, a log line that dumps stored bytes. Those are the realistic exfiltration routes for an appliance whose design goal is that its archive be portable, and against all of them the KEK stays behind.

What (1) does not defeat. An attacker with the appliance's disk, or root on the running appliance, or its memory. The KEK is one read(2) away for anyone with those. Option (1) is domain separation between the portable artifact and the machine, not confidentiality against physical possession — say it that way, because a sealed secret whose unlock key sits beside it is theatre if you claim more. Anyone needing the second property uses option (2), and then the KEK is genuinely not on the disk at all.

A defect this exposed, older than the sealing. The secret log is append-only and the only compaction trigger was a batch count, so a superseded value stayed in secrets.arrow — and a secret log sees a handful of writes in its whole life, so rotating the SSH host key left the old private key readable in the file for practical purposes forever, while this document and the module header both claimed a rotated key "must actually stop existing". Compaction now runs on the write that supersedes a value, and failing to remove the old one is an error rather than a warning.

Layout, symmetric with objects
incoming/*.pack   →  objects.znippy      immutable content · folded at repack
refs.arrow (live) →  __gunnar_refs__     mutable pointers  · compacted at repack

Live appends go to a sidecar stream so they never touch the sealed archive; repack() folds the compacted state in as a reserved section. Same format in both places.

4.4 repack(): write a new archive, swap atomically (D14)

repack() does not append. It writes a new .znippy from (old archive ∪ hot tier) and swaps it in atomically, then truncates the hot tier.

This is barely more expensive than appending — ArrowIpcSinkAppend::finish() already rebuilds the entire sorted lookup, trie and manifest — and it is strictly safer:

Incoming-pack removal happens only after the swap succeeds and the new archive has been opened and verified.

The git package's schema, and how it hangs off znippy's extension points

(Naming note: the three modules are __gunnar_oid__, __gunnar_graph__ and __gunnar_reach__ per D7/§4.2. git is a native znippy package format (D8), not a znippy-plugin-git; the plugin route is the rejected alternative, kept below only to show which znippy mechanisms the native format reuses.)

znippy's plugin system (znippy-common/src/plugin.rs, trait ZnippyPlugin) composes the archive schema as base columns + the union of every plugin's schema_fields() in a DenseUnion, each plugin filling only its own columns. Separately, reserved manifest modules (pkg_type = i8::MIN / RESERVED_PKG_TYPE, module_name = "__znippy_lookup__" / "__znippy_trie__") let extra sub-indexes ride along; read_znippy_manifest filters them out so ordinary readers never trip on them.

A git plugin therefore carries git-specific Arrow columns and its own reserved sub-indexes:

column / module carries buys
object_type blob / tree / commit / tag typed listing with no object reads
object_size uncompressed size size analytics, quota gates
__gunnar_graph__ oid, parents[], tree, committer_time, generation the commit graph as an Arrow table

The third is the significant one. Refs.Log over gRPC becomes an Arrow scan, not a graph walk, and the commit graph is queryable from DuckDB with zero gunnar code. gix-commitgraph exists for exactly this; here it is native and queryable.

Note P-1: one archive = one ecosystem, selected at write time. One archive per repo (or per namespace) fits that law exactly.

Ownership: DECIDED 2026-08-02, a NATIVE znippy package format

git becomes a first-class znippy package format, living in the znippy repo alongside znippy-plugin-maven, -python, -media, -rust-toolchain and -skidbladnir, registered in znippy-cli/src/handlers.rs::builtin_handlers() and discovered through meta() per P-3. znippy-plugin-skidbladnir is the precedent: a nordisk-internal format already lives natively in znippy.

This dissolves the upstream task. As a built-in, __gunnar_oid__, __gunnar_graph__ and __gunnar_reach__ are known module names, so is_reserved_module is extended with them directly and needs no generalisation, no naming convention, and no registration API. The pre-existing-archive reclassification risk disappears with it.

Two further wins from going native: stree already lives in znippy-zoomies, so the index code sits beside the structure it uses; and a git archive becomes queryable from DuckDB as a znippy capability rather than a gunnar bolt-on.

The consequence to plan around: release coupling. A change to the git schema is now a znippy release before gunnar can consume it. Both repos are Rickard's, so this is a cadence question, not a blocker. But gunnar's Cargo.toml must pin a published znippy-common version and must never carry a path dep on ../znippy (the same rule that removed the nornir-git patch in §3).

wasm was rejected, and stays rejected. The stree hot path needs AVX2 (_mm256_cmpgt_epi64), an mmap of the archive, and prefetch. wasm offers SIMD128 only, no host mmap, no prefetch, so the ~40× evaporates and the complexity stays. The appliance server would also have to ship a wasm runtime, detonating the dependency budget (§2) for the one build that can least afford it. wasm remains right where znippy already uses it: sandboxing untrusted per-file format parsers. Git object indexing is neither untrusted nor per-file-parse-shaped.

Already public and used as-is: RESERVED_PKG_TYPE, ArchiveMetaSink::push_subindex, GroupKey (all fields pub), read_reserved_section_bytes.

Prior notes retained

znippy-common already exposes the seam:

pub trait ZnippyReader: Send + Sync {
    fn list_files(&self) -> Result<Vec<String>>;
    fn extract_file(&self, relative_path: &str) -> Result<Vec<u8>>;
    fn contains(&self, relative_path: &str) -> bool;
    fn file_size(&self, relative_path: &str) -> Option<u64>;
    fn extract_files(&self, paths: &[&str]) -> Vec<Result<Vec<u8>>>;   // batch
}
pub trait ArchiveMetaSink { /* push_subindex, finish */ }

with per-chunk blob_offset / blob_size random access and a per-chunk blake3 over uncompressed bytes. znippy-iceberg already ships IcebergZnippyReader. The seam is built; gunnar consumes it.

Objects → znippy is an excellent fit. Immutable, content-addressed, write-once, read-random, batch-readable. That is znippy's exact shape, and extract_files maps straight onto "serve a pack" (which needs hundreds of objects at once).

Refs → znippy is a bad fit and must not be attempted. An archive cannot compare-and-swap a 41-byte value by rewriting the whole sealed object archive. Refs go to their OWN small Arrow IPC log instead (§4.5): same technology, different granularity.

The cheap fallback

tmpfs + the fs backend ("pretend to have a filesystem") is kept as a backend, not as the design. It is the honest fast path for an appliance with RAM to spare and it must not become the excuse not to build the traits.


5. Identity, auth, ACL: server side

On the server: no OS users, no authorized_keys, no home directory. gunnar owns its own table, in the RefStore backend's storage. (On the client, by contrast, ~/.ssh/id_ed25519, ssh-agent and ~/.ssh/known_hosts are correct and expected. §0.)

Principal { id, name, kind: User | Machine | Deploy | Anonymous }
PublicKey { fingerprint (sha256), algo, principal_id, added, last_used, expires? }
Namespace { name }                      // multi-tenancy, from day 1 — §9
Repo      { namespace, name, hash_kind, default_branch, visibility }
Grant     { principal, scope: Server|Namespace|Repo, level: Read | Write | Admin }

5.2 One sandbox, one fingerprint: the twinning that must not survive (D19, D20)

gunnar-core and gunnar-ssh were built concurrently and each grew its own path sandbox, key fingerprint, host-key loader and auth lookup. That is exactly the twinning LAW 5 forbids, and it is worse than it looks: the two sandboxes disagree. One enforces exactly two path components; the other permits nested namespaces. Two security checks that disagree are worse than either alone, because the safe one gives false confidence about the permissive one.

Resolution: gunnar-ssh depends on gunnar-core and deletes its copies.

the one implementation lives in
path sandbox → (namespace, repo) gunnar_core::sandbox::resolve_repo_path
key fingerprint gunnar_core::auth::Fingerprint
host key gunnar_core::host_key::ensure_host_key
principal + grant lookup gunnar_core::auth::AuthTable

Nested namespaces are permitted (team/sub/repo.git → namespace team/sub), and grants do not inherit, so team does not imply team/sub. The permissive shape is a superset, and the escalation risk it would otherwise carry is closed by the no-inheritance rule, which must stay pinned by a test.

Unquoting belongs in the SSH command parser, not in the sandbox. git sends one argv element with the quotes inside it (§5); stripping them is a transport concern. The sandbox receives an already-unquoted string and should not know SSH exists.

The ssh-key version split (D20)

Measured: gunnar-ssh resolves ssh-key 0.7.0-rc.10 (forced by russh 0.61.2, which pins it exactly), while gunnar-core resolves 0.6.7 from the workspace. Semver-incompatible, both in the graph, and auth_publickey hands over the 0.7-rc type, so a key from russh can never be passed to a 0.6 API.

Therefore the fingerprint is defined over bytes, not over a type:

/// SHA-256 over the SSH wire-format public key blob, rendered `SHA256:<base64>`
/// exactly as `ssh-keygen -lf` prints it.
pub fn fingerprint_of_blob(ssh_wire_blob: &[u8]) -> Fingerprint;

Both ssh-key versions can produce that blob, so neither crate needs the other's types and the pin conflict stops being an integration problem. Keep the known-answer tests against real ssh-keygen -lf output.

Do not try to unify the versions by bumping the workspace to a release-candidate, and do not let a member crate silently take a second copy of a workspace dependency without saying so.

5.1 SecretStore: the seam for host key and admin bootstrap (D10)

Two open questions from the last round — 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: where do secrets live when there is no home directory. One trait answers both.

pub trait SecretStore: Send + Sync {
    fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
    fn put(&self, key: &str, value: &[u8]) -> Result<()>;
    fn delete(&self, key: &str) -> Result<()>;
    /// Atomic get-or-create — the host key must never be regenerated by a race.
    /// `&mut dyn FnMut`, NOT `impl FnOnce` — an `impl` parameter makes the trait
    /// non-object-safe, and §4/§5 both require `Arc<dyn SecretStore>`. A `SecretStoreExt`
    /// provides the `FnOnce` call site.
    fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>>;
}

SSH host key: get_or_create("ssh/host_ed25519"), minted in-process via ssh-key (no ssh-keygen). It must be persisted and stable across reboots. An appliance that regenerates its host key gives every client a host-key-changed warning, which on an escape-hatch product is exactly the wrong first impression. A test must assert the key is byte-identical across a simulated restart.

Admin bootstrap: on an empty key table, gunnar serve reads an admin public key from config/boot parameters and installs it as the first Admin principal. If none is supplied and the table is empty, log loudly and refuse to serve rather than start an unreachable server.

SSH auth flow: russh auth_publickey → fingerprint the offered key → look up → attach the Principal to the session. The SSH username is decorative; identity comes from the key. (git@host:ns/repo.git works, and git means nothing.)

Then exec_request parses git-upload-pack '<path>' / git-receive-pack '<path>', resolves <path> to a repo inside the sandbox, and checks the grant: upload-pack needs Read, receive-pack needs Write.

Path sandbox: pin this with tests that go red. .., symlinks, NUL bytes, unicode normalisation tricks, .git/../... A path escape here is the whole security model. Assert the rejection, and assert a legitimate nested path still resolves; a sandbox that rejects everything is the identity-value trap.

⚠ CORRECTION, measured against git 2.53.0 on 2026-08-03. An earlier version of this section said "reject absolute paths". That would have rejected every ssh:// clone. What stock git actually sends, captured with a GIT_SSH_COMMAND that echoes argv:

ssh://git@host/ns/repo.git   →   git-upload-pack '/ns/repo.git'     ← LEADING SLASH
git@host:ns/repo.git         →   git-upload-pack 'ns/repo.git'

Two things follow. The ssh:// form is always absolute. And the quotes are part of the string gunnar receives: git passes one argv element, sshd's shell normally strips the quoting, and gunnar has no shell.

So the sandbox strips one optional leading / and one matched quote pair, then validates. //… stays rejected. /etc/passwd resolves to the catalog key etc/passwd, which is a lookup miss and never a filesystem path.

Symlink escapes are not a string problem and cannot be caught in path validation at all — on the appliance a repo reference never becomes a path. That check belongs in the fs backend's path materialisation, tested against real symlinks out of a temp root.

Scope needs a third variant: Server. With only Namespace | Repo, the bootstrap admin cannot create the first namespace — there is nothing to scope a grant to — so the server boots reachable and useless. Scope::Server is a strict superset and changes nothing else.

may(...) returns Result<bool>, not bool. The table lives in storage and a read can fail; a bare bool has to invent an answer for that case, and the safe-looking invention (false) turns a storage fault into a silent permission denial.

Nested namespaces do NOT inherit grants: team does not imply team/sub. The alternative is silent privilege escalation. Pin it with a test rather than leaving it to be discovered.

Any exec that is not exactly those two commands ⇒ channel failure, no shell, ever.

5.3 Anonymous read for public repositories: RESOLVED 2026-08-03 (D23)

The gap this closed. Every operation required a registered key, so a stranger could not clone a public repository by any transport. Repo.visibility was in the model and nothing honoured it. That is the difference between a git host for people with accounts and a place you can publish something. It is also why adding HTTPS (D22) would not by itself have opened gunnar up: the missing piece was an unauthenticated principal, not a second port.

A Public repository is readable with no key. That is now what the word means.

⚠ A semantic change, and it is stated rather than implied. Public used to mean "readable by any authenticated principal". An operator who marked a repository public under the old reading has published it under the new one. Private is still the default for a new repository and is unchanged.

How an anonymous session arrives

SSH has no unauthenticated mode: a client must complete some userauth method before it may open a channel. Three shapes were available and the third was taken.

candidate verdict
accept none for every username Would break the server. OpenSSH's client always sends a none request first, to learn the method list; russh's server/encrypted.rs calls Handler::auth_none on it regardless of the advertised methods. A server that answers SUCCESS authenticates every stock client as anonymous before it ever offers its key. Every existing push starts failing and every existing user silently loses their identity.
a well-known published key The anonymous-FTP shape. A stranger must first obtain a key file, which is worse than the problem it solves, and gunnar would hold a key whose private half is public in the same table that authenticates real users.
none, on one reserved username Taken.
ssh://anonymous@host/ns/repo.git   →  auth_none accepted  →  Principal::anonymous()
ssh://git@host/ns/repo.git         →  auth_none refused   →  publickey, exactly as before

ANONYMOUS_SSH_USER is anonymous. none is never advertised in RusshConfig::methods, which stays publickey-only: it is available for one name, so advertising it to everybody would make the failure list a lie, and russh calls auth_none on the opening probe either way.

This is the one place gunnar reads the SSH username, and §5's "the username is decorative" survives it: the name selects an authentication method, not an authorisation outcome, and it selects the one identity that holds nothing. A username cannot be used to become somebody; this uses it to become nobody.

Authorisation is the same single decision (LAW 5)
Principal::anonymous()  →  id = "anonymous", kind = PrincipalKind::Anonymous
AuthTable::may(id, repo, level)  rule 0:
    Ok(level == Read && repo.visibility == Public)

There is no second ACL. may_namespace and may_server refuse the anonymous id by construction, so no control-plane question can ever be answered true for it. The id is reserved: Principal::new and AuthTable::add_principal both refuse it (Error::ReservedPrincipalId), so no principal can be registered under that name, no key can be attached to it (register_key → NoSuchPrincipal) and no grant can name it. Rule 0 is therefore a statement about a name that cannot be claimed.

What anonymous can never do
Indistinguishability

A private repository and one that does not exist produce the same refusal, byte for byte, to an unauthorised caller. Measured with one name on one server in two states, not with two names: the pkt-line length prefix follows the name the client chose (0046 vs 0047), so a comparison across two names can never be byte-for-byte and blanking enough of it to pass would blank the thing being measured.

The switch

SshConfig::allow_anonymous (gunnar serve --no-anonymous) closes the path at the listener, before any repository is resolved. Default on, because Private is the default for a repository and nothing is anonymously readable until somebody publishes it. It is not what protects private repositories; the ACL does that. It is for an appliance that must be able to state it has never served a stranger.

MetricsSnapshot::connections_anonymous counts keyless sessions apart from connections_accepted, because "how much of my traffic is strangers" is the first question after publishing anything.


6. The wire: gunnar-wire

git-upload-pack (serving fetch/clone), protocol v2

  1. Advertise capabilities: version 2, agent=gunnar/<v>, ls-refs=unborn, fetch=shallow filter wait-for-done, object-format=sha1|sha256, server-option.

The advertised fetch value must list every argument the server accepts. A stock client will not send filter unless filter appears in the advertised capability value. Advertising fetch=shallow wait-for-done while accepting filter <spec> means partial-clone support that silently never activates, and looks identical to working code. Keep this list and the argument list below in sync, and pin it with a test that asserts the advertisement contains every argument the parser handles.

  1. command=ls-refs, args symrefs, peel, ref-prefix <p>, unborn.

  2. command=fetch, args want <oid>, have <oid>, done, thin-pack, no-progress, include-tag, ofs-delta, deepen <n>, shallow <oid>, filter <spec>, wait-for-done. Response sections: acknowledgments / shallow-info / wanted-refs / packfile (sideband: pack on band 1, progress on 2, errors on 3).

v0 fallback must exist. Some clients still open v0, and ls_remote's own fallback in nornir-git proves the case is real.

object-format must be echoed from the repo's actual hash kind. Answering sha1 to a sha256 repo yields an empty response. This exact bug is documented in nornir-git/src/ssh.rs and cost a session. Pin it with a sha256 fixture repo.

git-receive-pack (serving push), protocol v0

  1. Advertise refs + capabilities: report-status, report-status-v2, delete-refs, side-band-64k, quiet, atomic, ofs-delta, object-format=…, push-options.
  2. Read the command list: <old-oid> <new-oid> <refname>, flush.
  3. Optional push-options section.
  4. Read the packfile, index it, verify connectivity (every ref's new tip and its full closure must exist afterwards; a thin pack's bases resolve against the existing store).
  5. Run the policy gate (§7). A rejection here rejects the ref, and with atomic, the whole push.
  6. Apply the ref transaction; atomic ⇒ all-or-nothing.
  7. Report status per ref, and emit the event (§8) + attestation (§10).

Negotiation is where a git server is actually hard. Budget for it. A have/want loop that terminates on the happy path but never on a divergent history is the classic failure. Test against a real repo with real merge history, not a three-commit fixture.

6.1 Other transports: git:// never, smart HTTP BUILT (D22 → D24, D25)

git:// is never built. The git daemon protocol has no authentication and no encryption: anyone can serve anything to anyone, and a client cannot tell. GitHub disabled it in 2022 for that reason. Adding an anonymous unauthenticated listener to a server whose entire identity model is the key is the principal would be a hole cut deliberately, for a transport nobody chooses any more.

Smart HTTP is BUILT (D24). gunnar-http, feature http, default OFF.

It is a wrapper, and that is the whole design

Smart HTTP carries the same pkt-line protocol gunnar-wire already implements. The crate holds no framing, no ls-refs, no negotiation and no pack writer; if it ever grows one, something has gone wrong. Four URLs, measured against git http-backend 2.53.0 on 2026-08-03 rather than read off a specification:

GET /<path>/info/refs?service=git-upload-pack Content-Type: application/x-git-upload-pack-advertisement. v0 opens 001e# service=git-upload-pack + flush + the ordinary advertisement; v2 sends no preamble at all and opens 000eversion 2.
POST /<path>/git-upload-pack …-request in, …-result out.
the same pair for git-receive-pack push, which is v0 on every transport.

Protocol v2 rides the same POSTs behind a Git-Protocol: version=2 header, whose value is the same string SSH sends as an environment variable and is parsed by the same ProtocolVersion::from_git_protocol.

Stateless-rpc is the one behavioural difference, and it is one flag: gunnar_wire::Session::StatelessRpc. Over SSH the negotiation state lives in the stack frame serving the connection; over HTTP each POST is independent, so the client re-sends its whole request every round and the server answers one round and stops. Getting it wrong produces no error — it produces a clone that hangs, because the server is reading a body that has already ended while the client waits for the response it is holding.

0002 is written by the CLIENT, not the server. gunnar-pktline has Packet::ResponseEnd and its doc comment says "protocol v2 stateless-rpc response end", which reads as though a server emits it. It does not. Every response captured from git http-backend — v0 and v2, advertisement and rpc — ends with a flush-pkt and nothing after it. response-end-pkt is written by git's own remote-curl helper, which proxies protocol v2 for a fetch running as a child process and has to mark where each HTTP response ended in the single pipe it feeds that child. gunnar's server emits none; the decoder keeps handling it for the day gunnar's client half speaks HTTP.

Why it matters at all: most people clone over HTTPS, it is the default in every forge's UI, and corporate networks routinely block port 22 outbound. SSH-only means unreachable from a lot of real networks.

D24 — what authenticates an HTTP client

SSH gives gunnar the key table for free: the key is the principal, and gunnar-policy and the gRPC sshsig credential (D9) are both built on it. HTTP has no such thing. Three shapes were available.

candidate verdict
HTTP Basic with passwords A second identity model: a password table, a hashing policy, a rotation story, a reset flow. Rejected.
sshsig over HTTP, as D9 does for gRPC Correct in principle and unusable in practice: stock git cannot produce one, and stock git is the only client that matters here. Rejected for the git verbs.
a principal-bound access token Taken.
Authorization: Basic <user>:<token>   →  AuthTable::principal_by_token(token)  →  Principal
Authorization: Bearer <token>         →  the same
(no header)                           →  Principal::anonymous()               (D23)
                                      →  AuthTable::may(id, repo, level)      ← the SAME call

A token is a second door, not a second ACL. It names a principal and holds nothing else: no scope, no level, no repository list. Every authorisation question it can produce is answered by AuthTable::may on that principal's grants, which is the call an SSH key produces. Revoking a token closes a door; it changes nobody's access. The test that pins this revokes the grant while leaving the token registered, and the access goes.

gunnar token add <principal> mints it, prints it once on stdout, and forgets it. gunnar-core::mint_token_secret is 256 bits of OS entropy as gunnar_pat_<base64url>; the prefix is not decoration, it is what makes a leaked token findable in a log or a commit.

D25 — TLS: gunnar terminates none, and says so

A git server that only ever runs behind a TLS terminator is the answer here, and the argument is not "it was easier".

If TLS is ever terminated in-process, it is rustls on aws-lc-rs, not ring. holger runs rustls on ring across its live connectors, and that is real production evidence — but it points the wrong way for gunnar, which already links aws-lc-sys statically via russh/ssh-key, inside the proven 15,066,088-byte scratch image (123 AWS_LC strings, 0 PT_INTERP, 0 DT_NEEDED). Choosing ring would put a second crypto backend in an appliance that has one. rustls takes aws-lc-rs as its provider, so gunnar can have holger's rustls without holger's ring. One appliance, one crypto backend.

The cost, measured

hyper 1 + hyper-util + http-body-util + http + bytes + base64, every one with default-features = false, and not a framework. All of them are already in the resolved appliance graph because tonic pulls them in for gRPC — gRPC is HTTP/2. Measured 2026-08-03 by cargo tree -e normal over both feature sets:

appliance         278 crates (267 third-party)
appliance+http    279 crates (267 third-party)     ← the delta is `gunnar-http` itself

xtask's dependency guard now resolves an appliance+http profile and fails if the third-party count moves. Seen red by giving gunnar-http one extra dependency: "the smart-HTTP feature adds 1 third-party crate(s) to the appliance image".

axum is deliberately not used. It is in the graph — under tonic's router — and the guard's ledger excuses it on the grounds that gunnar registers no HTTP route of its own. This transport keeps that sentence true by going straight to hyper. It also loses nothing: git's URLs are a suffix match over an arbitrary-depth prefix (/<any/nesting>/info/refs), which a router expresses no better than a match does, and a routing table would be a second place the route set is written.

Why gunnar-http depends on gunnar-ssh. The seam — GitTransport, ServiceRequest, Dispatch — lives there and is transport-neutral; Dispatch::authorise grew a sibling authorise_service that takes a URL path instead of an exec line and shares everything after the parse. A second seam in gunnar-http would be a second answer to "may this principal push", which is the failure this whole design is arranged against. The edge costs nothing: a gunnar that speaks HTTP also speaks SSH. If a no-SSH build is ever wanted, seam and Dispatch move to their own crate; that build does not exist today.

What P-001 has to do with it. D22 deferred this behind P-001 because gunnar spent 219× more CPU than stock git to serve one clone and a second transport onto a slow path only adds traffic. That reasoning stands for deployment and not for existence: the transport is default-off, so nothing is on a slow path until an operator asks for it, and the wrapper adds no cost of its own to the path P-001 is about.

gunnar-pack

Both hash kinds. Both directions. Empty packs are legal and must not crash.


7. gunnar-policy: the pre-receive gate

This is what makes gunnar a forge rather than a bare repo over SSH. It runs between "pack is indexed" and "refs are applied", and it can reject.

pub trait Gate: Send + Sync {
    fn name(&self) -> &str;
    fn check(&self, ctx: &PushContext) -> Result<Verdict>;   // Allow | Reject(reason)
}

PushContext carries: principal, repo, every RefUpdate { old, new, name }, the set of newly introduced objects, and an ObjectStore handle to read them.

Objects, not commits. A size gate must see blobs, and the commit set is derived (new_commits()) rather than stored alongside, so the two cannot disagree (LAW 5). new_objects is a required constructor argument, never a builder option with a default: an empty object set is a silent always-allow switch, and no test that supplies the field could ever notice it was optional.

Verdict::Rejection carries ref_name: Option<FullName>, because §8.1's PushRejected{repo, ref, gate, reason} and report-status-v2's per-ref ng lines both need attribution. On the refusal path only, the engine re-runs the gate against each single-ref sub-context to get it, so the Gate signature stays simple.

Accept both gpgsig and gpgsig-sha256 headers. Git writes the latter in sha1/sha256 interop repositories; reading only gpgsig makes such a commit look unsigned: merely loud under a required-signature policy, silently wrong under branch protection.

The CI dependency guard must scope to NORMAL edges. gunnar-policy (and others) carry gix as a dev-dependency for fixtures. Dev-deps never reach the appliance build, but a guard reading raw cargo metadata would trip on them.

⚠ And it has a feature-unification hole that cargo metadata cannot see. gunnar-client needs gix with status (which pulls dirwalk, index and blob-diff) and blocking-network-client. Under resolver 2, cargo build --workspace unifies those features into the S crates' gix too. Feature declarations are per-crate, so a guard reading declarations passes while the actual workspace build has already dragged worktree features into the server crates.

The appliance build (--no-default-features --features server) is unaffected, and nornir-git forces some of this regardless of what gunnar declares. The guard must therefore assert on a RESOLVED graph for the appliance feature set, not on declared features. Otherwise it is exactly the kind of check that can never report red.

Gates in v1:

⚠ CORRECTION 2026-08-03. This said "reject a 4 GB blob before it lands", which contradicts §6. §6 puts pack-read-and-index at step 4 and the policy gate at step 5 — so by the time any gate runs, the bytes have already arrived. The gate rejects before refs are applied, which is a different and weaker guarantee.

Refusing 4 GB before receiving it needs a streaming ceiling in gunnar-pack, not a gate. LimitPolicy::max_pack_bytes is the seam for it.

And when a limit is configured but the size was never measured, the gate rejects. A limit that cannot be enforced is not a satisfied limit; the failure must be loud.

Hooks are in-process Gate impls, not shell scripts. There is no shell. A config-driven declarative gate covers the real cases; an out-of-process hook would be a gRPC call to a gate service, never fork/exec.

LAW 2 applies hardest here. Every gate must be seen to REJECT a crafted bad push before it is trusted to allow a good one. A gate that always returns Allow is indistinguishable from a working gate in any test that only pushes valid commits.


8. gRPC: gunnar-proto / gunnar-grpc

The only control plane. Proposed surface:

service Repos {
  rpc Create(...); rpc Delete(...); rpc List(...); rpc Get(...);
  rpc SetDefaultBranch(...); rpc SetVisibility(...);
}
service Refs {
  rpc List(...); rpc Get(...); rpc Log(...);          // commit walk, paged
  rpc ReadObject(...);                                 // blob/tree/commit/tag
  rpc Diff(...);
}
service Access {
  rpc AddPrincipal(...); rpc AddKey(...); rpc RevokeKey(...);
  rpc Grant(...); rpc Revoke(...); rpc ListGrants(...);
}
service Policy {
  rpc GetProtection(...); rpc SetProtection(...); rpc ListGates(...);
}
service Events {
  rpc Watch(WatchRequest) returns (stream Event);      // ← §8.1
}
service Admin {
  rpc Health(...); rpc Stats(...); rpc StorageInfo(...);   // which backend, sizes
}
service Mirror {
  rpc Add(...); rpc Sync(...); rpc Status(...);         // ← §11
}

8.2 Two listeners, one identity table (D9, D13)

SSH and gRPC are separate listeners. Git traffic never passes through gRPC; there is no SSH-over-gRPC bridge and none is wanted.

:22   (or :2222)  SSH   — git-upload-pack / git-receive-pack.  publickey auth, always.
:50051            gRPC  — the control plane.  sshsig credential + §5 grant, always.

Separate listeners, separate protocols — and one key table behind both.

✅ D9 RESOLVED 2026-08-03: gRPC authenticates with the §5 SSH keys.

v1 shipped allow-all, deliberately, to get the surface working. That hole is closed. The three candidates this section used to leave open were mTLS, a bearer token in metadata, and SSH-key-derived identity reusing the §5 key table. The third was taken.

The mechanism

A caller signs a short, single-use challenge with a key already in the §5 key table. The signature is an sshsig, the format ssh-keygen -Y sign produces and the one gunnar-policy already verifies for signed commits, and it travels in gRPC metadata:

gunnar-authorization: gunnar-sshsig-v1 <SHA256:…> <issued_at_ms> <nonce> <sshsig>

signed bytes:  gunnar-grpc-auth-v1\n<fingerprint>\n<issued_at_ms>\n<nonce>\n
sshsig namespace: gunnar-grpc

Four checks, each able to refuse: the header parses; issued_at is within ±5 min; the (fingerprint, nonce) pair has not been seen (single-use, bounded replay guard); the signature verifies against the OpenSSH key line the key table holds for that fingerprint. Only then does a caller exist.

Why this and not the other two
  • mTLS would add a second identity model beside the key table that already answers the same question: a CA, issuance, renewal, revocation, and a mapping from certificate subject back to a gunnar principal. Two identity models that can disagree is exactly the twinning §5.2 had to undo once already (LAW 5).
  • A bearer token is one secret granting everything, with no principal behind it: an audit line could only ever say "the token", and revoking one operator means rotating every operator. SecretStore (§5.1) would give it a home, but a home for a shared password is still a shared password.
  • SSH-key identity reuses the key table, the D20 fingerprint, the principal model and the ACL. gunnar key add already provisions it. Revoking a key over gRPC revokes it for git in the same instant, because there is one table.

The cost, recorded honestly: a caller must sign, so a credential cannot be typed into grpcurl by hand. The challenge does not bind the gRPC method, because a tonic server interceptor is handed Request<()> and cannot see the path. The single-use nonce is what makes a captured credential useless, not method binding.

Authorisation: one decision, not two

Every RPC declares a scope (Server / Namespace / Repo) and a level, and the answer comes from AuthTable::may / may_namespace / may_server (§5) through the seam. gunnar-grpc does not compute a grant decision and must never grow one.

Deny by default, twice

An interceptor refuses a request with no valid credential before any handler runs, and each of the 37 RPCs independently refuses a request that arrives without a verified caller, so mounting the services without the gate fails closed.

Audit (this is the other half of S-022)

Every authorised call emits one record on the gunnar::audit tracing target with method, principal, fingerprint, scope, level, auth mode and decision. Refusals at the gate emit a different record and no call record, so the trail never shows admin activity that did not happen.

What has NOT changed
  • Git push and fetch are unaffected, as they always were: separate SSH listener, publickey auth, different port, different crate. No SSH-over-gRPC bridge.
  • The loopback default stays. 127.0.0.1:50051, never 0.0.0.0. Closing the authentication hole is not a licence to widen the bind; defence in depth costs nothing here and tests/loopback_default.rs still goes red if anyone moves it.
The escape hatch

ServerConfig::insecure_allow_all_for_local_development() restores allow-all for one listener. It is never the default, is not reachable from an environment variable or a config file, warns at construction and again at every start, reports grpc_authentication_enabled: false on the wire, and stamps auth_mode="allow-all-insecure" on every audit record it produces.

8.1 The event stream

Events.Watch is server-streaming and is the reason UIs stop polling. Filterable by namespace / repo / kind. Events:

RefUpdated{repo, ref, old, new, principal, at} · RefDeleted{…} · PushRejected{repo, ref, gate, reason} · RepoCreated/Deleted · MirrorSynced{remote, refs_changed} · KeyUsed{principal, fingerprint}

Backed by a tokio::sync::broadcast bus that receive-pack publishes to. Lagging subscribers get dropped events with a gap marker, never unbounded memory.

This is the whole difference for dwarves and korp: a push lights the UI instantly.


9. Multi-tenancy, from day 1

Namespace is in the model above and in every path: git@host:<ns>/<repo>.git. Grants scope to namespace or repo. Adding this later means migrating every stored path and every ACL row; adding it now costs one field. Do it now.


10. Attestation, and why it costs zero dependencies

At receive-pack time gunnar already knows: who pushed, which key, which repo, which refs moved, which objects are new, and their hashes. That is a provenance record, free.

gunnar does NOT link the Build Thing, nornir, skade, arrow, or parquet. It:

  1. emits a PushAttestation on the event stream (§8.1), and
  2. appends one JSON line to an attestation log in its own store.

Anything that wants a warehouse row subscribes and writes it itself. The coupling is a proto message, not a dependency edge. This is also the only shape that survives the appliance case, where none of those crates could be linked anyway.


11. gunnar mirror: the verb that pays for itself fastest

Pull-mirror a remote into a gunnar repo, over the client that already works.

gunnar mirror add  <remote-url> <ns>/<repo>   # register
gunnar mirror sync <ns>/<repo>                # ls-remote, fetch deltas, apply refs
gunnar mirror status

A one-command import from any SSH git remote buys more than anything else here, and nornir-git already does the hard part. Ship it in v1.


11.1 Replication to an external mirror gunnar (D15)

The inverse of mirror: a gunnar server pushes its refs and objects to another gunnar (or any SSH git remote) after a successful receive-pack.

Configured per repo or per namespace. Driven off the §8.1 event bus — a RefUpdated event enqueues a replication job — so it costs no new plumbing, only a subscriber. Failures retry with backoff and surface as ReplicationFailed events; replication must never block or fail the originating push.

Two things this buys: a warm standby, and a second copy of every repository on hardware you control, kept current without a human running anything.

Planned, not v1. The Mirror service (§8) grows SetReplication / ReplicationStatus rather than gaining a new service.

12. LFS: possible, and possible without HTTP

git-lfs 3.0 defines a pure-SSH transfer protocol (git-lfs-transfer): the client execs another command over the same SSH connection and speaks a batch protocol there. Forgejo implements it. That is exactly gunnar's shape: same session, same auth, no HTTP server. And LFS objects are content-addressed blobs, i.e. znippy's shape again.

Verify against the git-lfs spec before committing to it. v2, not v1. But the exec dispatch in §5 must be written so adding a third command is one match arm, not a refactor.


13. Docs: .nornir/, and why it is not a build dependency

Docs follow the nordisk .nornir pattern (_TEMPLATE-*.md in dwarves/.nornir/), classes design / guide / idiot / reasoning / history / readme, class declared as <!-- nornir:class: X --> on line 1.

Planned set: design.md · gunnar-guide.md · gunnar-for-idiots.md · storage-design.md · storage-reasoning.md (§4, the gix finding; this one matters most) · wire-design.md · policy-design.md · grpc-guide.md · appliance-design.md (the tunnr seam) · README.md (the generated root README's source).

Doc generation routes through nornir docs render (see .nornir/docs.toml and release/nornir.toml), never through a build dependency. xtask/ is a different thing: the dependency guard, preflight and CI entry points. Neither is a dependency of any crate. cargo build -p gunnar-cli must not resolve nornir, typst, or any docs crate. If a docbook step is wanted, it is cargo xtask docs, gated behind its own workspace member that the default build never touches. Binaries in /docs/ go via Git-LFS, never as raw blobs.

⛔ No AI attribution in any generated artefact.


13.1 The systemd feature: Skidbladnir (D16)

Investigated 2026-08-02 with measured dependency counts.

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

[features]
systemd = ["dep:skidbladnir"]      # OFF by default

Copy modgunn's shape (modgunn/Cargo.toml:171 + modgunn/src/service.rs, ~110 lines). Use service::Lifecycle and systemd::install_systemd_scoped, the Layout-free API. Do NOT touch product::SystemdInstaller: it demands a version-dated Layout and a verified airgap bundle, and product::registry() hardcodes holger/tunnr/nornir_server/knut_flight. Wrong layer for gunnar.

What it gives: unit rendering plus pure-Rust D-Bus control of org.freedesktop.systemd1, with no systemctl subprocess, which is why it is admissible at all under the zero-shell rule. Both Scope::User and Scope::System.

Measured cost

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

Paid only when the feature is ON. With optional = true and nothing enabling it, Cargo does not resolve, download or compile skidbladnir or any of its 48 transitive crates. The appliance build (--no-default-features --features server) links zero bytes of it. That is a Cargo guarantee, not a convention.

Three costs recorded honestly

  1. It links a SECOND async runtime. zbus is a non-optional dependency of skidbladnir and its default resolves to async-io, not tokio, so the smol stack (async-io async-executor async-lock async-process polling blocking piper futures-lite event-listener …) 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 zstd-safe zstd-sys ron blake3 chrono uuid xattr filetime … are the airgap packer, not the installer, and gunnar never calls them. The crate is monolithic, so they cannot be declined. zstd-sys means the systemd feature needs a C compiler and pkg-config at build time.
  3. Tension with Brokkr's Software Criteria (D17, "minimal dependencies"). A fifth of the tree for a convenience. Justified only because it is default-off and the appliance never pays it. If that ever stops being true, drop the feature.

Version: pin 0.1.12, and why not 0.1.13

Local Skidbladnir is 0.1.13 and unpublishable: it added a dep on nornir-escape, which is not on crates.io. 0.1.12 is the newest publishable version.

gunnar must build standalone from a fresh clone (the same rule that removed the nornir-git patch in §3), so a path dep on ../Skidbladnir is not allowed, even though all five existing consumers (korp, dwarves, modgunn, holger, nornir) use one. gunnar would be the first registry-only consumer, and that path has never been exercised. Expect friction and report it.

0.1.12 is sufficient for v1: it has install_systemd_scoped, service::Lifecycle, and Systemd::{enable,start,stop,is_active}. It has no uninstall.rs: the DataRoot / UninstallReport / guarded-delete purge law exists only in the unpublished 0.1.13, and Lifecycle::uninstall() there returns a bare Result<()>. Accept the thinner uninstall for now.

Upstream follow-up (not a blocker): publish nornir-escape, cut Skidbladnir 0.1.14, then bump gunnar to get the uninstall law. A skidbladnir-systemd leaf crate would cut the ON cost from 48 to ~33 and let the consumer pick zbus's runtime. Worth proposing, not worth waiting for.

14. Build order

wave what done when
1 gunnar-pktline codec + incremental decoder, fuzz-shaped tests, byte-literal assertions
2 gunnar-store traits + fs + mem backends one conformance suite runs against every backend
3 gunnar-pack write + read pack round-trips through gix's own indexer, sha1 and sha256
4 gunnar-wire upload-pack real git clone from a stock git client succeeds
5 gunnar-ssh server real git clone git@localhost:ns/repo.git end to end
6 gunnar-wire receive-pack + gunnar-policy real git push succeeds; a bad push is seen to be rejected
7 gunnar-proto + gunnar-grpc + events a client streams a push event live
8 gunnar-client + gunnar mirror mirror a real codeberg repo, including a sha256 one
9 gunnar-store arrow-log ref store + gunnar-store-znippy cold tier + repack() conformance suite green on the tiered store; a repack seen to move objects hot→cold with reads unaffected across the swap; measured vs fs
9b git znippy package: __gunnar_oid__ (stree) + __gunnar_graph__ batch oid lookup beats the fs backend on a measured repo; Refs.Log served from the Arrow commit table, and that same table read by DuckDB
10b __gunnar_reach__ reachability bitmaps want − have by bitmap ANDNOT, measured against the graph-walk path on a real repo. No claim without a number
10 .nornir/ docs + appliance smoke gunnar serves from a tunnr-style image

15. Verification: LAW 2, concretely

The single most important requirement in this document:

Interoperability is asserted against the real git binary as a CLIENT.

gunnar never runs git. Its tests do — because the only honest proof that a git server works is that stock git talks to it. A test that only proves gunnar's client agrees with gunnar's server proves nothing; both halves can be wrong in the same way.

Required red-first guards:

Ratios, not counts, in every report. Date every measured figure.


16. Open questions

  1. MUST-FIX: gRPC authentication (D9). CLOSED 2026-08-03. v1 shipped allow-all; it now requires an sshsig credential over the §5 key table and a grant from AuthTable, denies by default at two layers, and audits every call. See §8.2 for the mechanism and for why mTLS and a bearer token were rejected. The loopback default stays regardless.
  2. packed-refs equivalent: compaction cadence for __gunnar_refs__. (Ref durability and the reflog shape are RESOLVED by §4.5: the log is the reflog.)
  3. Repack trigger policy. What makes repack() fire? Loose-object count, hot-tier bytes, time, or explicit gunnar gc only. Affects the O(total) rebuild cadence. Start with explicit gunnar gc plus a loose-object-count threshold; tune on measurement.
  4. Delta compression: non-delta first, measure, then decide. Clone bandwidth on a large repo may force it earlier than planned. Note that znippy's own zstd/lz4 chunk compression covers part of what deltas buy; measure before assuming git-style deltas are needed at all.
  5. LFS pure-SSH spec. Confirm the git-lfs-transfer protocol details before committing (§12). LFS objects are content-addressed blobs, i.e. the same shape as the cold tier — likely the same store.
  6. LFS object store. LFS blobs are content-addressed, i.e. the cold tier's shape. Same store, or separate? Decide with §12.
  7. Keep arrow out of the fs build. fs.rs reuses the arrow-log secret store inside the git dir, which drags arrow's ~21 crates into a configuration that otherwise needs none of them (gix-ref does refs there). A plain-file SecretStore for fs confines arrow to the znippy configuration, where it is already paid for. Small, and it restores the dependency budget on the normal-OS path.
  8. The O_EXCL lock does not self-clear after a hard kill. Settled 2026-08-03 (S-019): the PID+liveness check, not the MSRV bump. flock was unusable: the crate is unsafe_code = "forbid" with no libc/rustix, and File::try_lock is Rust 1.89 against a 1.85 MSRV. The lock file now records <pid> <start-time> (field 22 of /proc/<pid>/stat, so pid reuse is detected) and open reclaims a lock whose owner is provably gone. Undecidable cases still refuse — a live owner, no /proc, an unreadable or pre-S-019 owner line — and ArrowLogStore::force_unlock stays for those.
  9. arrow-log rejects non-UTF-8 ref names; mem and fs accept them. Git ref names are bytes, not UTF-8. Rejecting (InvalidRefName) rather than corrupting is right, but it makes backends behave differently, which the conformance suite should pin explicitly rather than leave as an accident.

(Formerly open, now decided, and not to be reopened: gRPC auth → D9 + item 0 above, BUILT; admin bootstrap and SSH host key → §5.1 SecretStore; default hash kind → D11, sha256; archive granularity → D12, one per repo.)

Upstream tasks this creates elsewhere

Resolved