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

nordisk/edda

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

Gunnar — design

Class: design · Function axis: f0 the whole (a git client and a git server over SSH) · f1 framing · f2 storage · f3 pack · f4 wire · f5 policy · f6 ssh · f7 gRPC control plane · f8 client. Strict: this file states what Gunnar IS. Its parts, and why they are shaped this way. Storage reasoning: storage-reasoning.md. Protocol: wire-design.md. The gate: policy-design.md. The no-OS case: appliance-design.md. The specification is TOTAL-PROMPT.md.

Gunnar is two programs in one workspace: a git client that runs on a developer machine, and a git server that does not require an operating system under it. Both speak git over SSH, nothing else. The control plane is gRPC. Neither half shells out, to git or to anything.

Status — 2026-08-03

Eleven crates plus xtask. The repository is under concurrent work and moved several times in one day, so two cargo test --workspace --no-fail-fast runs are recorded rather than one figure:

2026-08-03 suites passed failed ignored
commit 0191154, morning 62 484 0 3
working tree, late 70 516 1 3

Take the number from cargo test rather than from here. The three ignored ones want an external forge (GUNNAR_TEST_REMOTE and an ssh key) and are the only tests that reach the network. The one failure is a_force_push_is_published_as_forced_and_a_fast_forward_is_not in gunnar-cli/tests/push_events.rs, a file added the same afternoon — in-flight work, not a regression in anything described below. The workspace is not green as this is written.

Stock git clones from and pushes to a running gunnar serve over a real SSH connection, on sha1 and on sha256 repositories.

What is not done, stated plainly:

Gunnar could not clone from Gunnar, and the fix landed while this was being written. gunnar push to Gunnar always worked; the fetch path failed because every SSH session was served at protocol v0. gunnar-ssh refused every env_request, so GIT_PROTOCOL=version=2 never arrived. Stock git falls back to v0 without complaint, but gix's blocking transport demands multi_ack_detailed in v0, and Gunnar does not advertise it because it does not implement it. gunnar-ssh now accepts exactly one environment variable, GIT_PROTOCOL, and carries the raw value on ServiceRequest; the version is parsed by gunnar-wire's own parser so there is one implementation of the grammar. See wire-design.md.

The repository is under concurrent work. Every figure above is dated, and the crate tree moved between measurements taken the same day.

The asymmetry — read this before the crate table

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

gunnar-client the server crates
runs on a normal OS, always: a laptop or a build host a normal OS or bare metal with no OS under it
filesystem assumed, used freely: real working tree, real .git, real config not assumed; one trait seam (storage-reasoning.md)
gix::Repository used directly never; plumbing crates only
~/.ssh, ssh-agent, known_hosts used not present, not referenced
repositories working repositories, with a checkout bare only

The client is an ordinary git client. It may read ~/.gitconfig, honour ~/.ssh/config, prompt on an unknown host key, and write files to disk. None of that is abstracted, because 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?

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

The server is worktree-free, and that is most of the job

