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.
- Transport: SSH, and smart HTTP behind a default-off feature (D24). No dumb-HTTP, no git://. The two transports share one authorisation decision and one set of protocol state machines; smart HTTP is a wrapper, not a second implementation.
- Control plane: gRPC only. Every UI — dwarves, korp, facett, anything — talks tonic. There is no web UI and no REST in this repo.
- Zero shell, both halves. No
gitsubprocess, nosshsubprocess, nossh-keygen, nosshd, no libgit2, no C. Everything in-process, pure Rust.
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:90is a hardcompile_error!demanding either theringor theaws-lc-rsfeature; both are C/asm. Measured ongunnar-ssh's normal-edge tree: 196 crates, 2 of them C —aws-lc-rsandaws-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-gitwith it and mean writing an SSH implementation. Not worth it.ringis 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:
- Zero gunnar dependencies. Only
gix-*plumbing crates (gix-hash,gix-object,gix-pack,gix-features). If it needs a gunnar type, the design is wrong. - gix's own conventions, so it reads as theirs: an
Optionsstruct withthread_limit: Option<usize>, progress throughgix_features::progress, cancellation through&AtomicBoolnamedshould_interrupt, errors viathiserror. MIT OR Apache-2.0, matching gitoxide.- Its own tests, benches and docs, passing standalone.
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:
ssh::parse_ssh_url, scp-like andssh://formsssh::ls_remote: protocol v2ls-refswith a v0 fallback,symrefs+peelssh::parse_ref_advertisementtakes SHA-1 and SHA-256 oids and strips v2 attributesssh::connect_upload_pack→UploadPack, a russh async channel bridged to the blockingRead/Writegix's transport wants, viaSyncIoBridgeUploadPack::droptears the channel down inside the runtime context, because russh spawns its close message on drop and doing it off-runtime panicssshkey::generate_ed25519_openssh, in-process ed25519 minting, nossh-keygen
gunnar depends on this. It does not copy it. LAW 5.
⚠ One copy is forced, and the fix is upstream.
nornir-git 0.1.5exposes onlyconnect_upload_pack, withgit-upload-pack '<path>'baked in. Push needsgit-receive-pack, so ~40 lines of connect / auth / exec / SyncIoBridge / in-runtime-Dropare duplicated ingunnar-client/src/ssh.rs.Upstream fix:
nornir-gitshould exposeconnect_exec(loc, key, cmd)and defineconnect_upload_packas 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_sshcannot usessh-agentwhile push can, an asymmetry with no good reason; andgitio::porcelain_statusis private, while its two public derivatives drop the per-path state astatusverb has to print, forcing ~35 lines to be re-walked. Making itpubdeletes them.
⚠ Two gix behaviours that bite silently. Found by interop testing, not by reading.
Reference::peel_to_idpeels annotated tags. Pushingrefs/tags/v1with it sends the commit, so the tag silently arrives lightweight. Nothing fails:fsck --strictpasses on both sides. Follow symrefs without peeling instead.
PreviousValue::MustNotExistis 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'sfsbackend, 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:
cargo publish -p nornir-gitfrom/home/rickard/git/edda: exit 0, packaged 12 files / 349.7 KiB,--verifyleg compiled the packaged crate against registry deps in 4.70 s,Published nornir-git v0.1.5 at registry crates-io.- Independently confirmed against the registry index rather than trusting the exit code:
index.crates.io/no/rn/nornir-gitlists0.1.5,yanked=false. (LAW 2: a publish that prints success and a publish that landed are different claims.) - Prior to the upload: path deps 0 of 2 blocking (
nornir-hash 0.1.0required / 0.1.0 published;nornir-testmatrix ^0.2.7optional / 0.2.7 published); edda onmainwithorigin/main...HEAD=0 0;git status --porcelainover the three relevant crate paths empty. mcp__dwarves__release_gateshad the workspace atblock_correctnesswith 12 correctness blockers, none namingnornir-git. All ApiSkew on unrelated crates. Workspace-wide gate, not a gate on this crate.
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.libcalready arrives viagix-features/gix-utilsregardless of storage. And ongunnar-store's tree, 21 of 87 crates exist only because of arrow (chrono,iana-time-zone,num-bigint,num-complex,half,flatbuffersamong 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
fsconfiguration, wheregix-refhandles refs and arrow arrives only becausefs.rsreuses 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
ringoraws-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.
- incoming = the pushed packfile, kept as-is. Already an immutable indexed container;
gunnar-pack::index_packwrites it. Cost O(new objects). - cold = znippy. One archive per repo. Objects keyed by their oid hex as
relative_path. Immutable, queryable, blake3-verified. repack()/gunnar gcfolds hot into cold and truncates hot.
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:
- 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. - Per-chunk blake3 verification on every read.
get_fileverifies each chunk against its stored 32-byte checksum before returning bytes. Git object integrity checking, always on, at no cost. - Objects become queryable from outside. The lookup sub-index is a valid Arrow IPC stream; slice it by the manifest's
index_offset/index_lenand 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.mdand 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 inPackCopyAndBaseObjectsmode andRepackOutcomereportsobjects_copied_from_packagainstobjects_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.
ZnippyObjectsis not written; §4'sTieredObjectStore { 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 withcargo tree -e normalon 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-222enablessysinfo'smultithreadfeature explicitly. gunnar'sCargo.lockholds 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.0is a plain, unconditional[dependencies]entry ofznippy-common— nooptional, no feature gate. Itsbuild.rs:6pinshttps://github.com/facebook/openzl/archive/ca28e4bc….tar.gz,:76fetches it withCommand::new("curl"),:29cmake-builds it and:57linksstdc++.S-007spent a wave removinggix-commandto make "no subprocess" true; this putscurlandtarback, 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
i64keys 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:
ordinals: yes. Every indexGitIndexBuilderbuilds numbers the same oid-lexicographic sequence, soordinal == rank. znippy still stores the array, becausebuild_sectionis a lower-level API whose contract does not require the caller's ordinal to be a rank: the ordinal is the__gunnar_reach__bitmap space, and a caller indexing a subset of a larger archive carries ordinals from the larger space. Dropping it is 4 bytes per object and a narrower contract.rows: no, and it can never become the rank. A lookup row is a chunk row: an object abovefile_split_block_size(10 MiB) occupies several consecutive rows, and the lookup sub-index covers every path in the archive, not only git objects. An archive holding anything besides the object store has git rows that are not contiguous at all.rowsis monotonic in rank and equal to it only in the special case of a single-chunk, git-only archive.
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:
STree64Mmap::find_exactreturns a member of a duplicate-key run, not provably the first, and its leaf scan only walks ≤ B+1 records forward. Expand the equal-key run in both directions; do not trust either end.STree64::new/new_with_strideassertcount > 0, so an empty index carries no tree at all. Handle that as a clean miss, not a panic.
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.
- within a process, a per-repo
Mutexserialises ref transactions. - across processes, an exclusive lock (
flock/O_EXCL) taken at repo open, so a second gunnar refuses to start rather than corrupting the log. Two appenders writing at the stream's end would interleave and destroy frame boundaries; there is no reservation protocol in Arrow IPC. - readers are unconstrained: they replay the stream or read the in-memory map.
Three things that must be got right
These are small, testable, and must each be seen to fail before being trusted:
- 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.
- 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.
- 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:
- Beside the data, on the appliance's own disk —
secrets.kek, 32 bytes, mode0600, in the control directory, outside the Arrow secret log and outside anything aFoldRequestreaches. The default. - 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 beforeexecing gunnar. - An operator passphrase at boot — rejected, it contradicts unattended boot.
- 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:
- readers keep serving from the old archive for the whole rebuild; no window where a live archive has had its metadata tail truncated,
- a crash mid-repack leaves the old archive intact and the incoming packs untouched — the operation is simply retried,
- the swap is a rename, which is atomic on a real filesystem, and a single catalog commit where a catalog is in play.
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>>;
}
InternalSecretStore, build now. Backed by__gunnar_secrets__, the same Arrow IPC log mechanism as the ref store (§4.5).VaultSecretStore: NOTED, DO NOT BUILD. Placeholder for an external vault. Recorded so the trait is shaped for it; no crate, no dependency, no stub impl.
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 aGIT_SSH_COMMANDthat 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/passwdresolves to the catalog keyetc/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.visibilitywas 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.
Publicused 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.Privateis 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
- Never
receive-pack. A push needsWrite; rule 0 gives onlyRead. Asserted for fast-forward, force, ref-creation and ref-deletion, with the stored ref read back afterwards. - Never a control-plane call. gRPC is a separate listener needing an
sshsigsigned by a key in the §5 table, and anonymous has no key.AuthGateadditionally refuses any credential resolving to the anonymous id (AuthFailure::Anonymous) — a stategunnar-coremakes unreachable, guarded anyway becauseAuthGatetakesControlPlaneas a trait and any adapter can name any principal.gunnar-grpcdoes not linkgunnar-core, so it holds its own copy of the constant andgunnar-clipins the two equal. - Never a shell, a subsystem or any exec but the two git verbs: the existing "no shell, ever" rule, re-asserted from a keyless session.
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
- 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.
-
command=ls-refs, argssymrefs,peel,ref-prefix <p>,unborn. -
command=fetch, argswant <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
- Advertise refs + capabilities:
report-status,report-status-v2,delete-refs,side-band-64k,quiet,atomic,ofs-delta,object-format=…,push-options. - Read the command list:
<old-oid> <new-oid> <refname>, flush. - Optional push-options section.
- 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).
- Run the policy gate (§7). A rejection here rejects the ref, and with
atomic, the whole push. - Apply the ref transaction;
atomic⇒ all-or-nothing. - 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.
0002is written by the CLIENT, not the server.gunnar-pktlinehasPacket::ResponseEndand its doc comment says "protocol v2 stateless-rpc response end", which reads as though a server emits it. It does not. Every response captured fromgit http-backend— v0 and v2, advertisement and rpc — ends with a flush-pkt and nothing after it.response-end-pktis written by git's ownremote-curlhelper, which proxies protocol v2 for afetchrunning 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.
- The secret is never stored.
AccessTokenis keyed bysha256(secret), hex, and that hash is also the token's public id — whatgunnar token listprints and whatrevoketakes. Two things fall out and both were wanted: the auth document is a git blob and every version of it is kept (§4.5), so a table holding a usable credential would hold it forever; and a lookup is hash-then-probe, O(1), not a scan on every request. - The Basic username is ignored, because §5 says the username is decorative and that rule does not weaken on a second transport. Basic cannot send a password without a username, so
gitsends something; making it authoritative would let a caller select an identity by typing it. - Anonymous read is D23 unchanged, including the
allow_anonymousswitch — read from oneServeConfigfield by both listeners. Two fields is how one of them ends up stubbed, which is exactly what was found on the SSH side today. - 401 before 404. An unauthenticated caller gets 401 whether the repository is private, public-with-anonymous-off, or absent, so a stranger cannot enumerate. Once a credential has been accepted, 404 is safe and more useful.
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".
- Certificate lifecycle is the actual work, not the handshake. ACME, renewal, storage of a private key, rotation under load. All of it is deployment-shaped, and §0's server has no filesystem it may assume, no timer daemon and no shell. Putting it in the binary puts the most operationally fiddly part of a deployment inside the thing that is supposed to be a static blob.
- It is not a hole. Anonymous read carries no credential; there is nothing on the wire to lose but the contents of a repository that is public by definition.
- What would be a hole is a token in cleartext, so that is refused rather than documented.
Cleartext::RequireProxyTls(the default) rejects any request carrying anAuthorizationheader unless a proxy attests TLS withX-Forwarded-Proto: https, with 403 and not 401 — retrying will not help, and a 401 would invite the client to send the password again.--http-allow-cleartext-credentialsis the operator's explicit claim about a link they vouch for, and the tests use it rather than losing the default to convenience. - Trusting a header is a claim about deployment and it is the operator's to make: it is true when gunnar is reachable only through the proxy and false when it is reachable directly. gunnar cannot check that from inside, which is why the alternative is explicit and not inferred.
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
- Write (serving): walk from
want, subtract the closure ofhave, emitPACK+ version + count, then per object a type/size varint header and zlib data, then the trailer hash. Deltas are optional in the format. Ship a correct non-delta writer first, measure, then addofs-deltaif the numbers justify it. Verify by handing the produced pack togix_pack::Bundle::write_to_directoryand asserting object count and byte-exact readback of a known blob. - Read (receiving):
gix_pack::Bundle::write_to_directorywiththin_pack_base_object_lookupwired to theObjectStore, so thin packs resolve.
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 metadatacannot see.gunnar-clientneedsgixwithstatus(which pulls dirwalk, index and blob-diff) andblocking-network-client. Under resolver 2,cargo build --workspaceunifies those features into theScrates'gixtoo. 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, andnornir-gitforces 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:
-
Branch protection, per-ref-pattern: no force-push (new must descend from old), no delete, required signature, allowed principals.
-
Ref-name policy: a pattern allowlist. Rejects
refs/heads/tmp-*, enforcesfeat/…, whatever the namespace configures. -
Signed commits (ssh-sig), verifying
gpg.format=sshsignatures against the principal's registered keys. Pure Rust viassh-key. You already mint ed25519 keys innornir-git::sshkey; this is the other half. Differentiator. -
Size / count limits: refuse an oversized blob or an absurd object count.
⚠ 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_bytesis 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 formatssh-keygen -Y signproduces and the onegunnar-policyalready 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-grpcFour checks, each able to refuse: the header parses;
issued_atis 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 addalready 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
grpcurlby hand. The challenge does not bind the gRPC method, because a tonic server interceptor is handedRequest<()>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 fromAuthTable::may/may_namespace/may_server(§5) through the seam.gunnar-grpcdoes 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::audittracing 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, never0.0.0.0. Closing the authentication hole is not a licence to widen the bind; defence in depth costs nothing here andtests/loopback_default.rsstill 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, reportsgrpc_authentication_enabled: falseon the wire, and stampsauth_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:
- emits a
PushAttestationon the event stream (§8.1), and - 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
- It links a SECOND async runtime.
zbusis a non-optional dependency of skidbladnir and its default resolves toasync-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 atokiofeature that would collapse this, but skidbladnir declareszbus = "5"with defaults on and exposes no knob. - 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-sysmeans thesystemdfeature needs a C compiler and pkg-config at build time. - 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
gitbinary 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:
- Every
RefStoreconformance test must be seen to fail on a backend with a deliberately broken CAS, before the passing backend is believed. - Every policy gate must be seen to reject a crafted bad push.
- The sha256 path must be exercised by a sha256 fixture, not inferred from sha1 green.
- The path sandbox must reject escapes and accept a legitimate nested path.
- The dependency guard must go red on a deliberately added rayon dep.
- Pack output is verified by gix indexing it, not by gunnar re-reading its own bytes.
Ratios, not counts, in every report. Date every measured figure.
16. Open questions
MUST-FIX: gRPC authentication (D9).CLOSED 2026-08-03. v1 shipped allow-all; it now requires ansshsigcredential over the §5 key table and a grant fromAuthTable, 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.packed-refsequivalent: compaction cadence for__gunnar_refs__. (Ref durability and the reflog shape are RESOLVED by §4.5: the log is the reflog.)- Repack trigger policy. What makes
repack()fire? Loose-object count, hot-tier bytes, time, or explicitgunnar gconly. Affects the O(total) rebuild cadence. Start with explicitgunnar gcplus a loose-object-count threshold; tune on measurement. - 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.
- LFS pure-SSH spec. Confirm the
git-lfs-transferprotocol details before committing (§12). LFS objects are content-addressed blobs, i.e. the same shape as the cold tier — likely the same store. - LFS object store. LFS blobs are content-addressed, i.e. the cold tier's shape. Same store, or separate? Decide with §12.
- Keep arrow out of the
fsbuild.fs.rsreuses 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-refdoes refs there). A plain-fileSecretStoreforfsconfines arrow to the znippy configuration, where it is already paid for. Small, and it restores the dependency budget on the normal-OS path. TheSettled 2026-08-03 (O_EXCLlock does not self-clear after a hard kill.S-019): the PID+liveness check, not the MSRV bump.flockwas unusable: the crate isunsafe_code = "forbid"with nolibc/rustix, andFile::try_lockis 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) andopenreclaims a lock whose owner is provably gone. Undecidable cases still refuse — a live owner, no/proc, an unreadable or pre-S-019owner line — andArrowLogStore::force_unlockstays for those.- arrow-log rejects non-UTF-8 ref names;
memandfsaccept 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
- znippy needs
gitas a built-in package format (§4): the three reserved modules, their names inis_reserved_module, and registration inznippy-cli/src/handlers.rs::builtin_handlers(). Ships as a znippy release before gunnar can consume it.
Resolved
nornir-git0.1.5 publication, resolved 2026-08-02: published. Confirmed live on the registry index,yanked=false. No[patch.crates-io]. See §3.- Client OS assumptions. Resolved 2026-08-02: the client is a normal-OS program and always was. Only the server carries the no-OS constraint. See §0.
- znippy object keying, resolved 2026-08-02: oid hex as
relative_path, two tiers (incoming packs / znippy cold). Packs-in-znippy rejected. See §4. - Client scope. Resolved 2026-08-02: whatever gix's porcelain supports, and
commitis mandatory. gix 0.86 providescommit/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. gix does NOT provide push; gunnar implements send-pack itself, reusing the server's pack writer (§2).