A git server never checks anything out. It serves and receives bare repositories: objects and refs, nothing else. So the server needs none of the worktree, status or index machinery, no smudge/clean filters, no CRLF handling, no attributes, no ignore rules, no pathspec globbing, no .git/config (server configuration is Gunnar's own), no repository discovery (repositories are registered, not discovered), no credential helpers and no interactive prompts.

That set is the majority of what makes a git library filesystem-bound. What is left is objects, refs and secrets — and the first already has a trait upstream. The no-OS server is a smaller job than it first appears.

The function map (f0)

part crate side what it is
f1 framing crates/gunnar-pktline shared pkt-line encode, decode, incremental decoder. Zero dependencies, and it stays that way.
f2 storage crates/gunnar-store server the ObjectStore, RefStore and SecretStore traits, plus the mem, arrow_log and fs backends. fs is gix-odb + gix-ref, not gix::Repository.
f2 model crates/gunnar-core server repository catalog, principals, keys, grants, namespaces, the path sandbox, the host key
f3 pack crates/gunnar-pack shared pack writer and pack indexer
f4 wire crates/gunnar-wire shared upload-pack and receive-pack state machines
f5 policy crates/gunnar-policy server the pre-receive gate: protection rules, ref-name policy, ssh signature checks, size limits
f6 ssh crates/gunnar-ssh server the russh server: publickey auth, exec dispatch, grant check
f7 proto crates/gunnar-proto shared the .proto files and tonic-build
f7 grpc crates/gunnar-grpc server tonic service implementations and the event bus
f8 client crates/gunnar-client client clone, fetch, push, mirror, and the gix porcelain
— crates/gunnar-cli shared the gunnar binary: server verbs, client verbs, and the join
— xtask/ — the dependency guard, preflight and CI entry points. Never a build dependency.

Planned and not yet crates: gunnar-store-znippy (the cold object tier) and gunnar-store-iceberg (off by default).

Dependency direction is strictly downward. gunnar-pktline and gunnar-store depend on nothing of Gunnar's; gunnar-cli depends on everything. No cycles.

gunnar-cli is where the crates meet

Until it existed, gunnar-ssh had been proven against a banner test double and gunnar-wire against a pipe, and nothing had joined them. gunnar-cli/src/server/ holds that join: the SSH transport seam implemented over the wire state machines, the push gate seam implemented over the policy engine, and the gRPC control-plane seam implemented over gunnar-core. tests/ssh_end_to_end.rs drives stock git against it.

Server state lives under one data root:

<data-root>/
  control.git/            the catalog, the identity table, the SSH host key
  repos/<ns>/<name>.git/  one bare repository per catalog entry

The one edge that must not exist

gunnar-client must not be a dependency of any server crate. That edge would drag gix's porcelain, and with it the whole filesystem assumption, into an appliance build. cargo xtask guard asserts its absence from a resolved cargo tree for the appliance feature set, not from declared features. A declaration cannot see resolver-2 feature unification.

Two binaries' worth of features in one CLI

gunnar serve needs only the server crates. gunnar clone, push and mirror need only the client. They are feature-gated (server, client, both on by default) so an appliance image builds with --no-default-features --features server and links no client code at all.

Why pack and wire are shared, not server-only

gix cannot push. In gix 0.86 gix-protocol carries fetch/, ls_refs.rs and handshake/, and there is no send-pack module; Direction::Push exists only as a refspec direction. nornir-git's own header says the same.

So gunnar-client implements 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, both of which exist for the server.

The consequence is that gunnar-pack and gunnar-wire are marked shared, and so they inherit the server's constraint. No gix porcelain, no worktree features. They stay plumbing-only.

That divergence is CLOSED, 2026-08-05. §2 said the client's push also reuses "the command-list encoding in gunnar-wire". It did not: gunnar-client depended on gunnar-pktline and gunnar-pack and not on gunnar-wire, so the grammar was written twice — gunnar-wire's receive_pack parsed what gunnar-client's send_pack wrote, and neither had ever met the other. Four formats, not one: the capability list, the reference advertisement, the command list and the status report.

The resolution was not to fold send-pack into gunnar-wire. D21 makes it a donation-shaped crate — zero Gunnar dependencies, gix-* plumbing only, gix's own conventions — because gitoxide has no push and extraction after the fact never happens. That crate is gunnar-sendpack, and both directions now route through it: each format is one module with an encoder and a decoder, gunnar-client/src/send_pack.rs is a seam over it, and gunnar-wire's receive_pack calls command::parse_line, report::lines and advertisement::lines.

It costs the appliance one first-party crate and zero third-party ones (280 → 281, 267 → 267, cargo xtask guard, oden 2026-08-05). The pkt-line framer is behind a blocking-io feature that the workspace turns off by default, so a server consumer resolves gix-hash + bstr + thiserror — all three already in the graph — and gix-packetline stays out of it entirely (cargo tree for the appliance profile: 0 occurrences). A client names features = ["blocking-io"] and gets the driver.

The wire is byte-identical: crates/gunnar-wire/tests/receive_pack_wire_bytes.rs pins the advertisement and the report as literals and passes unchanged against the pre-move tree.

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. Zero Gunnar dependencies; only gix-hash, gix-object, gix-pack, gix-features. gix's conventions: an Options struct with thread_limit: Option<usize>, progress through gix_features::progress, cancellation through an &AtomicBool named should_interrupt, errors via thiserror. MIT OR Apache-2.0. Its own tests, benches and docs, passing standalone.

Naming stays gunnar-* in-tree. gix-* is GitoxideLabs' namespace and publishing into it would be squatting. Build gunnar-delta, offer the code, let them name it.

candidate why it belongs upstream status
delta computation gix-pack's entry::iter_from_counts has one Mode, PackCopyAndBaseObjects, which reuses deltas already in a pack and treats everything else as a base. There is no delta encoder in gitoxide. not started
send-pack, and the receive-pack grammar under it gix-protocol 0.64 has fetch/, ls_refs.rs, handshake/ and no push. Gunnar's is proven against real git receive-pack. Upstream's crate status now describes send-pack plumbing, so check before offering. gunnar-sendpack, 2026-08-05. Donation-shaped: 0 gunnar deps, MIT OR Apache-2.0, tests/donation_shape.rs green, 61 tests of its own of which 7 drive the real git receive-pack binary in both hash kinds. Consumed from BOTH ends, which is the point — one encoder and one decoder per format instead of two copies that agreed by inspection
pure-Rust SSH transport gix's own SSH transport execs ssh. The russh-to-gix bridge removes the subprocess. Lives in nornir-git, not here. exists upstream of Gunnar

"gix lacks it" is not "gix wants it". Build for Gunnar's need first and shape it so donation stays possible.

Dependency budget

The budget is tight on the server and unconstrained on the client. A dependency on the server is a line item on an appliance image; on the client it is a dependency. Effort spent minimising the client is wasted.

Server: gix-object, gix-hash, gix-pack, gix-features for git plumbing; gix-odb, gix-ref and gix-lock for the fs backend, behind the fs feature; russh and ssh-key; tokio; tonic and prost; flate2 for zlib, which git's object encoding requires; arrow-ipc, arrow-array and arrow-schema for the ref and secret log; the usual plumbing crates. There is no database.

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

Forbidden in the server graph specifically: gunnar-client; gix's porcelain and everything it drags — gix-worktree, gix-index, gix-status, gix-filter, gix-attributes, gix-ignore, gix-config, gix-discover, gix-prompt, gix-credentials, and gix-command, which exists to spawn subprocesses.

That rule was violated until 2026-08-03, because the fs backend opened gix::ThreadSafeRepository and that alone pulled all of them in. It now goes through gix-odb and gix-ref directly. Plumbing, not porcelain. Measured after the change: the appliance tree is 277 crates, 24 of them gix-*, and none of the banned names is among them. See appliance-design.md.

The C-toolchain ban was unachievable, and is retracted

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

The honest rule is no unvendored C and no system libraries. Both backends vendor their source, so the build stays hermetic and needs no distribution packages. That is what the appliance case requires. Dropping russh would take nornir-git with it and mean writing an SSH implementation; not worth it. ring is the smaller of the two backends if the crate count is ever worth trimming.

What is reused rather than written

The client half of git-over-SSH exists already and is proven against real forges. nornir-git 0.1.5, published on crates.io, carries SSH URL parsing in both scp-like and ssh:// forms, ls-refs over protocol v2 with a v0 fallback, ref-advertisement parsing for both sha1 and sha256 oids, an UploadPack bridge from a russh async channel to the blocking reader and writer gix's transport expects, a teardown that closes the channel inside the runtime context (russh spawns its close message on drop, and doing that off the runtime panics), and in-process ed25519 key minting.

Gunnar depends on that crate. It does not copy it.

The duplication that used to be forced here is gone, and it cost the fresh-clone property to remove. nornir-git 0.1.5 exposed only connect_upload_pack, with git-upload-pack '<path>' baked in, so push duplicated about 40 lines of connect, auth, exec, bridge and in-runtime Drop in gunnar-client/src/ssh.rs. The upstream fix — ssh::{connect_exec, SshAuth}, plus gitio::porcelain_status_in for the same reason on the status path — lands in nornir-git 0.1.6, and gunnar-client already uses it.

Until 0.1.6 is on crates.io, Cargo.toml carries a [patch.crates-io] pointing nornir-git at ../edda. While that block is there Gunnar does not build from a fresh clone on a machine without edda checked out beside it. The manifest says so at the block, and the block is marked for deletion. The moment 0.1.6 is published, bump the version to 0.1.6 and drop the patch. The rule it suspends is unchanged: no path dependency on a sibling checkout.

The client's scope is whatever gix's porcelain supports, and commit is mandatory: a git client without commit is not a client. gix 0.86 provides Repository::commit and commit_as, write_object, index_from_tree, merge_trees, merge_commits and merge_base, so commit, log, diff, status, branch, checkout, tag and merge are cheap. They are implemented on top of gix, not reimplemented.

What does not exist anywhere and is therefore Gunnar's actual content is 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.

Two gix behaviours that bite silently

Found by interop testing, not by reading the source.

  1. Reference::peel_to_id peels annotated tags. Pushing refs/tags/v1 with it sends the commit, so the tag arrives lightweight and fsck --strict passes on both sides. Follow symrefs without peeling.
  2. PreviousValue::MustNotExist is not a create guard on its own. gix's file ref store short-circuits an edit whose new value equals the current one before evaluating the precondition, so re-creating a branch at the same commit succeeds. Explicit existence checks are required, and gunnar-store's fs backend delegates to the same machinery.

Identity and access — server side

The server has no OS users, no authorized_keys file and no home directory. It owns its own tables, in the control repository:

Principal { id, name, kind: User | Machine | Deploy }
PublicKey { fingerprint (sha256), algo, principal_id, added, last_used, expires? }
Namespace { name }
Repo      { namespace, name, hash_kind, default_branch, visibility }
Grant     { principal, scope: Server | Namespace | Repo, level: Read | Write | Admin }

Scope::Server exists because with only Namespace | Repo the bootstrap admin has nothing to scope a grant to and cannot create the first namespace. The server would boot reachable and useless.

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

Namespaces exist from day one. Adding them later would mean migrating every stored path and every ACL row; adding them now costs one field. Nested namespaces are legal: team/sub/repo.git has namespace team/sub. Grants do not inherit, so team does not imply team/sub. That rule is what makes the permissive path shape safe, and it is pinned by gunnar-core/tests/acl.rs.

Secrets sit behind a SecretStore trait with get, put, delete and an atomic get_or_create. The implementation is the Arrow IPC log of storage-reasoning.md, the same mechanism as the ref store. A vault-backed implementation is noted so the trait is shaped for it, and is not built: no crate, no dependency, no stub.

The host key is obtained through get_or_create("ssh/host_ed25519") and minted in process with ssh-key. It must be stable across restarts; an appliance that regenerates its host key gives every client a host-key-changed warning. A test asserts the key is byte-identical across a simulated restart.

Admin bootstrap: on an empty key table, gunnar serve installs an admin public key from --admin-key as the first Admin principal. If none is supplied and the table is empty it logs loudly and refuses to serve, rather than starting a server nobody can reach.

One sandbox, one fingerprint (D19, D20)

gunnar-core and gunnar-ssh were written concurrently and each grew its own path sandbox, key fingerprint, host-key loader and auth lookup. The two sandboxes disagreed: one enforced exactly two path components, the other permitted nested namespaces. Two security checks that disagree are worse than either alone, because the strict one gives false confidence about the permissive one.

gunnar-ssh now depends on gunnar-core and its copies are deleted. The one implementation of each lives in gunnar_core::{sandbox, auth, host_key}. What stayed in gunnar-ssh is command::unquote: git sends one argv element with its quotes inside it and Gunnar has no shell to strip them, but that is a transport fact and the sandbox does not know SSH exists.

Fingerprints are computed over bytes, not over an ssh-key type. russh 0.61 pins ssh-key =0.7.0-rc.10; the workspace pins 0.6. Both copies are in the graph, they are semver-incompatible, and auth_publickey hands over the 0.7-rc type, so a key from russh can never be passed to a 0.6 API. fingerprint_of_blob(&[u8]) -> Fingerprint takes the SSH wire-format blob, which both versions can produce, and renders SHA256:<base64> exactly as ssh-keygen -lf prints it. Known-answer tests run against real ssh-keygen output. Do not attempt to unify the versions by moving the workspace to a release candidate.

The auth flow

russh auth_publickey fingerprints the offered key, looks it up, and attaches the principal to the session. The SSH username is decorative. Identity comes from the key, so git@host:ns/repo.git works and git means nothing.

exec_request then parses git-upload-pack '<path>' or git-receive-pack '<path>', unquotes, resolves the argument to a RepoRef inside the sandbox, and checks the grant: upload-pack needs Read, receive-pack needs Write. shell_request, subsystem_request, pty_request and x11_request are answered with a channel failure. env_request is checked against an allowlist of exactly one name, GIT_PROTOCOL, whose value is parsed as a protocol version and never executed or exported. There is no shell, and no exec path to a process at all. The command name selects an in-process state machine.

The sandbox accepts absolute paths, and must

Measured against git 2.53.0 with a GIT_SSH_COMMAND that echoes argv:

ssh://git@host/ns/repo.git   →   git-upload-pack '/ns/repo.git'     leading slash
git@host:ns/repo.git         →   git-upload-pack 'ns/repo.git'

The ssh:// form is always absolute, so a sandbox that rejected absolute paths would reject every ssh:// clone. One optional leading / and one matched quote pair are stripped, then the string is validated. //… stays rejected. /etc/passwd resolves to the catalog key etc/passwd, which is a lookup miss and never a filesystem path.

The output is not a path. RepoRef is validated components, and catalog lookups are by <namespace>/<repo> key, so on an appliance a traversal has nothing to traverse. The only place a RepoRef becomes a filesystem path is RepoRef::path_under, which is where the symlink check lives. A symlink escape is a property of the filesystem and cannot be caught by looking at a string.

gunnar-core/tests/sandbox.rs asserts both halves from one table: every escape is rejected, and a legitimate nested name is accepted. A sandbox that rejects everything passes every escape test and is useless.

The policy gate

gunnar-policy is what makes Gunnar a forge rather than a bare repository over SSH. It runs between "the pack is indexed" and "the refs are applied", and it can reject. Four gates ship in v1: branch protection, ref-name allowlist, ssh signature verification, and size and count limits. Hooks are in-process Gate implementations, not shell scripts, because there is no shell.

Full treatment: policy-design.md.

The gRPC control plane

The only control plane. Services: Repos (create, delete, list, get, default branch, visibility), Refs (list, get, log, read object, diff), Access (principals, keys, grants), Policy (protection rules, gate listing), Events (a server-streaming Watch), Admin (health, stats, storage info) and Mirror.

Events.Watch is filterable by namespace, repository and kind, and is the reason a UI does not poll: RefUpdated, RefDeleted, PushRejected, RepoCreated, RepoDeleted, MirrorSynced, KeyUsed. It is backed by a tokio::sync::broadcast bus that receive-pack publishes to. A lagging subscriber gets dropped events and a gap marker, never unbounded memory.

Three surfaces answer Unimplemented today: Refs.Diff (no server-side diff engine), Policy.* (gunnar-core has no protection store, so gate configuration comes from a gunnar-policy.json beside the repository), and Mirror.* (mirroring needs gunnar-client, which a server crate must never link). None of them answers an empty success. An empty ref list is indistinguishable from a working server with no refs.

Two model mismatches are recorded rather than papered over: gunnar-proto's RepoPath rejects a / inside namespace, so a nested-namespace repository is reachable by git and invisible to gRPC; and Scope::Server has no RepoPath, so a server-wide grant is carried as two empty strings. Both are gunnar-proto changes.

Two listeners, and the v1 authentication hole

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

0.0.0.0:2222     SSH    git-upload-pack / git-receive-pack.  publickey auth, always.
127.0.0.1:50051  gRPC   the control plane.

v1 ships gRPC with no authentication: allow-all. It is a deliberate hole and the highest-priority follow-up in this repository. Git push and fetch are unaffected: they run on the SSH listener against the key table above, and an open gRPC port cannot push code. What it does expose is every control-plane mutation: principals, keys, grants, branch protection, repository creation and deletion. Anyone who can reach the port is an administrator.

The default bind is therefore loopback, moving it off loopback is logged at error! on every start, and gunnar-cli/tests/serve_wiring.rs goes red if the default moves. The fix is not chosen yet: mTLS, a bearer token in metadata, or identity derived from the SSH key table.

Attestation, without a dependency

At receive-pack time Gunnar already knows who pushed, with which key, into which repository, which refs moved, which objects are new, and their hashes. That is a provenance record at no extra cost.

Gunnar does not link a warehouse or an analytics stack to record it. It emits a PushAttestation on the event stream and appends one JSON line to an attestation log in its own store. Anything that wants a warehouse row subscribes and writes the row itself. The coupling is a proto message, not a dependency edge, and that is also the only shape that survives the appliance case, where none of those crates could be linked.

gunnar mirror

Pull-mirror a remote repository into a Gunnar repository, over the client that already works:

gunnar mirror add  <remote-url> <ns>/<repo>
gunnar mirror sync <ns>/<repo>
gunnar mirror status

nornir-git does the hard part. This is in v1.

The inverse is planned and not in v1: a Gunnar server pushing its refs and objects to another Gunnar, or to any SSH git remote, after a successful receive-pack. It is driven off the event bus, so a RefUpdated event enqueues a replication job and it costs no new plumbing, only a subscriber. Failures retry with backoff and surface as events. Replication must never block or fail the originating push.

LFS

git-lfs 3.0 defines a pure-SSH transfer protocol: the client execs another command over the same SSH connection and speaks a batch protocol there. That fits Gunnar's shape — same session, same auth, no HTTP server — and LFS objects are content-addressed blobs, which is the cold tier's shape. It is v2, not v1, and the specification needs verifying before it is committed to. The requirement it places on v1 is only that the exec dispatch is written so that adding a third command is one match arm rather than a refactor.

Verification

The single most important requirement in this repository:

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

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

Guards that must be seen red before they are believed: every RefStore conformance test, against a backend with a deliberately broken compare-and-swap; every policy gate, against a crafted bad push; the sha256 path, exercised by a sha256 fixture and never inferred from a green sha1 run; the path sandbox, on escapes and on a legitimate nested path. Pack output is verified by having gix index it, not by Gunnar reading back its own bytes.

Two of those broken implementations ship inside the library rather than the test directory: gunnar_store::conformance's broken backends and gunnar_policy::blind's always-allow gate. So the ability to report red is permanent rather than a thing somebody once checked by commenting a line out.

The dependency guard lives in xtask/src/guard.rs and carries its own red-first proofs against synthetic graphs: rayon rejected, a server-to-client edge rejected, gix porcelain features in a server graph rejected, a graph missing its sentinel not counted as a pass, and a recorded deviation that excuses nothing failing the guard. A further check asserts every forbidden name is a crate that can actually appear, against the default-build graph, so a typo or an upstream rename cannot sit in the rule table reporting "absent" forever.

Docs

Documentation follows the nordisk .nornir pattern, with the class declared as <!-- nornir:class: X --> on line 1. Book routing is .nornir/docs.toml and generation is nornir docs, invoked as a tool: cargo build -p gunnar-cli must not resolve any docs crate. xtask/ holds the guard, preflight and CI entry points, not doc generation. §13 of the specification still says otherwise and is stale on that point. Binaries under /docs/ go via Git-LFS, never as raw blobs.

Written: this file · storage-reasoning.md · wire-design.md · policy-design.md · appliance-design.md · README.md. Planned: gunnar-guide.md, gunnar-for-idiots.md, storage-design.md, grpc-guide.md.

See also: TOTAL-PROMPT.md, the specification this design is drawn from.