gunnar — stinky
Opened 2026-08-03, against master at 0191154 ("Add gunnar-cli: serve both listeners, and clone over real SSH"), 27182 lines of Rust across crates/*/src. crates/gunnar-cli/ was being written while this pass ran; findings that cite it are marked and may have moved.
Everything below is judged against one load target: gunnar carries the git traffic of somewhere between one organisation and a very large forge — millions of repositories, thousands of concurrent clones, repositories with millions of objects.
This file and the front page must not drift. Several findings here were stated publicly in
README.md, which turns them from observations into commitments. When an item is markedDONEhere, amend its public claim in the same commit. When a public claim changes, add or update the entry here. A page that still advertises a limitation that was fixed is as misleading as one that hides a limitation that was not.2026-08-05: the README no longer carries a "Not wired" list. It was a front page listing what is absent, and its own title had gone false — the first bullet read "znippy cold tier — wired, off by default". Every claim moved, none was deleted: the cold tier to
cold-tier-decision.md, the three index sections towrite-read-cycle.md§3,S-003toperformance-design.md, andmulti_ack_detailed/ replication / partial clone towire-design.md. Those four files are what this rule now points at.
Nothing in gunnar has ever been load-tested, profiled or benchmarked. There is no benches/ directory anywhere in the tree and no CI. Every performance claim in the source comments is therefore unverified, and every number below that is not marked MEASURED is derived from reading the code or from arithmetic over the serde/struct shapes. Say which when you quote it (LAW 7).
How to use this file
Each finding is one ### S-NNN block. A fixing agent takes one id, fixes it, and edits only that block.
- Status lifecycle:
TODO→IN-PROGRESS→DONE· orWONTFIX(a deliberate trade, with the reason) · orINVALID(the finding was wrong, with the evidence). - Never delete a finding. Retracted ones stay, relabelled
INVALID. Ids are stable and are never reused. Closed:is filled by the fixing agent with the date, the commit sha, and how it was verified, not "looks right". A claim of doneness is not doneness.Verify:is the LAW 2 clause. It names the test that must be seen RED before its green means anything. A fix landed without that step is notDONE, it isIN-PROGRESS.- Severity is about the load target, not about taste.
blocker— gunnar cannot carry the target load with this in place ·scale— degrades badly with size or concurrency but does not disqualify ·correctness— wrong answers, silent-green guards, swallowed errors ·smell— misleading docs, dead code, tech debt. - Grouped by severity, not by crate. Append new findings at the end of their group with the next free id.
Summary
| id | title | severity | status |
| S-001 | The identity table is one JSON blob, parsed 4× per SSH connection | blocker | DONE |
| S-002 | The catalogue is one JSON blob; every repo lookup parses all of it | blocker | DONE |
| S-004 | A fetch materialises the client's whole object closure, then clones it | blocker | DONE |
| S-005 | verify_connectivity claims O(new history) and is O(whole repository) |
blocker | DONE |
| S-006 | repack() does not exist; no cold tier ever forms |
blocker | DONE |
| S-007 | The appliance build resolves gix-worktree, gix-index, gix-filter, gix-command | blocker | DONE |
| S-008 | No connection limit on either listener; 512 blocking threads are the accidental cap | blocker | DONE |
| S-009 | The gRPC control plane blocks tokio worker threads and starves the SSH listener | blocker | DONE |
| S-010 | The open-repository handle cache is unbounded and never evicts | blocker | DONE |
| S-011 | There is no parallelism anywhere in the serving path | blocker | DONE |
| S-012 | The arrow ref log never compacts and replays the whole stream at open | blocker | DONE |
| S-039 | The exit status is lost to a russh close race — a succeeded push reports failure | blocker | DONE |
| S-040 | gunnar cannot clone from gunnar, though stock git can | blocker | DONE |
| S-044 | Nothing has ever been load-tested or profiled; there is no benchmark harness | blocker | IN-PROGRESS |
| S-003 | No delta encoding; every served pack is whole objects | scale | TODO |
| S-013 | reachable_from_refs() walks the whole repository per non-tip want, uncached |
scale | TODO |
| S-014 | Unbounded client-driven accumulation: haves, wants, commands, push-options | scale | TODO |
| S-015 | Two heap allocations per packed object, and each object buffered compressed | scale | DONE |
| S-016 | Every connection materialises and clones the whole ref list | scale | TODO |
| S-017 | O(n²) Vec::contains on two client-sized lists |
scale | TODO |
| S-018 | A ref transaction fsyncs under a std Mutex on a blocking thread |
scale | IN-PROGRESS |
| S-019 | The O_EXCL store lock does not self-clear; a killed appliance will not restart |
scale | DONE |
| S-020 | multi_ack is not advertised; negotiation costs extra round trips |
scale | TODO |
| S-021 | Refs.ReadObject reads the whole object before applying max_bytes |
scale | DONE |
| S-022 | 58 log lines in 27182 lines of code, no spans, no ids, no counters | scale | DONE |
| S-047 | Negotiation re-derives every round from scratch, so a divergent fetch is O(rounds²) | scale | IN-PROGRESS |
| S-048 | Every authenticated HTTP request does a single-ref CAS on the identity table | scale | TODO |
| S-023 | MustNotExist is not a create guard on the fs backend, and the conformance suite cannot see it |
correctness | DONE |
| S-024 | PushAttestation has no producer; three published event fields are constants |
correctness | DONE |
| S-025 | touch_key is never called, so last_used is never set |
correctness | IN-PROGRESS |
| S-026 | The real-git interop suites self-skip and report green | correctness | DONE |
| S-027 | write_stream allocates a client-declared size with no cap |
correctness | DONE |
| S-028 | Unchecked subtraction in the loose-object decoder | correctness | INVALID |
| S-029 | expect() on client key bytes in the auth hot path, and on lock poisoning per request |
correctness | DONE |
| S-030 | Five swallowed errors, none logged or counted | correctness | DONE |
| S-031 | deepen-since / deepen-not / deepen-relative were never run against stock git |
correctness | TODO |
| S-041 | PushGate is handed no new-object set and no pack byte count |
correctness | DONE |
| S-042 | Nested-namespace repos are clonable over SSH and invisible to gRPC | correctness | DONE |
| S-043 | Policy has no store: Policy.* is Unimplemented and gates read a JSON file off disk |
correctness | TODO |
| S-032 | design.md, storage-reasoning.md and both READMEs describe a design that was dropped |
smell | DONE |
| S-033 | There is no CI system; cargo xtask ci is the gate and a human types it |
smell | WONTFIX |
| S-034 | A 250 ms sleep per completed transfer, in the request-completion path | smell | TODO |
| S-035 | The three znippy git indexes are unbuilt, and the stree key diverges from the spec | smell | TODO |
| S-036 | Spec'd and unstarted: mirror replication, LFS | smell | TODO |
| S-037 | unreachable! on a clap verb match |
smell | TODO |
| S-038 | Three upstream nornir-git items forcing duplication here |
smell | DONE |
| S-045 | russh forces two C crates — the "no C toolchain" rule is not achievable | smell | WONTFIX |
| S-046 | Two tests are load-flaky: green alone, red under a saturated workspace run | correctness | IN-PROGRESS |
| S-049 | sync_dir has a third and fourth copy in gunnar-coldtier, unlogged |
smell | TODO |
| S-050 | Every file:line in the cycle diagram was unguarded, and 8 of 24 had rotted |
correctness | DONE |
| S-051 | The build stamp's rerun-if-changed names nothing in a git worktree |
correctness | DONE |
Counts: 14 blocker · 13 scale · 15 correctness · 9 smell — 51 total. 29 DONE · 5 IN-PROGRESS · 14 TODO · 1 INVALID · 2 WONTFIX, counted from the table above on 2026-08-05 with awk -F'|' '/^\| \[S-0/ {gsub(/ /,"",$5); c[$5]++} END {for (k in c) print k, c[k]}' rather than by hand.
This line has now gone stale three times in one day, twice in the same direction, and that is the argument for running the awk rather than editing the number. The version immediately before this one read "24 DONE · 14 TODO"; the same awk over the same rows, unchanged, gave 25 DONE · 13 TODO, so it understated by one before this pass even began. The version before that read "46 total" and "11 DONE · 1 INVALID · 6 IN-PROGRESS · 28 TODO", which is 46 against 47 rows: S-046 is filed in the smell group and carries correctness severity, and the severity tally had dropped it. Both stale versions are preserved here because the pattern is the finding. Four status fields have been found disagreeing with their own entries — S-033, S-038 and S-045 all understated, which is the safe direction, and S-007 overstated, which is not and is written up in its own entry. 2026-08-04: both awks were re-run before this edit and the previous line was correct — 26/7/12/1/1 over 47 rows, and the severity tally matched too. The first time it has not gone stale. It moved here only because S-021 closed and S-048/S-049 were filed; the severity tally moved with it, which is the half that was dropped last time.
The one INVALID is S-028. The subtraction it names cannot underflow, measured against gix-object 0.63.0 over 503539 inputs with a detector proved able to report a violation; adding the prescribed checked_sub would have been a guard with an unreachable error arm. The five IN-PROGRESS, as of 2026-08-05, each with the reason it is not DONE rather than a status word on its own (S-033 left this list on 2026-08-05, closed WONTFIX: the CI question is decided, not unfinished — cargo xtask ci, typed by a human, and the worktree hole in it is fixed): S-018 (the cheap half landed, group commit did not) · S-044 (no benchmark harness) · S-025 (still no producer and no sidecar, re-checked a fourth time on 2026-08-04; the deliberately-uncalled comment has landed, the feature has not — and its twin touch_token turns out to be wired already, filed as S-048) · S-046 (control_plane_starvation fixed and proved under load; repack.rs's real_git_clones_a_repacked_repository not reproduced in 399 runs at load average up to 154. The failure is now instrumented, not diagnosed: as of 2026-08-04 the next occurrence records its exit status, both streams and the hash kind, where the one observed failure recorded nothing. It is neither fixed nor explained) · S-047 (three per-round redundancies removed and measured; two remainders named).
S-021 closed on 2026-08-04, the last of its three passes: the object ceiling is 64 MiB — 512 MiB / 8 concurrent inflates, the arithmetic this entry carried for two sessions — settable to the deployment through gunnar serve --max-object-bytes.
Ten entries now carry a Ready to apply block: S-021 (scale) plus the nine correctness ones S-025, S-026, S-027, S-029, S-030, S-031, S-041, S-042 and S-043, worked in one pass on 2026-08-03 while five other agents held gunnar-wire, gunnar-store, gunnar-pack, gunnar-ssh, gunnar-cli, gunnar-core, gunnar-policy and gunnar-grpc, every crate in the tree except gunnar-proto, gunnar-pktline and gunnar-client. Each block names the exact edit and, more importantly, the RED that has to be seen first. Several of them name a naive fix whose guard could not fail, which is why those entries were not landed quickly rather than a sign they are hard. Only S-042 landed, because gunnar-proto was the one crate free.
Corrections to the working assumptions this pass started from
Recorded because they were wrong and the next agent should not re-derive them:
- "The CI dependency guard does not exist." Partly wrong.
crates/gunnar-cli/tests/feature_split.rsexists, asserts on a resolved graph viacargo tree -e normal, guards against a vacuous empty tree, and asserts both directions. It is a good guard. What is wrong with it is narrower and worse. See S-007: it omits the entire half of §2 that is currently being violated. And there is no CI to run it (S-033). - "SSH end-to-end was never proved." Wrong.
crates/gunnar-cli/tests/ssh_end_to_end.rsdrives realgitover realsshagainst a running gunnar, asserts the clone brings back the pushed bytes, and asserts a read-only principal is refused a push. It uses a hardassert!(tools_present(), …)rather than a silent skip — the only interop suite in the tree that does. That one is right; the others are S-026. - "
receive_packdoes not publish to the event bus." Partly wrong.RefUpdated/RefDeleted/PushRejectedare published fromcrates/gunnar-cli/src/server/transport.rs:391.PushAttestationstill has no producer, and three of the published fields are hardcoded; see S-024. - "One global writer Mutex per store." Correct, but the blast radius is per repository, not server-wide. One
ArrowLogStoreis one repository'srefs.arrow+secrets.arrow. The server-wide serialisation point is a different lock,ServerStores::open; see S-010. - rayon is absent, confirmed.
grep -c rayon Cargo.lock→ 0.cargo tree --workspace -i rayon→package ID specification 'rayon' did not match any packages. The only string hits in the tree are three comments forbidding it and one test asserting its absence. LAW 3 holds.gatling/znippy-zoomiesare equally absent,grep -c gatling Cargo.lock→ 0. So the parallelism law is currently satisfied by there being no parallelism at all (S-011).
blocker
S-001 — The identity table is one JSON blob, parsed 4× per SSH connection
- Found: 2026-08-03
- Status: DONE
- Severity: blocker
- Where:
crates/gunnar-core/src/doc.rs:105-130·crates/gunnar-core/src/auth.rs:174-183,auth.rs:372-386,auth.rs:461-475·crates/gunnar-ssh/src/control.rs:102-113 - What:
AuthDoc— every principal, every public key, every grant on the server — is oneserde_jsondocument stored as a blob behindrefs/gunnar/auth. There is no index and no cache:DocRef::readdoesrefs.read→objects.read→serde_json::from_sliceover the whole document on every call. The SSH connection path calls it four times: once inprincipal_by_fingerprint(auth.rs:377) and three more inControl::access_for, which loops[Admin, Write, Read]callingmay()(control.rs:107-111), andmay()opens withlet doc = self.doc.load()?(auth.rs:462).may()then doesdoc.grants.iter().filter(|g| g.principal == principal_id …), a linear scan of every grant on the server, becausegrantsis aVec<Grant>(auth.rs:182), not a map. - Why it matters at load:
PublicKeystores the fullopensshauthorized-keys line (model.rs:97) becausegunnar-policyneeds the key material to verify ssh-sig commit signatures. Anssh-rsa4096 line is about 730 base64 characters, so one key record is roughly 900 bytes of JSON. At forge scale — call it 200000 principals with 2.5 keys each — the auth document is ~450 MB. That is parsed four times per SSH connection, before a single byte of git protocol is exchanged, and there is nothing between the network and that parse but the russh handshake. One thousand concurrent clones is 4000 concurrent 450 MB parses. This is not a slow path, it is an OOM. The grant scan is a second, independent O(all grants) cost on the same path. (Sizes derived from the serde shape, not measured; no table that large exists yet.) - Fix: The control tables must stop being one document. Two changes, in order: (1) an in-process read-through cache keyed on the ref target, invalidated when the ref moves. That alone turns 4 parses per connection into 0 in steady state and is a small change entirely inside
DocRef. (2) the structural fix §4.5 already names for refs: an append-only Arrow log with a periodic fold, so a key lookup is a map probe and a grant lookup is an index on(principal, scope)rather than a scan.grantsmust becomeBTreeMap<String, Vec<Grant>>keyed by principal whichever route is taken. - Verify: A test that registers N principals and asserts the number of
ObjectStore::readcalls made by one fullprincipal_for_key+access_forsequence is constant in N. Instrument the store with a counting wrapper. It must be seen RED at today's code, where the count is 4 reads of an N-sized blob. A timing assertion is not acceptable here: it would pass on a small fixture. Separately, a grant-scan test with 100000 grants asserting lookup does not scan them all, seen RED first. - Closed: 2026-08-03.
DocRefnow caches the parsed document behind the reference target andAuthDoc's three maps are sharded tries of blobs;grantsis keyed by principal, somay()is a probe. Measured on a counted store (crates/gunnar-core/tests/control_cost.rs, seen RED first): one warm connection sequence went from 4 object reads / 16617 bytes to 0 / 0 at N=10, and the same at N=1000. One cold sequence went from 1639677 bytes to 16864 at N=1000 (97× less), and its growth from N=10 to N=1000 from 98.7× to 4.1×. One coldmay()went from 488816 bytes to 8509 (57× less), growth 100× → 4.5×. The eight-test file itself went from 82.9 s to 2.4 s. CAS correctness is asserted byconcurrent_writers_do_not_lose_an_update, and proved able to fail bya_broken_compare_and_swap_loses_acknowledged_updates, which runs the same eight writers against aRefStorethat forces every edit. Not fixed, and now written down:remove_principal,keys_ofandcountsare still O(table). They are the administrative paths, and indexing them would mean a second index to keep in step.
S-002 — The catalogue is one JSON blob; every repo lookup parses all of it
- Found: 2026-08-03
- Status: DONE
- Severity: blocker
- Where:
crates/gunnar-core/src/catalog.rs:29-37·catalog.rs:218-240·crates/gunnar-core/src/doc.rs:144-180 - What: Same mechanism as S-001, different table.
CatalogDocholdsnamespaces: BTreeMap<String, Namespace>andrepos: BTreeMap<String, Repo>for every repository on the server in one JSON blob behindrefs/gunnar/catalog.get,contains,open,list,namespace,namespacesandcountsall callself.doc.load(): full parse, then a single-key probe or a filter. Writes are worse in principle and the module says so honestly atdoc.rs:24-30:DocRef::updatere-serialises the whole document, writes it as a new blob, and CASes the ref. Creating one repository rewrites the catalogue. - Why it matters at load: A minimal
Reporecord serialises to about 170 bytes with an empty description and short names, counted off the serde shape atmodel.rs:182-197; 250-300 bytes is realistic with a real description. At one million repositories the catalogue is 170-300 MB. Reading it is on the connection path (Control::resolve→catalog.get), so that is a third whole-table parse per SSH connection on top of S-001's four. Writing it means onegit repo createallocates, serialises and hashes 300 MB and stores a new 300 MB blob. And because the update is a CAS on a single ref withMAX_ATTEMPTS = 8(doc.rs:46), two concurrent creates contend on one ref, so the sustained create rate is one repository per full-document round trip, server-wide. Every historical version of the document also stays in the object store forever (doc.rs:20-22calls that a feature) with norepack()to fold them (S-006), so N creates leave N × 300 MB on disk. - Fix: Same two-stage answer as S-001. The cache fixes the read path cheaply; the write path needs the log-plus-fold shape, where creating a repository appends one row. The CAS retry loop is correct and should be kept. The problem is the unit of the write, not the concurrency control.
- Verify: A test that creates 1000 repositories and asserts total bytes written to the
ObjectStoreis O(1000 × record) and not O(1000² / 2 × record). It must be seen RED today, where the quadratic is the actual behaviour. Plus the same constant-read-count assertion as S-001 forget. - Closed: 2026-08-03.
CatalogDoc'snamespacesandreposare sharded tries (the sameShardedMapS-001 uses; one implementation, LAW 5). Measured, seen RED first: creating a repository cost 8785 bytes per create at N=100 and 86635 at N=1000, a 9.9× growth, the quadratic. It now costs 2268 and 3596, a growth of 1.6×.catalog.geton the connection path reads zero objects warm. The CAS retry loop is unchanged, as the entry asked: the unit of the write moved, the concurrency control did not. Still O(repositories) arelist,delete_namespaceandnamespaces, the listing paths, by construction.
S-004 — A fetch materialises the client's whole object closure, then clones it
-
Found: 2026-08-03
-
Status: DONE
-
Severity: blocker
-
Where:
crates/gunnar-wire/src/graph.rs:391-409andgraph.rs:427 -
What: Step 4 of
select_objectsbuildsexcluded_objects: HashSet<ObjectId>containing every commit, tree and blob reachable from everyhavethe client sent, found by walking. Step 6 then does:
let mut seen = excluded_objects.clone();
a full duplicate of that set. Both live for the whole request.
-
Why it matters at load: For an incremental fetch against a large repository the exclusion set is essentially the whole repository. At ten million objects with sha256 ids, one
HashSet<ObjectId>is 10e6 × 32 bytes of keys plus hashtable overhead, call it 450 MB, and line 427 makes it 900 MB, per in-flight fetch. TheDEFAULT_BUDGETof 10000000 (graph.rs:38) bounds the walk, not the set, so it does not help: the budget is exactly the size at which this allocation is at its worst. Thousands of concurrent fetches is not a slowdown, it is an out-of-memory kill of the whole server, and it takes every other connection with it. The module doc atgraph.rs:11-19is honest that the walk is O(client history) and points at__gunnar_reach__; it does not mention that the memory is O(repository) and doubled. -
Fix: Two separate things, both needed. (a) Delete the clone at line 427. Step 6 can take
&excluded_objectsplus its own smallseenfor newly emitted ids, or the two sets can be merged and threaded by reference. That is a small, safe, immediate win and halves the peak. (b) The structural fix is__gunnar_reach__(§4.2): a roaring or EWAH bitmap makeswant − havean ANDNOT over a bitmap whose size is O(repository bits), not O(repository × 32 bytes), and it is shared across requests instead of being rebuilt per request. -
Verify: A test that runs an incremental fetch against a fixture and asserts peak resident set (or an instrumented allocation counter around
select_objects) does not exceed a bound proportional to the sent objects. Seen RED first; today it will be proportional to the whole repository, twice. -
Correction to the finding: it was three copies, not two. The entry names line 427. Step 4 held two more of the same size and neither was mentioned: the tree walk kept its own
seen: HashSet<ObjectId>and poured every id it found into asink: Vec<ObjectId>that existed only to beextended intoexcluded_objectsand dropped. Counted by the instrument below, an incremental fetch peaked at 2.34 × one closure, and that count does not see theVecat all, so the allocated peak was higher still. -
Closed: 2026-08-03,
crates/gunnar-wire/src/graph.rs(part (a); part (b),__gunnar_reach__, is untouched and remains §4.2's work).
What changed. select_objects now builds the client's closure once:
OidSet— a newtype overHashSet<ObjectId>that does not implementClone. The regression this entry is about is now a compile error rather than a line somebody has to notice in review (LAW 5: by construction, not by guard).- Steps 4 and 5 are swapped, so the excluded-commit set is moved into the excluded-object set instead of being copied. The two steps commute, one reading the commit graph and the other the object graph, and that is what makes the move legal.
- The exclusion walk's
seenset isexcluded_objects. One set, one insert per id. Emit— the tree walk either keeps the ids it finds (the fetch path, which is about to pack them) or tallies them (the exclusion walk, which already has them inseen). That deletes the repository-sizedVec. The traversal budget is charged against either sink identically.- Step 6 takes the exclusion set as its
seenset rather than cloning it. Sound becausetree_closureskips an id that is inexcludeor already inseen, so aseenpre-loaded with everythingexcludeheld decides every id the same way; theexcludeparameter stays forverify_connectivity, which must keep the two apart.
The guard, and the RED (LAW 2). graph.rs's a_fetch_holds_the_clients_object_closure_once counts the object ids the walkers hold at once — a cfg(test) meter inside OidSet, so a normal build pays nothing — and asserts a ceiling of one closure plus a bounded scrap and a floor of one whole closure. The floor is not decoration: a select_objects that never built the exclusion set would hold almost nothing, satisfy any ceiling, and send the client the entire repository on every incremental fetch. Seen RED by restoring the pre-fix shape (the clone, the second seen, the Vec):
the walk held 2139 object ids at once for a client closure of 915 — 2.34× one copy,ceiling 943.
A second test, the_budget_bounds_the_exclusion_walk_not_only_the_commit_walk, exists because its first version could not fail: a budget of 10 against 200 commits is tripped by the commit walk, so the assertion was green whatever Emit::Tally did. Both are now proved red by breaking Emit::Tally's len() to return 0: with the budget chosen to sit between the commit count and the object count, that reports the exclusion walk ran to completion under a budget of 400. The first version reported nothing, which is why it is not the version that shipped.
MEASURED on oden, 2026-08-03. Peak resident set of one incremental fetch, three runs each, VmHWM from /proc/self/status (the kernel's own high-water mark, so no sampler can miss the peak). Fixture: a bare sha256 repository of 200000 commits / 600000 objects built by git fast-import, one have (the tip's parent), one want (the tip), done. The fetch itself sends 3 objects / 367 bytes in both builds, so every byte below is the negotiation, not the pack. The measuring process does nothing else; its RSS before the fetch is ~7.8 MB.
| peak RSS | per excluded object | |
before (7312df2) |
182.9 MB (182.7 / 183.1 / 182.9) | 305 B |
| after | 140.8 MB (140.7 / 141.0 / 140.8) | 235 B |
| saved | 42.1 MB, 23% | 70 B |
Scaled to the ten million objects this entry is written against — derived, not measured, and linear because the sets are — that is about 700 MB less per in-flight fetch, which is the same order as the ~900 MB the entry estimated. Sixteen concurrent clones of such a repository is ~11 GB of duplication that no longer happens. What is not claimed: the remaining 140.8 MB is still O(repository), because one copy of the closure is still one copy. Only __gunnar_reach__ changes that.
S-005 — verify_connectivity claims O(new history) and is O(whole repository)
-
Found: 2026-08-03
-
Status: DONE
-
Severity: blocker
-
Where:
crates/gunnar-wire/src/graph.rs:463-483, doc comment atgraph.rs:466-469 -
What: The doc comment states:
The frontier is the set of reference values that existed before the push, so the walk costs
O(newly pushed history)rather thanO(repository): a client that pushes one commit onto a million-commit branch is checked against the one commit.
The code calls select_objects(graph, tips, frontier, …). Inside select_objects, the frontier is the excludes argument, and steps 3 and 4 walk the full commit closure of the excludes and then the full tree-and-blob closure of every one of those commits (graph.rs:380-409). The frontier is every pre-existing ref, so that closure is the entire repository. Only after paying that does step 5 compute the small set the doc describes. The stated complexity and the actual complexity differ by the size of the repository. This is a claim that was never tested against a repository large enough to contradict it.
-
Why it matters at load: Every push walks and hashes every object in the target repository. On a ten-million-object repository a one-commit push does ten million object reads and builds the ~900 MB set of S-004 before it can accept. Push latency becomes a function of repository size rather than push size, which is the exact scaling property a forge cannot have. It also means S-004's memory blow-up applies to the push path, not just fetch.
-
Fix: Connectivity does not need the exclusion object closure at all. What it needs is "is every object reachable from the new tips present, stopping at commits that were already refs". That is a walk from
tipsthat halts on any commit in the frontier set, with no closure of the frontier computed. Splitselect_objectsso the connectivity caller gets astop-on-frontierwalk and the pack caller keeps the exclusion closure (which S-004 then replaces with bitmaps). Do not fix this by editing the doc comment to match the code. -
Verify: A test with a repository of N pre-existing commits that pushes one commit and asserts the number of
ObjectStore::readcalls is bounded by a constant, not by N. Seen RED first; today it is Θ(N) plus every tree and blob. -
Closed: 2026-08-03, uncommitted in the working tree (
crates/gunnar-wire/src/graph.rs: newFrontier,verify_connectivityrewritten;crates/gunnar-wire/src/receive_pack.rs: the frontier built once per push; testcrates/gunnar-wire/tests/connectivity_cost.rs). MEASURED, seen RED first. The test pushes one commit onto a branch that already carries N commits and counts everyread,headerandcontainsthe push makes, assertingcost(N=8) == cost(N=400)exactly; a bound on a single N would pass on anything generous, and> 0is asserted separately so a stubbed check cannot pass by touching nothing. Against the old code: 77 touches at N=8 and 2037 at N=400, i.e. ~5 object touches per pre-existing commit, which is the finding. After: equal at both N, and the push is asserted to have actually been accepted and the reference actually moved, so the cost measured is not the cost of refusing. The fix is a walk from the new tips down, stopping at any commit a reference already pointed at, with the frontier's root trees excluded from the tree walk and oneseenset for the whole push. The frontier's object closure — steps 3 and 4 ofselect_objects, which is the entire repository — is never computed.Frontieris also built once per push rather than once per reference command, which was a second factor of K nobody had noticed. The doc comment was corrected as well as the code, because the code does not reach the complexity the old comment claimed and pretending otherwise would repeat the original sin. The honest bound is now stated in the source asO(new commits + the tree closure of those commits), one snapshot's worth of trees rather than the repository's history, with the two remainders named: a blob is existence-checked rather than inflated but the tree side is still proportional to the number of paths in the snapshot, and a push that bases a new branch on an old commit that is not itself a reference value has nothing to stop the commit walk before the root. Both need reachability data gunnar does not have, either §4.2's__gunnar_reach__or the incoming pack's new-object set (S-041), and the comment says so rather than claimingO(newly pushed history). Green after: 39/39gunnar-wireprotocol tests and 17/17real_git_interop, includingpush_create_update_delete_sha1,push_sha256,push_rejected_by_a_gate_is_reported_to_gitandpush_atomic_rejects_the_whole_batch. Realgitpushing into gunnar, which is what says the narrower walk still refuses an incomplete push.
S-006 — repack() does not exist; no cold tier ever forms
-
Found: 2026-08-03
-
Status: DONE
-
Severity: blocker
-
Where: absent. Referenced as existing by
crates/gunnar-store/src/lib.rs:48-49·crates/gunnar-store/src/arrow_log.rs:24·crates/gunnar-core/src/catalog.rs:13·crates/gunnar-policy/src/gates/limits.rs:10·crates/gunnar-store/src/conformance.rs:1084. Honestly refused atcrates/gunnar-cli/src/server/admin.rs:290-328. -
What: MEASURED, independently re-confirmed when
gunnar-clilanded, and now stated inREADME.md, so it is a commitment. Verified by grep: there is nofn repackanywhere in the workspace, andgunnar gclists what it would repack and exits non-zero rather than pretending. Five source files describe it in the present tense as the mechanism that folds pushed packs into a znippy archive and compacts the arrow log.gunnar gccorrectly refuses rather than pretending (admin.rs:316-321); that part is right. There is also no tieredObjectStore: the only two implementations areMemObjectStore(a flatBTreeMap) andFsObjects(a flatgix_odbhandle).gunnar-store-znippyandgunnar-store-icebergare listed in §2's crate table and do not exist on disk. -
Why it matters at load: Nothing ever reclaims. Incoming packs accumulate under
objects/packforever, every superseded control-document blob stays forever (S-001/S-002 generate one per mutation), and the arrow ref log never compacts (S-012). Disk grows monotonically with total lifetime traffic, and read performance degrades with the number of pack files. There is no operator lever;gunnar gcexists and refuses. -
Fix: Build
repack()per §4.4: write a new archive from (old ∪ incoming packs), fsync, atomically swap, truncate the incoming tier (D14 forbids mutating in place). It is the same wave asgunnar-store-znippyand the__gunnar_oid__stree index (S-035). The pack-rewrite is a parallel workload: fan out with gatling. -
Verify: The conformance suite run against the tiered store, plus a test that pushes into a repository, runs
repack(), and asserts (a) objects are readable across the swap with no window where a read fails, and (b) the incoming tier shrank. Seen RED first, trivially, since the function does not exist; keep the test after it does and assert (a) by reading concurrently during the swap, which is where an atomic-swap bug actually lives. -
Closed: 2026-08-03,
FsStore::repack()incrates/gunnar-store/src/repack.rs, driven bygunnar gc(crates/gunnar-cli/src/server/admin.rs).gunnar gcnow repacks and reports applied outputs; the test that asserted its refusal (serve_wiring.rs) is inverted, not deleted.
The cold tier is a consolidated PACKFILE, not a znippy archive, and that is a deliberate answer to P-005 rather than a shortcut. P-005 establishes that §4's znippy tier stores inflated object content, which forecloses pack-copy permanently and makes every cold-tier fetch pay a full recompression, the exact cost P-001 exists to delete. P-005's own fix (store the deflated pack entry) turns the tier into a packfile with an archive around it, which §4 explicitly rejects ("packs-in-znippy is rejected"). Those two cannot both hold, and the choice between them is Rickard's; P-006 (znippy needs cmake, a C++ toolchain and a build-time network fetch) and P-014 (znippy has no writer lock at all) both argue for not hard-wiring znippy into the appliance until it is made. So repack() was built over the tier git itself uses. The swap protocol is what §4.4 specifies and is unchanged when the tier is replaced: snapshot → write new → verify open → only then remove.
What it does: snapshots the packs and loose objects to fold (so a push arriving mid-repack is safe by construction, not by a lock); writes one new pack through gix-pack's generator in PackCopyAndBaseObjects mode; hands it to gunnar-pack::index_pack, which persists pack-<id>.pack then pack-<id>.idx, and since gix-odb discovers packs by their index, the new tier becomes visible in one atomic rename; reopens the index that landed and looks up every object; fsyncs the directory; and only then unlinks the folded packs (.idx first) and loose objects. A second run with nothing to fold is a no-op, asserted by pack-file mtime, not a rewrite.
P-001 is unblocked, and measured, not assumed. RepackOutcome reports objects_copied_from_pack against objects_recompressed, because a byte count and a wall clock cannot tell a pack-copy from a re-deflate. On the sha256 fixture: 11 copied, 1 recompressed of 12 objects, the one being the loose object, which has no pack residency to copy from. Delta chains survive a repack.
RepackOptions::prune_unreachable (default off) is what finally reclaims a superseded control-document blob. It is off by default because an object written but not yet referenced is unreachable by definition; the hazard is documented at the option, and there is no CLI flag for it yet, so gunnar gc never prunes.
Zero new crates: the appliance graph is 313 packages, the same figure S-007 closed on. gix-pack, gix-features and gunnar-pack were all already in it.
Seen RED twice, deliberately, before being trusted (LAW 2):
- dropping one object from the folded set → 4 tests failed, naming the object count, the loose fold and the pruned count;
- moving the deletion of the old packs to before the new pack exists, the exact thing D14 forbids → the interrupted-repack and the SIGKILL test both failed on "a pack the repack had not finished replacing was already deleted".
Still open, and not claimed: the znippy tier itself (P-005/P-006 must be settled first), the __gunnar_oid__ stree index (S-035), arrow-log compaction at repack (S-012), and a --prune flag on gunnar gc.
S-007 — The appliance build resolves gix-worktree, gix-index, gix-filter, gix-command
-
Found: 2026-08-03
-
Status: DONE
-
Severity: blocker
-
Where:
crates/gunnar-cli/Cargo.toml:24-44(serverfeature pullsgunnar-store/fs) ·crates/gunnar-store/Cargo.toml:15-16(fs = ["dep:gix"]) · guard that misses it:crates/gunnar-cli/tests/feature_split.rs:69-87 -
What: MEASURED 2026-08-03, command:
cargo tree --manifest-path crates/gunnar-cli/Cargo.toml -p gunnar-cli \
--no-default-features --features server -e normal
The appliance build resolves 350 unique packages, 47 of them gix-*, and among them every crate §0 lists as "the server needs none of" and §2 lists as "Forbidden in the S graph specifically":
gix-worktree 0.55.0 · gix-index 0.54.0 · gix-filter 0.33.0 · gix-attributes 0.34.0 · gix-ignore 0.22.0 · gix-glob 0.27.0 · gix-config 0.59.0 · gix-discover 0.54.0 · gix-pathspec 0.19.0
plus a complete client-side git transport stack that a server has no use for — gix-protocol 0.64.0, gix-transport 0.58.0, gix-url 0.37.0 — and gix-command 0.9.1, the crate whose job is spawning git subprocesses, in a build whose specification opens with "Zero shell, both halves. No git subprocess." crates/gunnar-cli/src/server/stores.rs:10-18 admits the fs-backend part of this in prose, re-confirmed when gunnar-cli landed: fs is the only ObjectStore in the tree that survives a restart, so the appliance build has no alternative until S-006's cold tier exists. The rest of the list is not admitted anywhere. feature_split.rs is a good guard aimed at the wrong half. It checks for gunnar-client, nornir-git, rayon, libgit2, git2, openssl, skidbladnir and zbus, and passes. It checks for none of the nine crates above. It is green while the constraint it appears to enforce is broken. The LAW 2 shape, in a guard whose own header is about not trusting declarations.
-
Why it matters at load: This is what the appliance image is, and tunnr's whole case is that the image is small and has no filesystem assumptions. Every one of those crates is filesystem machinery linked into a binary meant to run as PID 1 with no filesystem. It is also 350 packages against §2's stated budget.
-
Fix: Two parts. (a) Extend
feature_split.rswith the §0/§2 forbidden list — matched with the trailingvthe file already uses for its other names — and let it go red now, as a standing marker, rather than deleting the finding. (b) Remove the cause: the appliance needs anObjectStorethat survives a restart and is notgix::Repository, which is S-006's znippy cold tier. Until that lands,fsis the only survivable backend and the guard should be red with a comment pointing here. -
Verify: Add the nine names to the loop at
feature_split.rs:80and see the test go RED on today's tree. Then, after the cold tier lands, see it go green, and confirm by re-running thecargo treecommand above by hand, because a guard that reads a tree it failed to produce is exactly the vacuous case that file's owntree()helper defends against. -
Closed: 2026-08-03. Appliance tree 350 to 313 crates, 47 to 24 gix; all 8 banned crates absent. The fs backend moved off the gix porcelain facade onto gix-odb + gix-ref. Feature narrowing could not have worked: gix-config and gix-discover are unconditional deps of gix.
-
Correction 2026-08-03 — this entry was marked DONE with HALF of its Fix and NONE of its Verify clause done, and the missing half was the guard. Part (b), the cause, was genuinely fixed and is re-measured below. Part (a) — "Extend
feature_split.rswith the §0/§2 forbidden list … and let it go red now, as a standing marker" — was not.nothing_forbidden_is_in_either_graphstill listed exactlyrayon,libgit2,git2,openssl,openssl-sysand none of the nine crates this entry is named for, which is word for word the defect the What section describes: "a good guard aimed at the wrong half … green while the constraint it appears to enforce is broken". So the finding was fixed and then left with nothing standing between it and the next dependency edit.
Re-MEASURED 2026-08-03, the command in the What section, run by hand and not read off a cached tree: the appliance resolves 314 unique packages, 24 of them gix-* (313/24 when this entry closed; the extra one is other work landing, not a regression here), and all thirteen forbidden names — the nine gix-* filesystem crates plus gix-protocol, gix-transport, gix-url and gix-command — are absent.
The guard now exists, in crates/gunnar-cli/tests/feature_split.rs: the_appliance_links_no_worktree_no_client_transport_and_no_subprocess, over the resolved cargo tree -e normal graph, appliance feature set only — the default set is deliberately excluded, because gunnar-client is a git client and legitimately links the transport stack.
Seen RED, by proving the detector rather than by faking the disease. The historical red is no longer reproducible without putting the porcelain facade back, so what was watched failing is the matching itself: adding gix-odb v — a crate that genuinely is in the appliance graph, and is what the fs backend moved onto in the fix — turns the new test red with
the APPLIANCE build links gix-odb v, which §0 and §2 forbid it (S-007).
5 passed; 1 failed. A companion test, the_forbidden_list_would_actually_catch_a_crate_that_is_there, pins that permanently: it asserts gix-odb v is present and that gix-od v is not, so neither an empty cargo tree nor a mis-shaped name can leave the list matching nothing. Without it the thirteen assertions above are thirteen ways of comparing a string to itself.
S-008 — No connection limit on either listener; 512 blocking threads are the accidental cap
-
Found: 2026-08-03
-
Status: DONE
-
Severity: blocker
-
Where:
crates/gunnar-ssh/src/server.rs:231-243(accept loop) ·crates/gunnar-ssh/src/config.rs:50-62·crates/gunnar-cli/src/server/serve.rs:142-168·crates/gunnar-cli/src/server/transport.rs:122·crates/gunnar-cli/src/main.rs:41-44 -
What: There is no
max_connections, no semaphore, no per-IP or per-principal rate limit, and no admission control anywhere ingunnar-ssh. The listener binds and hands the socket to russh'srun_on_socket; nothing bounds how many sessions exist at once. The gRPC listener is the same:tonic::Server::builder()with noConcurrencyLimitLayerand noTimeoutLayer. What does exist and is genuinely useful:inactivity_timeout600 s, a per-sessionexec_gracewatchdog of 60 s that closes a session that authenticates and never execs (server.rs:379-400), constant-time auth rejection, andmax_auth_attempts: 6— but that last one is per connection, so it caps nothing across connections. The real limit today is that each git transfer occupies one tokio blocking-pool thread for its whole duration (transport.rs:122), and nothing callsmax_blocking_threads, so the default 512 is the server's actual concurrency ceiling. Past it, transfers queue silently: no log, no metric, no error, no visibility. -
Why it matters at load: "Thousands of concurrent clones" is the stated target and 512 is the undesigned ceiling. Slowloris is partly answered by the two timeouts, but a fork-bomb of clones is not answered at all; a client opening ten thousand sockets is limited only by file descriptors. And because the queueing is invisible, the failure mode an operator sees is "git is slow", with nothing in the logs.
-
Fix: An explicit
tokio::sync::Semaphoresized from configuration in front of the transfer spawn, with the wait time logged and counted so saturation is visible; an explicitmax_blocking_threadsso the number is a decision rather than a default; a per-principal and per-IP concurrent-transfer cap; andConcurrencyLimitLayer+TimeoutLayeron the gRPC router. Every one of these needs a number an operator can set, because the right number is deployment-specific. -
Verify: A test that opens
limit + 1concurrent transfers and asserts the last one is refused with a clear message (or waits and is counted) rather than silently queueing. Seen RED first; today it queues invisibly. Assert the counter moved, not just that nothing crashed. -
Closed: 2026-08-03, an explicit ceiling with a visible refusal, in
crates/gunnar-ssh/src/admission.rs.SshConfiggrowsmax_concurrent_transfers(default 256) andmax_transfers_per_principal(default 32);exec_requesttakes a slot fromAdmissionafter authorisation and holds it in aDropguard for the life of the transfer. Refuse, not queue — decided, not defaulted. A git client retries, so a refusal that arrives in milliseconds is better information than a socket that goes quiet; a queued transfer still holds the TCP socket, the russh session, the channel and the key material that a fork-bomb of clones is trying to exhaust; andinactivity_timeout(600 s) andexec_grace(60 s) both read a queued transfer as idle, so a long wait ends in a disconnect anyway. Above all, a queue would make saturation invisible again, which is the whole bug. The refused client is TOLD: a pkt-lineERR … try againand exit status 75 (EX_TEMPFAIL, deliberately not the 128 that means "denied", so a retry script can tell transient from permanent). The refusal is LOGGED atwarnwith the limit and the in-flight count, and it is COUNTED:AdmissionStats { admitted, refused_at_capacity, refused_per_principal, in_flight, peak_in_flight }, reachable fromBoundServer::admission()andServer::admission(). Per-principal rather than per-IP, because gunnar authenticates keys: a per-IP cap throttles an office behind one NAT and misses a botnet.max_blocking_threadsis now owned too:gunnar_cli::server::serve::GIT_BLOCKING_THREADS = 512, built byserve::runtime(), with a unit test asserting it stays at least 2× the transfer limit so the invisible ceiling cannot come back underneath the visible one. Seen RED: with the gate swapped forAdmission::unlimited(), 3 of the 5 tests incrates/gunnar-ssh/tests/admission.rsfail: the third concurrent transfer is served (banner instead ofERR), no exit status arrives, andrefused_at_capacitystays 0.the_limit_is_the_thing_being_testedpins the same behaviour from the other side with the limit configured off, so the guard cannot pass for an unrelated reason. NOT done, and still open under this id: the gRPC router has noConcurrencyLimitLayerorTimeoutLayer(the control plane is now isolated by S-009's separate runtime, which bounds the damage but is not a request cap), and there is no accept-level connection cap. russh owns the accept loop, and the resource that actually runs out is the transfer. -
Reopened and re-closed, 2026-08-03 — the per-principal cap was one eighth of the advertised limit and bound on an EMPTY server. The first fix was right that one identity must not be able to take a shared host away from everybody else, and wrong about when that should bind.
DEFAULT_MAX_TRANSFERS_PER_PRINCIPAL(32) was an unconditional ceiling against aDEFAULT_MAX_CONCURRENT_TRANSFERSof 256, so it fired whether or not there was anybody to be fair to. Measured on oden, 32 cores (the numbers that reopened this):
| load | served | refused |
| 1 principal, 64 clients | 32 | 32, all per-principal |
| 32 principals, 320 clients | 256 | the rest |
So a client holding one key could never reach the 256 written on the box; that took eight identities. gunnar is an appliance — one team, a handful of keys — so every real deployment silently got one eighth of its capacity, and the 224 idle slots were invisible because the refusal named a limit that was doing exactly what it said. Fixed as max-min fairness in crates/gunnar-ssh/src/admission.rs (Admission::ceiling_for): the per-principal number is now a floor and a share, not a wall.
contended = occupied (candidate included) ≥ CONTENTION_PERCENT (75) % of the server-wide limit
ceiling(p) = if !contended { the server-wide limit }
else { max(configured, server_wide / active principals) }
admit = the server-wide gate has room AND p holds < ceiling(p)
75 % of 256 is 192, leaving 64 free slots — twice the configured floor — as headroom in which an arriving identity is never asked about fairness at all. Below that threshold free slots exist by definition and refusing anything refuses work the box could do, which is the mistake being corrected. 192 in flight is also 6× the core count the defaults were sized for (performance-design.md Measurement 3), so the machine is deep into queueing before fairness engages. active principals is the length of the per-principal map read under the same lock as the counts, so it is exact rather than sampled — and a stale entry now shrinks everybody's share, which is why the map is still scrubbed of zero counts. The refusal reports the ceiling in force, not the configured one, plus how many identities it is divided between; an operator who read 1 out of a config file and 2 out of a log would otherwise conclude the gate was broken. The old behaviour is still available and is now reachable: SshConfig:: per_principal_limit_is_hard / gunnar serve --hard-per-principal-limit, for a host where an identity is a tenant and the cap is what is being sold. --max-concurrent-transfers and --max-transfers-per-principal are settable too, which this entry's own Fix line asked for in the first place and which nothing had delivered: both numbers were unreachable from the command line, so the two defaults were the only values any deployment could have. Seen RED, both directions, three levels. Two probes inside ceiling_for, each failing a disjoint set — which is what proves the tests measure different things rather than agreeing with each other:
- probe
return configured(the pre-fix wall) → 6 of 10 unit tests and 3 of 7 integration tests fail. The message is the bug verbatim: "transfer 32 of 256 was refused on an EMPTY server holding one identity", and over the real SSH wire "transfer 1 of 8 was refused on an EMPTY server holding ONE identity, with a per-principal floor of 1". - probe
return server_wide(fairness never binds) → 5 of 10 unit tests fail, including the greedy-principal and hard-cap ones; the direction-A tests pass, as they must. Third level, the shipped release binary undergunnar-installtest pressure, same server, same 2003-object fixture, same 32 clients on one key, 32-wide server, floor 4 — one flag apart (oden, load 53–62): --hard-per-principal(the pre-fix wall): 4 ok, 28 refused (28 per-principal), gate peak 4, overlap 10.8×.- shipped default: 32 ok, 0 refused, overlap 24.3×. Contention still binds: 64 clients over 8 identities against the same 32-wide server gave 33 ok / 31 refused (10 per-principal + 21 server-wide), gate peak exactly 32, and the two witnesses agreed exactly (client 10+21, server log 10+21).
gunnar.bare_pressurenow drives both rounds — solo and contended — on one server and one fixture, and shrinks the server (--max-concurrent-transfers 32) rather than the client count, because contention at the shipped 256 begins at ~200 concurrentgitprocesses. The gate's rule is a ratio, so a small server is the same arithmetic; the fixture is still not shrunk, because that changes whether transfers overlap at all. One trap found while doing it:PressureRound::okcounts clones completed over a round, not concurrency, so it legitimately exceeds the limit (measured 33 against 32).peak_in_flightis the only witness of the concurrency limit and the arm asserts on that.
S-009 — The gRPC control plane blocks tokio worker threads and starves the SSH listener
-
Found: 2026-08-03
-
Status: DONE
-
Severity: blocker
-
Where:
crates/gunnar-cli/src/server/control_plane.rs:859-886(walk) ·control_plane.rs:776-811(storage_info) ·control_plane.rs:471-543(log) ·control_plane.rs:354-375(create_repo) ·crates/gunnar-cli/src/server/stores.rs:143-168· design note that predicted this:crates/gunnar-grpc/src/seam.rs:28-34 -
What:
ControlPlaneis deliberately a synchronous trait, andseam.rs:28-34says the backend belongs behindspawn_blocking"at the call site". No call site does it.spawn_blockingappears zero times ingunnar-grpc/src, and every service method callsself.ctx.control.xxx()?straight from the async fn. What runs on the worker thread as a result:walk(control_plane.rs:861) — an unbounded recursivestd::fs::read_dirthatstats every loose object file in the repository, reachable from the unauthenticatedAdmin.StorageInfo;log— a synchronous commit walk, oneobjects.readper commit;create_repo→stores.repo_store→std::fs::create_dir_all+FsStore::open_or_init, which opens a git directory and replays an arrow log.
Both listeners run on one new_multi_thread runtime built in main.rs:41-44, and the worker pool defaults to core count.
-
Why it matters at load: A handful of concurrent
Admin.StorageInfocalls against repositories with many loose objects will occupy every worker thread for as long as the directory walks take. While they do, the SSH listener's accept, auth and exec dispatch, all async tasks on the same pool, do not run. The two-listener separation of D13 does not survive contact with a shared runtime. And since D9 leaves gRPC unauthenticated, anyone who can reach the control port can do this deliberately. -
Fix: Wrap every
ControlPlanecall intokio::task::spawn_blockingat the service layer: one helper, applied uniformly, per LAW 5 rather than 30 copies. Boundwalkwith a depth and entry cap and a deadline. Consider a second runtime for the control plane so a control-plane stall cannot reach the git path at all. -
Verify: A test that issues a slow
Admin.StorageInfoagainst a fixture with many loose objects and asserts an SSH handshake completes within a deadline while it runs. Seen RED first, because today the handshake is expected to stall. Assert the handshake, not the RPC's own return: the RPC returning proves nothing about what it blocked. -
Closed: 2026-08-03. The control plane runs on a runtime of its own, built in
crates/gunnar-cli/src/server/serve.rs:control_plane_workers()= cores ÷ 4 clamped to 2..=8, overridable withServeConfig::control_plane_workers. The gRPC listener is bound inside that runtime, because a tokio socket registers with the I/O driver of the runtime that created it. Chosen over wrapping ~30 service methods inspawn_blocking: it holds for every control-plane call including ones written later by someone who never readseam.rs, and a control plane that stalls now degrades the control plane only (LAW 5: one place, not thirty). It also needed no edit ingunnar-grpc, which another agent was inside.Servergained aDropimpl and ends the runtime withshutdown_background, because dropping aRuntimeon a runtime thread panics. MEASURED on oden,crates/gunnar-cli/tests/control_plane_starvation.rs. 16 concurrent authenticatedAdmin.StorageInfocallers against a 120 000-loose-object repository (one call = ~180 ms), server runtime of 2 workers, load generated from a separate runtime so the measurement is of the server's scheduling and not the test's:
| SSH identification latency | median | |
shared runtime (pre-fix, HEAD) |
1.59 s – 3.02 s | ~2.4 s |
| control plane on its own runtime | 86 µs – 2.8 ms | ~148 µs |
~1000× on the worst case. The probe reads the SSH-2.0-gunnar_… line over std::net from an OS thread outside both runtimes; measuring from a task on the starved runtime would have been right for the wrong reason. The test asserts the fixture is big enough to block (one call ≥ 10 ms) and that the control plane really was loaded, so a green cannot be vacuous. Note what the pre-fix server did not do: fail. It answered seconds late, which is exactly the "git is slow" an operator cannot find in a log. NOT done: walk still has no depth cap, entry cap or deadline. A caller can still make the control plane itself unresponsive — that is now the only thing they can make unresponsive, which is the property this fix bought.
S-010 — The open-repository handle cache is unbounded and never evicts
- Found: 2026-08-03
- Status: DONE
- Severity: blocker
- Where:
crates/gunnar-cli/src/server/stores.rs:56andstores.rs:143-177 - What:
open: Mutex<HashMap<String, FsStore>>caches oneFsStoreper repository for the life of the process. There is no LRU, no capacity, no TTL and no eviction;close_repois called only byrepo delete --purge. The module explains why the cache exists —FsStoretakes anO_EXCLlock, so opening one per request would fail the moment two clones of the same repository overlapped — and that reasoning is correct. What is missing is the other half: a bound. Each cachedFsStoreholds an open lock file, an opengix::ThreadSafeRepository(loose-object directory handles and mmapped pack files), an openrefs.arrowandsecrets.arrowfile, and the fully replayed ref map in RAM (S-012). TheMutexis also taken on every repository lookup on every request, making it the one genuinely server-wide serialisation point in the design, and it is guarded by.expect("the repository handle cache lock")in three places, so one panic anywhere under that lock poisons it and every subsequent request panics. - Why it matters at load: A server touching a million repositories accumulates a million open handles. File descriptors run out first, typically long before memory, and the failure is total, not graceful: every new repository open fails while the cache holds descriptors for repositories nobody has touched in weeks. Address space for the pack mmaps goes the same way.
- Fix: A bounded LRU with a configurable capacity, evicting on insert; and because eviction drops the
O_EXCLlock, an in-flight transfer must hold a reference that keeps its entry alive, so the value wants to beArc<FsStore>with eviction refusing to drop a handle with outstanding users. Shard the map to remove the single global mutex. Replace the three.expect()s on the lock with poison recovery or a typed error. - Verify: A test that opens
capacity + Kdistinct repositories and asserts the number of live handles stays at capacity and that a repository still being read is not evicted out from under the reader. Seen RED first. Today the count grows without bound, and the second half cannot even be expressed. - Closed: 2026-08-03.
ServerStores::openis a bounded LRU ofArc<FsStore>(DEFAULT_OPEN_REPOS = 256, a descriptor budget: ~10 fds per handle against a default 1024RLIMIT_NOFILE), and the evictor skips any handle with outstanding users, so an in-flight transfer cannot have itsO_EXCLlock pulled out from under it. The three.expect()s on the lock are gone: a poisoned cache is recovered, not propagated.crates/gunnar-cli/tests/handle_cache.rsasserts both halves and both were seen red: with eviction disabled the cache holds 20 handles for 20 repositories at a capacity of 4; with the liveness filter removed,repo_storeon the held repository fails outright on the lock, the production failure the reference count exists to prevent. Not done: the map is still behind one global mutex. Its critical section holds no I/O, and no profile has shown contention there, so sharding it is written down rather than built.
S-011 — There is no parallelism anywhere in the serving path
-
Found: 2026-08-03
-
Status: DONE
-
Severity: blocker
-
Where:
crates/gunnar-pack/src/write.rs:182-209(write_pack) ·write.rs:227-232(missing_objects) ·crates/gunnar-wire/src/graph.rs:155-202,graph.rs:210-269,graph.rs:337-461·crates/gunnar-core/src/auth.rs:469-475 -
What: Every one of these is a serial
forloop over objects on a single thread: pack generation including per-object zlib deflate (the dominant CPU cost of serving a fetch), the commit closure, the tree closure, the exclusion closure, the connectivity check, and the grant scan. MEASURED:grep -c gatling Cargo.lock→ 0;znippy-zoomiesis not a dependency of any crate in the workspace. The only parallelism in the whole tree is insidegix_pack's own index-writing thread pool, reached throughIndexOptions::thread_limit(read.rs:27-28, 52-56) on the receive path — gunnar's own code, not gunnar's. ThreeCargo.tomls carry a "NO rayon (LAW 3)" comment. LAW 3 is satisfied, but by omission: there is no fan-out to have chosen the wrong tool for. -
Why it matters at load: One clone of a large repository pegs exactly one core and leaves the rest of the machine idle. Throughput per box is therefore (cores × concurrent transfers) only if the transfers are independent, and they contend on S-010's global mutex and S-009's shared worker pool. A forge is a parallel workload being served serially.
-
Fix:
gatling(znippy-zoomies) is the tool. Never rayon — LAW 3, ROOT LAW #0. The four workloads that must fan out, in priority order:- Pack generation — per-object deflate and (once S-003 lands) delta search. This is embarrassingly parallel over a known object list with an ordered output; it is the biggest single win available.
- Object closure walks — the tree closure in particular is a frontier expansion over independent subtrees.
- Connectivity checks on push (S-005) — same shape.
repack()(S-006) — a bulk rewrite, the classic batch case. Addznippy-zoomiesto the workspace dependencies when the first of these lands, and extendfeature_split.rs(S-007) to assert rayon stays out once a real fan-out exists to be tempted by.
-
Verify: A benchmark — there are none today (S-033) — that packs a fixture with N objects and reports objects/second, run before and after. Then a guard that the parallel and serial paths produce byte-identical packs, seen RED by deliberately reordering one object. Do not assert "it was faster": on a small fixture the threaded path is slower and the guard would be tuned away.
-
Closed: 2026-08-03, resolved as a measured decision NOT to parallelise the per-request serving path, per
P-004, plus the one real declaration bug this entry was hiding. The reflex fix in this entry's own "Fix" list — fan pack generation out withgatling_map_balancedper request — is a regression at the load target, and it is measured, not argued. gatling has no pool object and no global runtime (gatling_forkjoin.rsmodule doc: "Purestd::thread::scope; no global pool, no rayon"); every call spawnsn_workersfresh OS threads and joins them. At the stated target of 1000 concurrent clones that is 1000 × 64 = 64 000 OS threads plus a spawn/join storm per request, andperformance-design.mdMeasurement 3 already shows this box slower at 64 threads than at 32 (20.3× at 32, 19.2× at 64, efficiency 63% → 30%). Intra-request fan-out buys latency, not throughput: at saturation it rearranges the same total work. So the serving path stays one core's worth per transfer, and concurrency comes from serving many transfers at once, which is exactly whatS-008's admission gate now meters and bounds. That gate is the implementation of this decision, not a separate thing. What did land under this id:P-002.gix-pack'sparallelfeature is now declared by the workspace instead of inherited. MEASURED before the change:cargo tree -p gunnar-pack --no-dev-dependencies -e features -i gix-featureslistedcrc32, default, fs-read-dir, prodash, progressand noparallel, sogunnar-pack— the one crate that passesIndexOptions::thread_limiton the receive path — resolved serial when built alone, with no error and no warning. The appliance set had it only viagunnar-store/fs→gix-odb/parallel, i.e. by accident.crates/gunnar-cli/tests/feature_split.rs::the_pack_indexer_asks_for_its_own_threadingasserts it in both feature sets and was seen RED against the old declaration. Noznippy-zoomiesdependency was added, and that is deliberate rather than a blocker: gatling is published (gatling = { package = "rotaryengine", version = "0.1.10" }, pure Rust, no C, no cmake; it isznippy-common/OpenZL that carriesP-006's toolchain cost, not gatling), so adding it later is cheap. There is simply no non-per-request batch workload in the serving path to give it. The batch tier is where it belongs:repack()(S-006),gc, index building (P-011), the closure walk on a large fetch (P-010) and connectivity checks on a large push (S-005). Those are one-at-a-time offline jobs and should takegatling_map_balancedwithn_workers = 0behind a semaphore of 1–2, called throughspawn_blocking— gatling blocks and must never run on a runtime thread. NOT done: the per-object deflate, closure walks and connectivity checks are still serialforloops. That is the decision, not an oversight — but it means a single clone of a huge repository on an idle box is still one core, andP-004's "fan out only above a size threshold and only when the admission gate says the box is idle" is unbuilt.
S-012 — The arrow ref log never compacts and replays the whole stream at open
-
Found: 2026-08-03
-
Status: DONE
-
Severity: blocker
-
Where:
crates/gunnar-store/src/arrow_log.rs:388-424(Log::open) ·arrow_log.rs:268-281(Backing::read_all) ·arrow_log.rs:801-824(replay_refs) · the promise atarrow_log.rs:24 -
What: Opening a store reads the entire
refs.arrowinto oneVec<u8>(read_all), scans it for the last complete IPC message, constructs aStreamReaderover all of it, and replays every batch ever written into aBTreeMapwith last-wins semantics. The design table at line 24 says compaction happens by "rewrite the whole state as one batch atrepack()", andrepack()does not exist (S-006). So the log only ever grows. Each ref transaction is one framed IPC message: a flatbuffer schema message for seven columns plus a padded body. Order of 1 KB on the wire for what is logically a 32-byte ref move. The module doc's "Refs are few, so this is instant" (line 21) is true of the number of refs and false of the number of writes, which is what is replayed. -
Why it matters at load: A repository pushed to a hundred times a day for five years is ~180000 batches, an order of 200 MB
refs.arrowthat is read whole into memory and replayed on every open, to reconstruct a map of maybe fifty refs. Because handles are cached forever (S-010), that cost lands once per repository per process lifetime. So server startup after a restart is a thundering herd of full-log replays, and the first clone of each repository after a restart pays it. On the appliance, where this backend is the ref store, it is the boot path. The reflog-for-free property (line 22) is real and worth keeping; the problem is that the reflog and the current state are the same structure with no fold between them. -
Fix: Compaction independent of
repack(). The ref log can fold itself: write a new stream containing one batch of current state plus a bounded tail of history, fsync, swap atomically. Trigger it on a batch-count or byte-size threshold at open and periodically. Also stop reading the whole file into aVec;valid_prefix_lencan scan incrementally over a buffered reader, and a compacted log makes the full replay cheap enough that the allocation stops mattering. -
Verify: A test that writes N transactions, reopens, and asserts open cost is bounded independently of N. Count
readbytes, not wall clock. Seen RED first. Then a test that a compaction preserves the exact resolved ref map and that a crash during compaction leaves the old log intact — the property the atomic swap exists for, and the one a naive implementation gets wrong. -
Closed: 2026-08-03. Compaction rewrites the log as one batch and swaps atomically; rows outside a bounded reflog window are ARCHIVED, not dropped, since §4.5 makes the log the reflog. Measured: 180000 pushes replayed 276 MB / 180000 batches at open, now bounded at 1.57 MB / 1024 batches and flat, cost(10) == cost(1000) as an exact assert_eq. Compaction landed in
arrow_log.rs, independent ofrepack(), which is still S-006's problem.ArrowLogStore::compact()folds the log to one snapshot batch plus a bounded reflog window, written to a new file, fsynced, renamed — never mutated in place, same D14 discipline as §4.4. The trigger is a batch count on the write path (CompactionPolicy, default 1024 batches / 256 retained), so the live log never exceeds the trigger on disk and an open never replays more than it; firing only at open would still have paid the unbounded replay once. MEASURED 2026-08-03, one ref moved N times,OpenCost= bytes read + batches replayed at open:
| pushes | before (never compacts) | after (default policy) | after (folded, window 4) |
| 10 | 15 808 B · 10 batches | 15 808 B · 10 batches | 8 128 B · 5 batches |
| 1 000 | 1 536 448 B · 1 000 batches | 1 536 448 B · 1 000 batches | 8 128 B · 5 batches |
| 10 000 | 15 360 448 B · 10 000 batches | 1 223 104 B · 796 batches | 8 128 B · 5 batches |
One single-ref push is 1 536 bytes on the wire, so this entry's 180 000-push five-year repository was a 276 MB / 180 000-batch read on every open and is now bounded at ≤1.57 MB / ≤1024 batches, flat. What compaction discards: the reflog, which §4.5 says is this log, so the rows that fall out of the window are moved, not dropped: one complete self-contained Arrow IPC stream per compaction in refs-archive/, named by timestamp, never read at open, still a DuckDB glob. Archive is written and fsynced before the swap, so a crash duplicates a span of history rather than losing it. Secrets are the deliberate exception — secrets.arrow folds with no archive and no window, because a rotated host key must actually stop existing. A log written before this existed is folded at open as well, because the write-path trigger cannot help a repository that is only ever read from; that open pays the replay once and every later one is bounded. Eleven new tests, each seen RED against a deliberate break (2026-08-03): a no-op compaction turns 7 red; compacting in place instead of staging + renaming turns the crash test red and nothing else; dropping the archive turns 2 red; a fold built from a one-transaction-stale state turns the concurrent-writer test red on a lost update (w0 left at oid 0x17 where 0x18 was acknowledged); disabling the open-time fold turns exactly its own test red; dead write-cost counters turn the S-018 guard red. The open-cost measurement's own red-capability is permanent: the_open_cost_guard_catches_a_log_that_never_compacts runs the same helper against CompactionPolicy::never() — the behaviour this module used to have — and asserts it still grows. cargo test -p gunnar-store --features fs: 59 passed, 0 failed, conformance and its four break-wrappers included.
S-039 — The exit status is lost to a russh close race — a succeeded push reports failure
-
Found: 2026-08-03
-
Status: DONE
-
Severity: blocker
-
Where:
crates/gunnar-ssh/src/server.rs(exec_request/ the transport future) · mitigated atcrates/gunnar-cli/src/server/transport.rs:148-185(see S-034) -
What: MEASURED 2026-08-03, found by joining
gunnar-sshtogunnar-cli. russh'sChannelCloseOnDrop::dropspawns a task that sendsChannelMsg::Close.exec_requestsendsexit-statusafter the transport future resolves — that is, after the stream has been dropped — so theClosewins the race and the exit status never reaches the client. Two measured symptoms:- a raw
ssh … git-upload-packreturned a complete 244-byte advertisement and exit 255; git pushprinted* [new branch] HEAD -> mainand thenerror: failed to push some refs, for a push that had fully succeeded server-side.
- a raw
-
Why it matters at load: A push that works and reports failure is worse than a push that fails. Clients retry, CI marks builds red, and operators chase a server that is behaving correctly. Nothing in the logs contradicts the client (S-022), so the diagnosis cost per incident is high and the incident rate is 100 %. This is the most user-visible defect in the tree.
-
Fix: Stop using a stream that closes on drop.
Channel::make_reader()/make_writer()do not close the channel when dropped, so the exit status can be sent while the channel is still open. Then delete the 250 mslingerworkaround (S-034) in the same change; leaving it in place would hide a regression. -
Verify: The existing assertion in
crates/gunnar-cli/tests/ssh_end_to_end.rs(the_remote_command_reports_an_exit_status) must be seen RED withlingerremoved and before the real fix, so it is pinned to the race and not to the timer. Add agit pushcase asserting git's own exit code is 0 on a successful push, because the advertisement-only case would pass against a half-fix. -
Closed: 2026-08-03. Channel::split() replaces into_stream(); neither half carries a Drop impl, so exit-status and close go through the same mpsc sender and ordering is FIFO. The 250 ms linger workaround is deleted. Restoring the bug turns 7 of 9 end-to-end tests red.
S-040 — gunnar cannot clone from gunnar, though stock git can
-
Found: 2026-08-03
-
Status: DONE
-
Severity: blocker
-
Where:
crates/gunnar-ssh/src/server.rs(env_requestrefused) ·crates/gunnar-wire/src/caps.rs(multi_ack_detaileddeliberately omitted — S-020) -
What: MEASURED live 2026-08-03.
gunnar-sshrefusesenv_request, soGIT_PROTOCOLnever reaches the server and every SSH session is served protocol v0. Stock git copes with that. gix's blocking v0 client does not: it requiresmulti_ack_detailed, whichgunnar_wire::capscorrectly omits because it is not implemented. Result:gunnar push→ gunnar succeeds:unpack ok, 3 objects, 209 bytes;gunnar clone← gunnar fails with "Currently we require featuremulti_ack_detailed, which is not supported by the server".
Neither half is individually wrong: refusing an unimplemented capability is right (S-020), and the v2 negotiation both ends implement is fine. The defect is the combination, and it was invisible until the two crates were joined. Nothing tested gunnar against gunnar. Stated in README.md, so it is a commitment.
-
Why it matters at load: gunnar's own client is the reference client, and D15's gunnar→gunnar mirror replication (S-036) is built on exactly this path. Until it works, replication cannot be built and gunnar's client cannot be dogfooded against gunnar's server, which is how this class of defect gets caught in the first place.
-
Fix: Accept
env_requestforGIT_PROTOCOLand only for it — an SSH server must not accept arbitrary environment from a client — so v2 is negotiated and v0 stops being the only path. Implementingmulti_ack_detailed(S-020) is the other route and closes both entries; theenv_requestroute is smaller and should land first. -
Verify: A test that runs
gunnar cloneagainst a livegunnar serveand asserts the cloned bytes equal the pushed bytes, the gunnar↔gunnar leg that does not exist today. Seen RED first, which it currently is by construction. Do not assert only that the connection succeeded: the failure here is mid-protocol, after a successful handshake. -
Closed: 2026-08-03. env_request takes an allowlist of exactly GIT_PROTOCOL, attached to the service request AFTER authorisation returns. gunnar_clones_from_gunnar runs the real binary against a live server and asserts file contents on disk.
S-044 — Nothing has ever been load-tested or profiled; there is no benchmark harness
-
Found: 2026-08-03
-
Status: IN-PROGRESS
-
Severity: blocker
-
⚠ This entry was FALSELY CLOSED once, on 2026-08-03, and the closure stood for part of a day. It was marked
DONEwith aClosed:line reading "env_requesttakes an allowlist of exactly GIT_PROTOCOL …gunnar_clones_from_gunnarruns the real binary against a live server", which isS-043's closure, copy-pasted. It says nothing about a benchmark harness, and at that moment nobenches/, nobench/and nonornir bencherwiring existed anywhere in the tree. Recorded rather than quietly overwritten, because a blocker that closes itself with another entry's evidence is the same disease as a guard that reports green without running: the status field was believed, and nobody read the sentence under it. TheClosed:line below is the real one, and it names numbers that can be reproduced. -
Where: absent. No
benches/anywhere in the tree, nonornir bencherwiring, no load-test harness, no profile ever taken -
What: Stated in
README.md, so it is a commitment. Every performance property inTOTAL-PROMPT.mdis currently an assertion with no measurement behind it: the stree-over-fst argument, the batch-lookup argument, the tiering argument, the "refs are few, so replay is instant" claim, the zero-copy claim on the pack path. Some are demonstrably wrong: S-005 is a documented complexity the code does not have, and S-012 and S-015 contradict their own module docs. The rest are unfalsified rather than verified, which at this scale is the same thing. This file's own numbers are hand-measured today or explicitly marked inferred, for the same reason. -
Why it matters at load: A server can only be claimed to carry a load if it has been made to carry it and watched while it did. There is currently no number anyone can quote about gunnar, and no way to tell whether a change made it faster or slower, which makes S-003, S-011 and S-018 unjustifiable and unguardable. It is a blocker because it decides whether the rest of this file is even in the right order.
-
Fix:
nornir bencheris the house tool — not criterion. Wire it for the four numbers that decide everything else: pack write throughput (objects/s and bytes/s); clone wall clock and pack size against stock git on the same fixture; negotiation round trips; and pushes per second to one repository (S-018's fsync ceiling). Then a load harness: N concurrent clones against one server, reporting p50/p99 latency and the point at which throughput stops rising. That last number is S-008's real ceiling and nobody knows it. Profile one clone of a large repository before optimising anything; this file's ordering is reasoned, not measured, and a profile may reorder it. -
Verify: The first benchmark run is the verification, and its output belongs in this repository, dated. Then: introduce a deliberate 2× regression (drop the pack writer's compression level, say) and see the benchmark report it. A harness producing a number nobody compares against is the same as no harness.
-
Closed: partly. A harness exists:
bench/, a DETACHED crate with its own workspace, 16 arms measured full-size on oden 2026-08-03: 11 gunnar-against-gunnar, and 5 gunnar-against-stock-git2.53.0, which is the half this entry called for and which is now done (see the head-to-head block below andP-015). STILL NOT closed: the pushes-per-second / fsync ceiling (S-018) and the socket-level throughput knee (S-008) have no arm, andclone_cold_tieris absent rather than stubbed because no cold tier exists (a self-skipping green arm would be S-026 exactly). This entry was previously marked DONE carrying S-043's closure text at a moment when no harness existed anywhere. with its own[workspace](so the root workspace and the appliance feature resolution are untouched);bench/examples/nornir-bench.rscarries 11 gunnar-against-gunnar arms, allgunnar.*, emitting oneBenchRunJSON line vianornir_bench::api::run_main_json. Full-size first run, oden, 2026-08-03 (32 cores, sha_ni, sha256 repositories):
| arm | headline |
gunnar.pack_deflate |
130.7 MB/s at ratio_x 2.85 (level 6) |
gunnar.pack_trailer_hash |
1890 MB/s sha256 |
gunnar.clone_large |
1 GiB fixture, clone_ms 3246, 19.9 MB/s, objects_recompressed 2048/2048 |
gunnar.clone_concurrent_64 |
p50 988 ms, p99 1044 ms, 60.4 clones/s, peak_threads 67 |
gunnar.push_large |
push_ms 255, index 121.2 MB/s vs 49.6 MB/s serial (parallel_speedup_x 2.45) |
gunnar.walk_1m |
710 341 objects/s over 1 000 002 objects |
gunnar.lookup_1m |
2 189 547 hits/s, 82 798 misses/s |
gunnar.lookup_1m_64pack |
529 963 hits/s, 3 355 misses/s, miss_degradation_x 23.7 |
gunnar.repack_1m |
87 054 objects/s, seal_ms 0.002 |
gunnar.negotiate_divergent |
154 490 ms, 1250 rounds, 18 901 251 commits visited |
The head-to-head, and gunnar loses it — oden, 2026-08-03
This is the number this entry existed to get, and it is bad. Five vs_git_* arms, same repository, same stock git 2.53.0 client, same ext:: pipe on both sides, both sides inside one pinned ubuntu:26.04 + git=1:2.53.0-1ubuntu1 container. Fixture: 2048 commits over 1024 files in 32 directories, 16 KiB blobs, ~2% of eight files rewritten per commit — churn, not fresh random blobs, because delta reuse is exactly what P-001 costs and a fresh-blob corpus would have flattered gunnar. 528 MiB of blob content; 36 172 objects in a full clone; git repack -adf at default window and depth, i.e. what a server that has run git gc looks like.
| arm | metric | gunnar | git 2.53.0 | ratio |
vs_git_clone (sha1) |
bytes on the wire | 108.70 MB | 5.93 MB | 18.34× worse |
| wall clock | 4971 ms | 462 ms | 10.76× worse | |
| server CPU | 4.875 s | 0.022 s | 219.96× worse | |
| peak RSS (floors 6.07 / 4.91) | 17.33 MB | 12.06 MB | 1.44× worse | |
vs_git_clone_sha256 |
bytes | 115.06 MB | 6.49 MB | 17.72× worse |
| wall clock | 5107 ms | 528 ms | 9.68× worse | |
| server CPU | 4.994 s | 0.028 s | 176.41× worse | |
vs_git_fetch_divergent |
bytes (1008 haves each side) |
1.79 MB | 0.33 MB | 5.4× worse |
| wall clock | 177 ms | 124 ms | 1.43× worse | |
| server CPU | 0.143 s | 0.026 s | 5.56× worse | |
| objects sent | 2912 | 2979 | gunnar cut better | |
vs_git_clone_concurrent ×16 |
bytes | 193.17 MB | 26.77 MB | 7.22× worse |
| wall clock (batch) | 1127 ms | 444 ms | 2.54× worse | |
| server CPU | 7.03 s | 0.265 s | 26.48× worse | |
| p50 / p99 | 998 / 1048 ms | 388 / 406 ms | ~2.6× worse | |
| total RSS for the batch | 19.23 MB (1 process) | 135.95 MB (16 processes) | 7.1× better | |
vs_git_push |
wall clock | 384 ms | 493 ms | 0.78×, gunnar wins |
| server CPU | 0.332 s | 0.451 s | 0.74×, gunnar wins | |
| peak RSS | 20.62 MB | 54.73 MB | 0.38×, gunnar wins | |
| bytes in | 1.28 MB | 1.28 MB | 1.00, the client built both |
All five arms are from one run, so the columns are comparable with each other rather than stitched together. Taken at gunnar e60c95d, and the ref matters: an earlier run at 87524d6 put the fetch arm's CPU ratio at 6.65× and its wall ratio at 2.15×, and e60c95d's negotiation fix is what moved them to 5.56× and 1.43×. The clone rows did not move, because negotiation is not what a clone spends its time on.
Read it plainly: on everything a client waits for, gunnar is between one and two orders of magnitude behind stock git, and the cause is known. P-001 has not landed and S-003 (no delta encoding) is open, so every object is inflated and re-deflated whole. 528 MiB of content becomes 108.70 MB out of gunnar (a 4.9× plain deflate) and 5.93 MB out of git (89×, because git ships the deltas it already has). The 18.34× is the delta-reuse gap, made concrete, and it is the strongest argument in the tree for doing P-001 next. The hash algorithm is not the cause: sha256 moves the byte ratio from 18.34× to 17.72× and the CPU ratio from 219.96× to 176.41×.
gunnar wins the receive path outright — 22% less wall clock, 26% less CPU and 38% of the memory of git receive-pack — and wins total memory under concurrency by 7.1×, because it is one process where git is sixteen. Both of those are real, and neither cancels the clone number.
A finding that is not a benchmark result: FsStore::open_or_init takes a cross-process exclusive lock on the repository (gunnar-store/gunnar-store.lock). Run process-per-connection like git upload-pack, fifteen of sixteen gunnar servers die with "the store is locked by another writer". gunnar can only ever be one process per repository, which is what gunnar serve is, and why the concurrent arm runs each side in its own native shape and says so.
Caveats, none of them removable, all of them stated on the row: the gunnar side is gunnar-wire over a pipe, so gunnar's SSH layer is excluded and the numbers are optimistic for gunnar on transport; the gunnar server process is the bench binary, so an RSS floor is measured and reported for both sides; the meter that counts bytes and reaps rusage adds one process and one memcpy to both columns; oden was at 32/32 cores busy from other agents throughout, though gunnar_drift_x on the clone arm was 0.996, so the host did not move under that comparison; the fixture is synthetic.
Two of this entry's four asks are still not done and are not claimed: there is no pushes-per-second / fsync arm (S-018), and no socket-level load test that finds S-008's knee. vs_git_clone_concurrent uses 16 real client processes and real server processes, but over ext:: pipes rather than gunnar's SSH accept loop.
The Verify clause is satisfied in kind rather than to the letter: instead of one deliberate regression, every arm carries an applied-output assertion and four were watched failing: the zero-gunnar.*-arm run (exit 1), clone_large's objects_recompressed, negotiate_divergent's commits_visited = 0, and lookup_1m_64pack's degradation. The last of those found a hollow guard in this suite's own first version: comparing two arms measured minutes apart on a busy host reported a 30× "degradation" between a fixture and itself, so the comparison was moved inside one arm as an A/B/A bracket. See P-015 for the full record.
scale
S-003 — No delta encoding; every served pack is whole objects
- Found: 2026-08-03
- Status: TODO
- Severity: scale
- Where:
crates/gunnar-pack/src/write.rs:245-257(entry_header) ·write.rs:106-128(add_object) · declared atcrates/gunnar-pack/src/lib.rs:20-23 - What:
entry_headeris the only place a pack entry type byte is chosen and it mapsgix_object::Kindonto exactly four headers:Blob,Tree,Commit,Tag. There is no arm that can produceOBJ_OFS_DELTA(6) orOBJ_REF_DELTA(7), andadd_objecttakes already-inflated full content. The crate documents this as deliberate ("measure first"), and §16.3 says the same. Nothing was ever measured. The receive side is delta-aware:read.rs:133-151hands the stream togix_pack::Bundle::write_to_directory, which resolves ref- and ofs-deltas including thin-pack bases. So gunnar can receive an efficiently packed push and can never serve one back. Worse: an incoming pack whose objects are stored delta-compressed is re-inflated to full content and re-deflated whole on every fetch (write.rs:202-206). - Why it matters at load: This is the single largest determinant of clone bandwidth, and bandwidth is the thing a forge at this scale actually pays for. Git's delta chains are why a repository with heavy revision churn clones in a fraction of its checked-out size. The multiplier for gunnar is unmeasured — do not repeat a figure from general knowledge as if it came from this repository — but it is a multiplier on both egress bytes and on server CPU, since deflating full blob content costs far more than deflating a small delta instruction stream.
- Stated in the README, so this is a commitment, not an observation. Severity is
scalerather thanblockeron a deliberate judgement: gunnar works without it and interoperates with stock git — it just costs far more bandwidth than it should. It is nonetheless the largest single scale item in this file. - Fix: Implement
ofs-deltaemission in the writer, sinceofs-deltais already advertised (caps.rs:114) and accepted-and-ignored (upload_pack.rs:175-177). Open research question, 2026-08-03: whethergix-pack'sdata::outputpipeline —count::objects→entry::iter_from_counts→bytes::FromEntriesIter— can produce the delta encoding, or whether the window search has to be hand-written. Settle that first; reusing it is LAW 5 and would delete most of this item. The window/depth search is the parallel workload S-011 wants: fan it out with gatling (znippy-zoomies), never rayon (LAW 3). Before writing any of it, measure: clone a real repository with gunnar and with stock git and record both pack sizes and both wall clocks. That measurement is the deliverable even if the implementation slips. - Verify: A test that clones a fixture with real history through gunnar and asserts the served pack is materially smaller than the sum of its whole objects. Seen RED against today's writer, where the two are equal by construction. Beware the identity value: a fixture of unique random blobs deltas to nothing and would make a working encoder look broken. Use a fixture with near-duplicate revisions.
- Closed:
S-013 — reachable_from_refs() walks the whole repository per non-tip want, uncached
- Found: 2026-08-03
- Status: TODO
- Severity: scale
- Where:
crates/gunnar-wire/src/upload_pack.rs:922-942, used atupload_pack.rs:886-911 - What:
WantPolicy::ReachableFromRefsis the default (upload_pack.rs:83-84), and it must be, becauseAdvertisedTipsbreaks partial clone's lazy fetch; the reasoning atupload_pack.rs:63-69is right. But proving reachability callsselect_objectsover all refs with no excludes, i.e. enumerates every object in the repository, collects them into aVec, then re-collects into aHashSet.check_wantscorrectly builds it at most once per request (upload_pack.rs:894-905) — and then throws it away. There is no cross-request cache. - Why it matters at load: Every
git checkoutin ablob:nonepartial clone issues a lazy fetch naming un-advertised blob ids. Each one triggers a full repository enumeration: two allocations the size of the repository (theVecthen theHashSet, both live at once during thecollect) and a full object-graph walk. A single developer checking out branches in a large partial clone can hold a server core and a gigabyte for the duration, repeatedly. The doc comment is honest that this is the cost; the finding is that it is paid per request with nothing amortising it. - Fix:
__gunnar_reach__(§4.2) is the real answer and makes this a bitmap test. Before that lands, two cheap mitigations: cache the closure keyed on the tuple of ref targets, so it is rebuilt only when a ref moves; and avoid the double allocation by havingselect_objectsbe able to emit into a caller-supplied set. - Verify: A test that issues two lazy fetches back to back against an unchanged repository and asserts the second does not re-walk. Count
ObjectStore::readcalls, and see it RED today where both walks are identical. Then a test that a ref move does invalidate, so the cache cannot pass by never refreshing. - Closed:
S-014 — Unbounded client-driven accumulation: haves, wants, commands, push-options
-
Found: 2026-08-03
-
Status: TODO
-
Severity: scale
-
Where:
crates/gunnar-wire/src/upload_pack.rs:673,upload_pack.rs:676-706(the v0 negotiation loop) ·crates/gunnar-wire/src/wire_io.rs:101-117(read_section) ·crates/gunnar-wire/src/receive_pack.rs:251-258(push-options) ·receive_pack.rs:305-350(the command list) -
What: Four places accept an unbounded number of client-supplied lines and keep them all:
haves: Vec<ObjectId>grows on everyhaveline with no cap, and the loopwhile !donehas no round limit and no deadline, so a client that never sendsdonekeeps the connection and the vector alive;read_sectionloops until a flush with no line cap,to_vec()-ing each payload;- push-options become owned
Strings, one per line, uncapped; - the ref command list is uncapped.
A pkt-line payload is up to 65516 bytes, so each accepted line costs up to 64 KB. read_packet also allocates a fresh vec![0u8; len] per line (wire_io.rs:44) — a per-packet heap allocation on the framing hot path. For balance: gunnar-pktline's decoder itself is genuinely well defended: every length parse is bounded, every slice is guarded, MAX_PAYLOAD is checked before the payload is waited on, and no panic on attacker-controlled length or content was found in decode.rs. The hole is above it, in what the callers are willing to accumulate.
-
Why it matters at load: One authenticated client can hold a connection open indefinitely while consuming server memory at 64 KB per pkt-line, on a server with no connection cap (S-008). The
inactivity_timeoutof 600 s does not help — the client is active. This is a cheap denial of service against a forge that must accept pushes from anyone with a grant. -
Fix: Explicit configured caps on each: maximum
havelines, maximum negotiation rounds, maximum ref commands per push, maximum push-options, and a wall-clock deadline on the whole negotiation. Reject with a clearERRline, log and count the rejection. Reuse one buffer inread_packetinstead of allocating per line. -
Verify: A test that sends
cap + 1havelines and asserts a clean, specific refusal rather than acceptance. Seen RED today, where it is accepted. And a test that a negotiation which never sendsdoneis closed on the deadline. -
Closed:
S-015 — Two heap allocations per packed object, and each object buffered compressed
-
Found: 2026-08-03
-
Status: DONE
-
Severity: scale
-
Where:
crates/gunnar-pack/src/write.rs:115andwrite.rs:121-124·crates/gunnar-wire/src/graph.rs:106,graph.rs:126,graph.rs:302·crates/gunnar-pktline/src/decode.rs:58 -
What:
PackWriter::add_objectruns once per object and allocates twice:
let mut header = Vec::with_capacity(16); // write.rs:115
let mut deflate = ZlibEncoder::new(Vec::new(), …); // write.rs:121
The second is the more serious: the encoder's sink is a fresh Vec, so each object's entire compressed form is materialised in memory before a byte reaches the socket. The pack as a whole streams — write_pack correctly reuses one 8 KB read buffer at write.rs:200 and PackWriter is generic over W: Write — but a single multi-gigabyte blob is buffered whole, which contradicts the "streamed, nothing buffered" claim at write.rs:60-61 at exactly the granularity that matters. In the walker, Graph::commit (graph.rs:126) and Graph::peel (graph.rs:106) each allocate a fresh Vec per call, and both are called once per commit in three separate passes of select_objects; CommitFacts.parents is a Vec per commit (graph.rs:302). Packet::Data(buf[4..len].to_vec()) copies every decoded pkt-line.
-
Why it matters at load: These are the "zero-copy on hot paths" goal's direct counterexamples. Per-object allocation churn at millions of objects is measurable allocator pressure and cache thrash; the compressed-object buffer is an unbounded per-connection memory spike proportional to the largest blob in the repository, which a client chooses.
-
Fix: Write the entry header into a fixed
[u8; 16]on the stack. GivePackWritera reusable compression buffer, or better, wrapself.outin theZlibEncoderso deflate output streams straight into the hasher and the socket with no intermediate. Hoist the walker'sbufto a field onGraphreused across calls, and reuse one buffer inread_packet. Consider aSmallVecforparents, which is 1 or 2 in almost every commit. -
Verify: An instrumented allocation counter around packing a fixture of N objects, asserting allocations are O(1) in N rather than O(N). Seen RED first. Plus a test that packs one very large blob and asserts peak memory stays bounded. RED today, where it is the blob's compressed size.
-
Closed (the pack writer): 2026-08-03, uncommitted in the working tree (
crates/gunnar-pack/src/write.rs; testcrates/gunnar-pack/tests/streaming.rs). MEASURED, seen RED first. The assertion is on the shape of the byte stream leaving the writer, not on a resident-set reading: a writer that buffers an object whole hands its sink one enormouswrite, a writer that streams hands it a bounded run of small ones. Against the old code, packing one 8 MiB blob produced a single 8 389 939-byte write. After, no single write exceeds the 64 KiB scratch buffer. The fixture blob is deliberately incompressible and the test asserts it is. Eight megabytes of zeroes deflate to a few kilobytes and would slip under any bound while the writer buffered every one of them, which is the identity-value trap in the one place it was easy to fall into here. Both allocations are gone. The entry header is a[u8; 16]on the stack. TheZlibEncoder::new(Vec::new(), …)is replaced by aflate2::Compressheld on the writer andreset()between objects, driven against one 64 KiB scratch buffer allocated once per writer, so each chunk is hashed and written before the next is produced, and the compressor state is no longer reallocated per object either. The trailer hash is unaffected: the same bytes reachhasher.updatein the same order, anda_streamed_pack_is_the_same_pack_gix_indexesasserts gix's own checksum over the finished pack equals the trailer the writer wrote. Green after: 20/20roundtrip.rsandreal_gix_odb.rschecks, every claim about the produced bytes made by handing them togix_pack, never to gunnar's own reader. Not fixed, and still open under this id: the walker's per-callVecs (graph.rs:106,126,302) andPacket::Data(...).to_vec()ingunnar-pktline/src/decode.rs:58. Those are allocator churn rather than an unbounded spike, and the unbounded spike is what made this entryscale. -
Closed:
S-016 — Every connection materialises and clones the whole ref list
- Found: 2026-08-03
- Status: TODO
- Severity: scale
- Where:
crates/gunnar-store/src/refs.rs(RefStore::itersignature) ·crates/gunnar-store/src/arrow_log.rs:840-847·crates/gunnar-wire/src/receive_pack.rs:205-230·crates/gunnar-wire/src/upload_pack.rs:828-872 - What:
RefStore::iterreturnsResult<Vec<(FullName, Target)>>, a fully materialised, fully cloned list, by trait signature. The arrow-log implementation clones every name and every target into a newVec(arrow_log.rs:845). Bothwrite_ref_advertisement(receive-pack) andcollect_refs(upload-pack) call it once per connection, and receive-pack additionally callsself.refs.resolve(inner)per symbolic ref inside the loop.add_reachable_tagscallsiter(Some("refs/tags/"))again on the same request. - Why it matters at load: Repositories with very large ref namespaces are normal at forge scale: release tags, per-pull-request refs, mirrors of other forges' change refs. A repository with a million refs allocates and clones a million
FullNames (each a heap string) and a millionTargets on every single connection, before any negotiation. Theref-prefixfiltering that protocol v2 provides is applied after the whole list has been built, so it saves wire bytes and no server work. - Fix: Change the trait to hand back an iterator or to take a visitor (
fn for_each(&self, prefix, f: &mut dyn FnMut(&FullName, &Target))), so a prefix request touches only its prefix and nothing is cloned. On the arrow-log backend the state is already aBTreeMap, so a prefix range is a natural sub-range. This is a trait change and therefore touches all backends. Do it once, before more callers appear. - Verify: A test with N refs asserting an
ls-refswith a narrowref-prefixclones O(matching) and not O(N). Count clones with an instrumentedTarget. Seen RED today, where the two are equal. - Closed:
S-017 — O(n²) Vec::contains on two client-sized lists
-
Found: 2026-08-03
-
Status: DONE
-
Severity: scale
-
Where:
crates/gunnar-wire/src/upload_pack.rs:944-953(commit_wants) ·upload_pack.rs:1029(add_reachable_tags) -
What:
if peeled.kind == gix_object::Kind::Commit && !out.contains(&peeled.id) { // :948
out is a Vec<ObjectId> and contains is a linear scan, run once per want. The wants list is whatever the client sent and is itself uncapped (S-014). Same pattern at line 1029 over the tag list.
-
Why it matters at load: Quadratic in a client-controlled input. Ten thousand wants, which a mirror clone or a mass fetch legitimately produces, is fifty million
ObjectIdcomparisons per request, on top of apeel(an object read) per want. Cheap to fix and cheap to exploit. -
Fix: A
HashSet<ObjectId>alongside theVecfor the membership test, or anIndexSet. Two-line change in each place. -
Verify: A test asserting
commit_wantscost is linear in the number of wants. Count comparisons or assert on a large-N wall clock with a generous bound. Seen RED first at N = 100000, which today will not finish quickly. -
Landed 2026-08-03, and this entry's list of two sites was incomplete. The
Vec::containsscans are gone:commit_wantsandadd_reachable_tagsboth use aHashSetfor membership, and so does thetipsloop thatS-031's deepening fix had just added with the same shape. Writing the guard found the larger half.crates/gunnar-wire/tests/want_cost.rscounts store touches rather than timing anything, followingconnectivity_cost.rs:cost(1000 repeats) == cost(1), exactly. Against the original code it answered
1000 copies of one want cost 3035 store touches and a single want costs 38
Three touches per repeated line, because a repeated want was peeled again and peel is an object read. Fixing commit_wants alone moved it to 2036, not 38: the other two were wants_exist's existence probe and the selection walk peeling every tip. So the fix is one writer, not four patched consumers (LAW 5) — dedupe_oids runs once in each protocol version's want assembly, before anybody reads the list, and order is preserved because pack order is observable. Now 38 == 38. What is fixed but NOT guarded, stated rather than papered over: the Vec::contains → HashSet half, quadratic in distinct wants, has no deterministic instrument. Both spellings return identical output and touch the store identically, so only wall clock separates them, and this crate does not assert on wall clock (S-046 already carries two load-flaky tests). The gap is named in commit_wants' doc comment instead of being covered by a timing test that would go red on a busy box.
- Closed: 2026-08-03
S-018 — A ref transaction fsyncs under a std Mutex on a blocking thread
- Found: 2026-08-03
- Status: IN-PROGRESS
- 2026-08-03: the cheaper immediate step this entry asks for is done; the fix is not.
ArrowLogStore::refs_write_stats()reportstransactions,lock_wait_nanos(timed aroundrefs_log.lock()),fsync_nanos(timed aroundsync_all) andfsyncs, and the open path logsbytes_read/batches_replayedat debug. So the ceiling is now countable per repository instead of asserted.the_write_path_reports_what_it_spentis the guard, seen RED by killing both counters. Group commit is NOT implemented. Every transaction is still one fsync under the per-store mutex, and the pushes-per-second benchmark that would justify the redesign is still S-044's missing harness. The line ratio that matters: this closes the measurement, none of the throughput. - Severity: scale
- Where:
crates/gunnar-store/src/arrow_log.rs:849-916(transaction) ·arrow_log.rs:456-471(commit) ·arrow_log.rs:529(refs_log: Mutex<Log>) - What:
transactiontakesself.inner.refs_log.lock(), astd::sync::Mutex, and holds it acrossLog::append, which writes and thensync_all()s the file before returning. The acknowledgement barrier is right and the durability tests around it are good (thethe_durability_guard_catches_a_missing_fsynccompanion atarrow_log.rs:1120is exactly the LAW 2 shape). The concern is what it costs. Readers correctly do not take this lock, they read theRwLock<BTreeMap>, so the design's own statement atarrow_log.rs:48-55is accurate. Corrected from the working assumption: this mutex is per store, and a store is one repository, not server-wide. - Why it matters at load: Every push to one repository serialises behind one fsync, so that repository's push throughput is capped at 1/fsync-latency, order of hundreds per second on NVMe, far fewer on anything shared or on a spinning device. For a busy monorepo with heavy CI push traffic that is a real ceiling. Because the call is synchronous inside
spawn_blocking(transport.rs:122), each waiter also occupies one of the 512 blocking threads (S-008) while it waits, so a slow disk on one hot repository consumes the server's global transfer capacity. - Fix: Group commit: batch concurrent transactions arriving within a short window into one IPC message and one fsync, acknowledging all of them after it. That preserves the "one transaction, one framed message" atomicity property for each batch member only if the framing is reworked carefully, so this needs design, not a patch. The cheaper immediate step is to make the wait visible: log and count fsync latency and lock wait time so the ceiling is measurable before it is hit.
- Verify: A benchmark of pushes per second to one repository, then the same after group commit, with a crash-injection test asserting that a batch acknowledged together is either wholly present or wholly absent after a torn write. That second test must be seen RED against a naive implementation that acknowledges before the shared fsync.
- Closed:
S-019 — The O_EXCL store lock does not self-clear; a killed appliance will not restart
-
Found: 2026-08-03
-
Status: DONE
-
Severity: scale
-
Where:
crates/gunnar-store/src/arrow_log.rs:476-511(LockFile) ·arrow_log.rs:637-649(force_unlock) · surfaced atcrates/gunnar-cli/src/server/stores.rs:76-82 -
What: Confirmed, and the code says so itself at
arrow_log.rs:478-481:flockwould release on process death, anO_EXCLfile does not, and the trade is "a stale lock after a hard kill, cleared byforce_unlock". The lock file records the owning pid (arrow_log.rs:493) but nothing reads it back to decide staleness. The choice was made to avoidunsafeandlibc, which the crate forbids (unsafe_code = "forbid"in its manifest), a defensible reason. -
Why it matters at load: §0's whole premise is a single Rust binary running as PID 1 in a distroless image on tunnr hardware. There is no operator on that box, no shell, and no way to run
force_unlock. A power cut leaves every repository the server had open holding a stale lock, and the appliance boots into a permanent refusal to serve. The failure is silent-adjacent:stores.rs:76-82produces a good error message, but nobody is there to read it. At forge scale the same applies to any orchestrated restart after an OOM kill (which S-001/S-004 make plausible). -
Fix: Recovery on startup, not an operator verb. Read the pid from the lock file and check liveness; if the pid is gone, log loudly at
warnand reclaim.libc-free liveness on Linux is a/proc/<pid>existence check plus a start-time comparison against/proc/<pid>/statfield 22 to defeat pid reuse: nounsafe, no new dependency. Keepforce_unlockfor the case where the check cannot be made. On the no-filesystem appliance path the question does not arise, since the memory backing has no lock file. -
Verify: A test that starts a child holding the lock,
SIGKILLs it, and asserts the parent reopens successfully, plus a companion asserting the parent is still refused while the child is alive, so the recovery cannot pass by reclaiming unconditionally. That companion is the whole test; without it the fix is worse than the bug. -
Closed: 2026-08-03,
crates/gunnar-store/src/arrow_log.rs(LockFile::acquire, newLockOwner/proc_start_time/owner_is_gone),crates/gunnar-store/src/error.rs, and the applied-output proof incrates/gunnar-store/tests/repack.rs.
Recovery is at startup, exactly as the Fix clause asked, and not a new operator verb. The lock file now records <pid> <start-time>, the start time being field 22 of /proc/<pid>/stat. The pid alone is not enough, and that is the whole difficulty: a recycled pid reads as "still running" to a /proc/<pid> existence check, so the naive fix never recovers and nothing on a quiet box would say so. No unsafe, no libc, no new dependency; unsafe_code = "forbid" still holds.
The rule is: reclaim only what is PROVABLY gone. Alive refuses. A recycled pid is reclaimed. A zombie is reclaimed, because it holds no descriptors, can write nothing, and its pid cannot be recycled while it sits there, which matters because that is the appliance's real shape for as long as its reaper is slow. Everything undecidable refuses: no /proc on this platform, an owner line that will not parse, or a lock written by a gunnar older than this change that recorded no start time. force_unlock stays for exactly that remainder, and its doc now says so. The reclaim is one attempt, not a loop: if a second starter reclaimed the same stale lock a microsecond earlier it now holds a live one, and this one must refuse. Retrying would be a loop that ends in two writers.
SEEN RED, and the shape of the red is the point. With acquire put back to the pre-fix behaviour (refuse on AlreadyExists, never reclaim) cargo test -p gunnar-store --lib reports 44 passed; 2 failed:
a_lock_left_by_a_dead_process_is_reclaimed_and_one_held_by_a_live_one_is_notpanicked: a lock left by a process that is GONE was not reclaimed:StoreLocked { path: "/tmp/.tmpuGUznB/gunnar-store.lock" }
a_recycled_pid_does_not_keep_a_dead_owners_lock_alivepanicked: a lock whose recorded start time is not the live pid's is stale:StoreLocked { path: "/tmp/.tmpmRWA9C/gunnar-store.lock" }
The two refusal tests stayed green through that probe, which is the half the Verify clause calls "the whole test": a fix that reclaimed unconditionally would pass the two failures above and be worse than the bug. Five tests in all, and each names the thing it would otherwise pass vacuously on. The live-owner refusal and the reclaim are in one test against one real sleep process, so no fixture drift can separate them; the reclaimed store is asserted to answer a query rather than merely to exist; the zombie test asserts the owner really reached state Z before concluding anything; and the_lock_file_records_this_process_as_its_owner exists because an acquire that wrote no owner line would make every later recovery undecidable, which reads exactly like the bug.
The applied output, in the scenario the entry describes. repack.rs::a_repack_killed_mid_flight_leaves_the_old_state_serving spawns a real child, SIGKILLs it mid-repack, and reopens. That reopen used to require a force_unlock call, and that call is now deleted: the test passes without it, against a real stale lock file left by a real killed process. cargo test -p gunnar-store --features fs: 75 passed, 0 failed.
Docs corrected in the same change rather than left to drift. .nornir/appliance-design.md, .nornir/storage-reasoning.md and .nornir/TOTAL-PROMPT.md item 7 all stated force_unlock as the recovery path and named "a PID-plus-liveness check or a higher MSRV" as an open decision. That decision is now made and recorded: the PID check, not the MSRV bump. Error::StoreLocked's message changed too, because reaching it now means the owner is alive or could not be shown to be gone, which is not what it used to mean.
Not claimed: platforms with no /proc are refused rather than handled, and there is no test for them, because this box has /proc. On the appliance the question does not arise for the memory backing, which has no lock file at all.
2026-08-03 — the recovery is now proved for gunnar serve, not only for the store. The proof above is at the store level: an in-process ArrowLogStore reopened after a child was SIGKILLed. The scenario the entry is about is an appliance that comes back after a power cut, and that had no end-to-end proof, because the only two arms that run the server as a process put it in a container or a KVM guest and every container run starts from a fresh volume — there was never a stale lock there to reclaim.
installtest/src/bare.rs closes that. It runs gunnar serve as an ordinary process, SIGKILLs it, asserts the lock file survived naming that exact pid and start time, starts a second server on the same data root, and requires it to log the reclaim and then answer a real ls-remote with the pushed oid. Measured on oden 2026-08-03 against the binary at 7b6a9ee: reclaim to serving in 100 ms.
Two red switches keep it from being vacuous, and both are run by cargo xtask bare red --arm bare:
lock-holder-aliveleaves the first server running and starts the second on a port of its own. The port matters: reusing it would let a bind failure produce the red, and the proof would then pass just as happily against a gunnar that reclaims live locks, which is the behaviour this entry says is far worse than refusing to boot. On a free port the store lock is the only thing that can refuse it, and gunnar refuses with "its owner is still running, or could not be shown to be gone".lock-owned-by-harnessrewrites the stale lock with the harness's own live pid and start time, so the reclaim must be refused for a pid that was never gunnar's.
The first version of lock-holder-alive was itself blind and is recorded here because the shape recurs: it reused the port, the second server died of a bind failure, and the harness reported READY anyway — wait_for_ssh returns on the first SSH banner and the banner came from the server that was already there. That is the same hazard wait_for_ssh's own header describes for podman's port forwarder, arriving by a different road.
S-020 — multi_ack is not advertised; negotiation costs extra round trips
- Found: 2026-08-03
- Status: TODO
- Severity: scale
- Where:
crates/gunnar-wire/src/upload_pack.rs:669-673and the v0 loop throughupload_pack.rs:706·crates/gunnar-wire/src/caps.rs - What: Neither
multi_acknormulti_ack_detailedis advertised, so v0 clients run single-ACK negotiation: the server emits oneACK <oid>and the client then sendsdone. The comment atupload_pack.rs:669-672is explicit and correct about this, and not advertising a capability that is not implemented is the right call. The alternative is lying on the wire, which is what breaks interoperability silently. This entry records the resulting efficiency gap, not a defect. But see S-040: combined withgunnar-sshrefusingenv_request, this is no longer only an efficiency gap. It is why gunnar cannot clone from gunnar at all. Fixing S-040 by theGIT_PROTOCOLroute leaves this entry standing; fixing it by implementingmulti_ack_detailedcloses both. - Why it matters at load: Single-ACK converges on a common ancestor in more round trips than multi-ack against a client with divergent history, and each extra round trip is a full network latency plus a
first_commonscan (upload_pack.rs:795) over the accumulated haves. Against clients on high-latency links, and at forge scale where a large share of fetches are incremental, this is real wall-clock cost that git servers are expected not to have. - Fix: Implement
multi_ack_detailedand advertise it.Reachability(graph.rs:491-579) already memoises the "can this reach a common" question across wants, which is the expensive half; what is missing is the ACK state machine. - Verify: A test against stock
git(2.53.0 is what is on this box) counting negotiation round trips for a fetch with divergent history, before and after. Seen with the higher count first, so the improvement is measured rather than assumed. - Closed:
S-021 — Refs.ReadObject reads the whole object before applying max_bytes
-
Found: 2026-08-03
-
Status: DONE
-
Severity: scale
-
Where:
crates/gunnar-cli/src/server/control_plane.rs:545-572 -
What:
let mut buf = Vec::new();
let Some(data) = objects.read(&id, &mut buf).map_err(store_error)? else { … };
let size = data.data.len() as u64;
let (bytes, truncated) = match max_bytes {
0 => (data.data.to_vec(), false),
limit if size > limit => (data.data[..limit as usize].to_vec(), true),
_ => (data.data.to_vec(), false),
};
The whole object is inflated into buf before max_bytes is consulted, and then copied again with to_vec(). A caller asking for one byte of a one-gigabyte blob costs the server the full gigabyte twice over.
-
Why it matters at load: This RPC is unauthenticated by D9, so on any deployment where the control port is reachable, including from any other local process, it is a one-request memory amplifier. It also runs synchronously on a tokio worker (S-009), so the inflation blocks the SSH listener while it happens.
-
Fix: Push the limit down into the read: the
ObjectStoretrait needs a bounded read, or the caller needs the streaming variant. Also drop the second copy: the response can take the buffer by value rather thanto_vec()-ing a slice of it. -
Verify: A test requesting
max_bytes = 1from a large object and asserting peak allocation is bounded by the limit and not by the object. Seen RED first. -
Investigated 2026-08-03, no code changed. The Fix clause above is not reachable as written, and the reason is worth recording before the next agent re-derives it:
ObjectStorehas no bounded read and no backend can grow one honestly.FsObjectsis agix_odb::Storehandle, whose entire read surface isgix_object::Find::try_find(id, &mut Vec), the whole object, into the caller's buffer.try_headeranswers kind and size without inflating, and that is the only bounded thing available. Checked againstgix-odb 0.83.0(src/store_impls/dynamic/find.rs,src/store_impls/loose/find.rs): there is no streaming or partial variant to delegate to.- For a delta-encoded object a bounded read is impossible, not just unimplemented. A prefix of an
ofs/ref-delta object cannot be produced without materialising its base, and its base's base, so "give me the first megabyte of this four-gigabyte blob" costs the full chain whatever the API looks like. Any bounded-read trait method would therefore be honest only for loose and non-delta-packed objects and would silently fall back to a full inflate for the rest. That fallback is exactly the shape of a guard that cannot go red, and it is why this was not implemented as a defaulted trait method. - So the achievable fix is not "apply the limit before inflating". It is a header-first probe plus a configured ceiling and an explicit refusal: read
header()(free), and if the object is larger than the ceiling answerResourceExhaustednaming the size, rather than inflating. That trades an OOM for a refusal on a request that works today, which is a contract change and a deployment number, so it wants deciding rather than slipping in. The second, unconditional half, theto_vec()atcontrol_plane.rs:564-566that copies the whole object a second time, can go with no contract change: the response can takebufby value aftertruncate. - Not done here because the call site is
crates/gunnar-cli/src/server/, which a concurrent agent held during this session. Both halves are that file only. Restated Verify: a test asserting the RPC refuses an object above the ceiling without inflating it (assert on the error and on an instrumentedreadnever being called, not on wall clock), plus one asserting the allowed path copies the object once rather than twice. Both seen RED first.
-
Re-examined 2026-08-03 (second pass). The reasoning above stands and no code was changed;
crates/gunnar-cli/src/server/was still held by another agent.read_objectis now atcontrol_plane.rs:549-575and is byte-for-byte the code the entry quotes. Deliberately not papered over: no defaulted bounded-read trait method was added, because for a delta-encoded object it would fall back to a full inflate and that fallback is a guard that cannot go red, which is the whole reason this entry is IN-PROGRESS rather than DONE. Ready to apply, and the two halves are genuinely different in kind:- The unconditional half: no contract change, do this whenever
gunnar-cliis free. Lines 563-568 copy the object a second time. Replace
- The unconditional half: no contract change, do this whenever
let (bytes, truncated) = match max_bytes {
0 => (data.data.to_vec(), false),
limit if size > limit => (data.data[..limit as usize].to_vec(), true),
_ => (data.data.to_vec(), false),
};
with a truncated computed from size and max_bytes, then buf.truncate(limit) and data: buf, so the buffer moves into the response instead of being cloned out of it. Note data.data borrows buf, so size and kind must be read off data and the borrow dropped before buf is moved. RED: assert Vec::as_ptr of the response's data equals the pointer buf was allocated at, or count allocations around the call. Asserting the bytes are right passes today and proves nothing about the copy.
-
The contract half needs deciding, not slipping in. A configured ceiling and an explicit
ResourceExhaustednaming the object's size, taken fromheader()(free, no inflate) beforeread(). It turns a request that works today into a refusal, and the number is deployment-specific. RED: assert on an instrumented store thatreadwas never called for an over-ceiling object, not on wall clock, and not on the error alone, since an implementation that inflates and then refuses returns the same error. -
The unconditional half LANDED 2026-08-03. The contract half is still not done, and still should not be slipped in.
control_plane.rs'sread_objectno longer copies the object a second time:kindandsizeare read offdatainside a block, the borrow ends, andbufis truncated and moved into the response. Sound by the signature rather than by convention —ObjectStore::readties itsData<'a>to the&'a mut Vec<u8>it was handed, so the bytes are inbufor the code does not compile. SEEN RED, with theto_vec()restored, through a counting#[global_allocator]incrates/gunnar-cli/tests/read_object_copies.rs— because this entry is right that asserting on the bytes passes either way and proves nothing:
reading an 8388608-byte object allocated 16825617 bytes, over the 12582912-byteceiling that sits between one copy and two
16825617 is 2x the object plus ~48 KB of incidental allocation, which is the second copy exactly. The test also asserts a floor (allocated >= BLOB_BYTES), so a stubbed read that allocated nothing cannot pass, and asserts the returned bytes equal the object, so a "no copies" fix returning an empty buffer cannot either. The first inflation is untouched, so a caller asking for one byte of a huge object still costs the server the whole object. That needs the configured ceiling and explicit ResourceExhausted described above, which turns a working request into a refusal and wants a deployment number decided rather than picked by an agent. The test's own module docs say so, so the next reader does not mistake this for the whole fix.
- The contract half, DECIDED with a measurement 2026-08-03 — and it turned out to split into two numbers, only one of which needed deciding.
The number that needed no deciding, and has landed. tonic 0.13.1's DEFAULT_MAX_RECV_MESSAGE_SIZE is 4 MiB and the send default beside it is usize::MAX (read out of the vendored source, tonic-0.13.1/src/codec/mod.rs:100-101, not from memory). gunnar overrides neither. So a Refs.ReadObject response over 4 MiB cannot be received by a client using the generated stub's defaults: it fails on the client with a length error after the server has already inflated and serialised it. That is not a deployment number, it is a property of the transport, and refusing such a request costs nobody anything.
read_object now takes the size off ObjectStore::header — free, gix-odb answers kind and size out of the index without inflating — and answers ResourceExhausted naming the object's size when the response would exceed it. On the response, not on the object, which is the whole point: asking for a 1 KiB prefix of a 1 GiB blob is answered exactly as before, so this closes an expensive failure without turning a working request into a refusal. ControlError grew a ResourceExhausted variant mapping to Status::resource_exhausted, because "retry narrower" and "you sent something malformed" are different facts to a generated client.
SEEN RED, and on the allocation rather than on the error, because the error cannot tell the two implementations apart. With the header probe swapped for a full read() — i.e. inflate first, then refuse, returning the identical ResourceExhausted with the identical message:
refusing a 12582912-byte object allocated 12631547 bytes, which is the objectitself: it was inflated and only then refused, so the memory amplifier this refusalexists to close is still open (S-021)
The guard is crates/gunnar-cli/tests/read_object_copies.rs's an_undeliverable_response_is_refused_without_inflating_the_object, over the counting #[global_allocator] that file already had. It also asserts the error names the size (or a caller cannot pick a max_bytes that would work) and — the other side, without which a blanket refusal would pass — that a 1024-byte prefix of the same object is still answered, truncated, with the right bytes. reading_an_object_inflates_it_once_and_does_not_copy_it_again's fixture moved from 8 MiB to 3 MiB so it measures the copy instead of hitting the new refusal; its subject is one materialisation against two, which does not depend on the size.
The number that still needs deciding, stated so it is one sentence for Rickard rather than a design question. The first inflation is still unbounded for a caller that passes a small max_bytes: the server materialises the whole object to produce the prefix, because ObjectStore has no bounded read and for a delta-encoded object cannot honestly grow one (the investigation above, against gix-odb 0.83.0). Bounding that needs a ceiling on the object, which genuinely does refuse requests that work today. The ceiling is
ceiling = server memory budget for this RPC ÷ concurrent inflates
and the denominator is already known: read_object runs synchronously on a control-plane worker (S-009 gave the control plane its own runtime and did not add spawn_blocking), and there are control_plane_workers() of them — cores ÷ 4 clamped to 2..=8, so 8 on oden's 32 cores. So a 64 MiB ceiling is a 512 MiB worst case, a 256 MiB ceiling is 2 GiB. The only missing input is the budget. It is written at MAX_RESPONSE_BYTES in control_plane.rs so the next reader does not re-derive it.
Worth recording alongside: S-008's own "NOT done" note says the gRPC router still has no ConcurrencyLimitLayer. That does not make the denominator unbounded here, because the synchronous call occupies a worker thread and further callers queue rather than allocate — but it does mean the bound moves the day anyone wraps these methods in spawn_blocking, which gunnar-grpc/src/seam.rs:28-34 still asks for.
- Closed: 2026-08-04, commit
9f36e68. The object ceiling landed, and the budget was decided rather than deferred again.
The number: 64 MiB, from the arithmetic this entry already wrote — 512 MiB budget / 8 concurrent inflates. Both factors are now constants in control_plane.rs carrying their own reasoning. The denominator uses the clamp's upper bound rather than the live control_plane_workers(), deliberately: a default derived from available_parallelism() would refuse different requests on a laptop and on the forge host, which is the class of difference nobody finds until production. The budget is 512 MiB because the control plane is an administrative surface sharing a box with the git serving path, and half a gigabyte is far more than object inspection needs — the number that would want defending is a larger one, not this.
It is settable end to end, because this is the one ceiling that turns a working request into a refusal and so must not be compiled in: --max-object-bytes on gunnar serve → ServeConfig::max_object_bytes → CoreControlPlane::with_max_object_bytes, with 0 for no ceiling spelled explicitly rather than being what you get by configuring nothing. Verified reachable by gunnar serve --help, not by reading the clap derive. The refusal names both the object's size and the ceiling, so an operator can tell whether to raise the limit or the caller to stop asking.
SEEN RED three ways, because the error alone distinguishes none of them.
-
Inflate first, refuse after — a byte-identical
ResourceExhausted, caught only by the allocator:refusing a 3145728-byte object allocated 3242117 bytes, which is the objectitself: it was inflated and only then refused (S-021) -
The ceiling read off the constant instead of the field — i.e. configured but not wired. The object came back served, which no assertion on the constant could ever have caught.
-
0no longer disabling it:a ceiling of 0 is no ceiling.
The guard also asserts the other side three times over, or a blanket refusal would pass everything: the same object through a default-ceiling plane is still read, a 4 KiB object through the same low-ceiling plane is still served, and an uncapped plane returns the whole thing.
A fourth red, which nobody was looking for, and it is this file's own disease. tests/read_object_copies.rs measures with a counting #[global_allocator], which is per process, while cargo test runs a binary's tests on parallel threads. Every measurement was therefore fed by the other tests' allocations and reset by their ALLOCATED.store(0). It was a coin flip and it lied in both directions:
reading an 3145728-byte object allocated 6484018 bytes, over the 4718592-byteceiling ...(another test's inflation, inside this window)
reading an 3145728-byte object allocated only 48772 bytes, so this is not measuringthe inflation at all(another test reset the tally mid-window)
The second shape is the dangerous one: without the floor assertion, "allocated almost nothing" reads as an excellent result. This was latent from the moment the second measuring test was added — the two raced and happened not to lose — so every earlier SEEN RED recorded in this entry was taken under a --test filter that ran one test at a time. A third test made it reliable rather than causing it. Fixed by construction: the exclusion lock is taken by fixture(), not by each test, so a fourth test added later gets it without a usage rule to remember. Three consecutive runs had failed; five consecutive full runs are green.
What is still true and is not claimed fixed: a read that IS allowed still inflates the whole object to produce a prefix. ObjectStore has no bounded read and for a delta-encoded object cannot honestly grow one. The cost of that is now bounded, not eliminated, and the module docs of both the source and the test say so.
S-022 — 58 log lines in 27182 lines of code, no spans, no ids, no counters
-
Found: 2026-08-03
-
Status: DONE
-
Severity: scale
-
What: MEASURED 2026-08-03,
tracing::call sites per crate:
| crate | calls |
| gunnar-cli | 15 |
| gunnar-ssh | 14 |
| gunnar-core | 7 |
| gunnar-grpc | 7 |
| gunnar-client | 4 |
| gunnar-policy | 3 |
| gunnar-store | 3 |
| gunnar-wire | 3 |
| gunnar-pack | 2 |
| gunnar-pktline | 0 |
| gunnar-proto | 0 |
| total | 58 |
There is no tracing::span! and no #[instrument] anywhere, no connection id, no request id, no request timing, and no metrics of any kind: no counters, no histograms, no prometheus, no metrics crate. Connection identity in the SSH logs is an ad-hoc ?self.peer (Option<SocketAddr>) repeated at each call site rather than a span field. The distribution is worse than the total suggests:
-
Every gRPC service file has zero logging:
service/access.rs,service/admin.rs,service/mirror.rs,service/policy.rs,service/refs.rs,service/repos.rs. Soprincipal add/remove,key add/revoke,grant/revoke,repo create/delete, the whole mutating control plane and the one that D9 leaves unauthenticated, produce no log line at all. There is no audit trail for the surface that most needs one. -
crates/gunnar-cli/src/server/control_plane.rs,stores.rsandadmin.rs: zero. -
Across the entire pack-write, pack-read and object-store layer there are two call sites, both on the success path at the end of an operation (
write.rs:148,read.rs:165). No corrupt pack, CAS failure, missing object, alloc-limit trip or hash-kind mismatch is ever logged where it happens. -
gunnar-pktlinehas zero by design (it is the zero-dependency leaf), which is defensible, but it means the front door never reports a malformed frame, and no caller logs it either. -
Why it matters at load: At forge scale the questions an operator must answer in minutes are: which connection, which principal, which repository, how long did it take, how many failed, is the queue growing. None of them can be answered from gunnar's output today. S-008's invisible queueing and S-018's lock waits are both unobservable for the same reason. Nothing here can be retrofitted usefully at incident time.
-
Fix: A
tracingspan per SSH connection carrying a generated connection id, peer address, principal id and repository, entered before auth and covering the whole session, so every line inside inherits them. A span per gRPC request likewise. Timing on every transfer. Counters for: connections accepted/refused, auth failures, transfers by verb, bytes served, objects packed, policy rejections by gate, CAS failures, walk budget trips, blocking-pool saturation. Anerror!at every point where an error is currently returned silently. -
Fix note: LAW 5. One span helper, applied at the two entry points, not thirty hand-written field lists.
-
Verify: A test that captures
tracingoutput for one full clone and asserts the connection id appears on every emitted line, and that a rejected push emits exactly one error line naming the gate and the ref. Seen RED first; today it emits neither. -
PARTLY CLOSED 2026-08-03, the gRPC half. The worst item in the list above was "every gRPC service file has zero logging … the whole mutating control plane, and the one that D9 leaves unauthenticated, produces no log line at all". Both halves of that sentence are now false:
- D9 is closed (§8.2). Every RPC needs an
sshsigcredential over the §5 key table and a grant fromAuthTable. - Every authorised call emits exactly one audit record on the
gunnar::audittracing target, carryingmethod,principal,fingerprint,scope,level,auth_modeanddecision. A refusal at the gate emits a different record (grpc.auth.refused) and no call record, so the trail never shows admin activity that did not happen. - One writer, in
gunnar_grpc::auth, not seven hand-written field lists (LAW 5, the fix note above). - Proved in
crates/gunnar-grpc/tests/auth.rs:a_mutation_emits_an_audit_record_naming_the_calleranda_call_refused_before_the_handler_leaves_no_call_record, the second of which also asserts the capture itself is not blind (an allowed call does produce a record); without that, both absence assertions would pass against a subscriber that saw nothing.
- D9 is closed (§8.2). Every RPC needs an
-
CLOSED 2026-08-03, the rest of it: spans, request ids, timing and counters. The second half, and the one the Fix and Verify sections above describe.
One span per connection, and one vocabulary. crates/gunnar-core/src/observe.rs is the single definition of both. Six sibling targets, TARGETS enumerating them so a filter can be written against a list rather than a guess:
| target | what is on it |
gunnar::audit |
control-plane mutations: who called what, and the decision (D9) |
gunnar::ssh |
connections: accepted, authenticated, dispatched, every refusal |
gunnar::git |
transfers: upload-pack, receive-pack, objects, packs, bytes |
gunnar::policy |
the push gate: which gate refused which reference |
gunnar::store |
ref transactions, CAS contention, repack |
gunnar::metrics |
the counter heartbeat |
The hierarchy, all of it in one process and none of it restating a field:
ssh.connection { conn, peer, principal, repo } gunnar::ssh
└── git.transfer { service, access } gunnar::git
├── git.upload_pack | git.receive_pack gunnar::git
│ └── store.ref_txn { document } gunnar::store
└── policy.gate { ref_name } gunnar::policy
principal and repo are declared Empty at new_client and recorded when auth_publickey and exec_request learn them, so a line at any depth carries all four. Measured in the test capture: gunnar_wire::receive_pack and gunnar_pack::read, crates this change did not touch, come out carrying conn=…, principal=tester, repo=team/levels without a line of their own being edited. Two hand-offs would have dropped the span silently and both are explicit: tokio::spawn (the watchdog and the transfer task take .instrument) and spawn_blocking (WireTransport::serve captures Span::current() and enters it on the blocking thread, which is where the whole git conversation actually runs).
The request id is not a UUID. RequestId is a u64, process seed in the high 32 bits and a relaxed fetch_add in the low 32, rendered {:016x}. No dependency, no allocation, no lock, fixed width so it is greppable.
Timing, and applied outputs rather than "done". Every operation records a duration: git.transfer.finished (admission to last byte), git.upload_pack.served, git.receive_pack.served, policy.gate.*, store.ref_txn, store.repack.*. The lines carry what was applied: objects sent, bytes measured at the wire (a Metered<T> around the stream, not derived from an object count), negotiation rounds, common, protocol as spoken, refs ok / refs rejected, pack bytes, packs and loose objects folded, and copied vs recompressed (P-001's measure).
Counters, 18 of them, one block of AtomicU64 in ServerMetrics. gunnar serve mints one and hands the same Arc to the SSH listener and the transport, so connections_refused and pushes_rejected are readings of one server. No lock is taken to record one; transfers_in_flight is released by a Drop guard, not a call. A repacks counter was written and then removed: gunnar gc runs in a separate process with no ServerMetrics, so it could never have moved, and a counter that cannot move reads as "this never happens".
The heartbeat. One gunnar::metrics line every 60 s (ServeConfig::metrics_interval), skipped when nothing changed, plus one at shutdown. It answers "what is this server doing right now" from a journal an operator already has open, and cost four lines rather than a second listener and a second protocol.
Errors that used to be silent now say so. repo_store failing after the grant check said yes (the client saw a dead channel and the server said nothing); upload-pack/receive-pack failing mid-transfer; the ref transaction's final Err; a CAS that keeps losing (warn! from the second retry, because one lost race is normal and a document that keeps losing them is S-018); a policy engine that refuses without naming a gate.
- Verify: done, and seen RED.
crates/gunnar-cli/tests/observability.rs, six tests, all driving stockgitover real SSH into a realgunnar serve. The capture (tests/common/capture.rs) is aLayerthat keeps each span's fields in its extensions and attaches the inherited set to every event, so an assertion can demand thatprincipalcame from the span and not from the call site, which is the exact difference between the fix and what it replaced. It is a global subscriber:with_defaultis thread-local, and every transfer runs on a spawned task and a blocking thread, so a thread-local capture would have seen nothing and passed.
Three emissions were broken and the suite watched go red, then restored:
| broken | red |
span.record("principal", …) deleted from auth_publickey |
"the connection span does not carry the principal", 2 tests, 4 still green |
metrics.add_gate_refusals(1) deleted |
"gate_refusals did not move: 0 → 0", 1 test |
Metered::write stops counting |
"bytes_out is 0, which is not a packfile", 2 tests |
What each test pins: the connection span's conn/peer/principal/repo as inherited, the span stack ssh.connection → git.transfer → git.upload_pack, and that every line under the connection carries the same id; a refused key producing a warn naming reason=unknown-key and the fingerprint, with no principal on it and auth_faults deliberately not moved; a gate refusal naming gate=branch-protection and ref_name=refs/heads/main, agreeing with the ref-level line; counters read before and after with per-field deltas, including the ones that must not move; that the essential events are at info or above so the shipped default is legible; and that every gunnar:: target is declared, with the remaining module-path targets pinned to a known list so the gap cannot grow silently.
-
What is still dark, said plainly.
gunnar-wire,gunnar-packandgunnar-storestill log on module-path targets (gunnar_wire::receive_pack,gunnar_pack::read, …). Their lines inherit the connection span, so they are attributable, butRUST_LOG=gunnar::git=debugdoes not select them. Those crates were held by other agents during this change. The test pins the current set so a new one turns it red.- No metrics surface on gRPC.
Admin.Statsreports the catalog and the event bus, notServerMetrics; that needs agunnar-protochange. Today the counters are reachable fromServer::metrics()and from the heartbeat line. - No histograms. Durations are on individual lines; there is no p50/p99 anywhere, so "the slow clone" is findable and "clones got slower this week" is not.
- The blocking-pool saturation counter named in the Fix section does not exist.
tokiodoes not expose the depth of its blocking queue.S-008's admission gate counts what it refuses, which is the observable half. gunnar-pktlinestill logs nothing, by design (zero dependencies), so a malformed frame is still reported by nobody.
-
Closed: 2026-08-03. The gRPC half with D9, the rest (spans, request ids, timing, counters) in the same day's second pass.
S-047 — Negotiation re-derives every round from scratch, so a divergent fetch is O(rounds²)
-
Found: 2026-08-03, from the
gunnar.negotiate_divergentbench arm -
Status: IN-PROGRESS
-
Severity: scale
-
Where:
crates/gunnar-wire/src/negotiate.rs(negotiate, and the newHaveMemo) ·crates/gunnar-wire/src/graph.rs(Reachability::reaches_common) ·crates/gunnar-wire/src/upload_pack.rs:673-705(the v0 loop, same disease, not fixed) -
What: A protocol-v2
command=fetchcarries the client's wholehavelist on every round, andnegotiatewas a pure function of one round's arguments. So round n redid, from scratch, the work of rounds 1..n. Three separate redundancies, all with the same shape and all invisible in a three-round test:common.contains(have), a linearVecscan perhave, inside a loop overhaves, both of which grow with the round number.Reachability::reaches_commonread every commit twice. The iterative post-order left a commit on the stack under its unresolved parents; the second look had to re-parse the commit to reach its parents again. Correct, and exactly double.- The date cutoff was re-derived by re-reading every common commit, every round. git does not do this:
upload-pack'sgot_oid()folds eachhave's date into a runningoldest_haveas it arrives, once.
-
The diagnosis, because it was not what the number looked like. The arm reported 1250 rounds and 18.9 M commits visited for 154 s, and neither of those is the cause.
- Not the round count. 1250 is
40 000 haves ÷ 32 per round, the bench's own synthetic client, which offers a fixed window and never gives up. Realgitdoubles its window from 16 and abandons negotiation afterMAX_IN_VAINhaves, so it would take ~10 rounds against the same history. The round count is the instrument, not the finding. What it does do is multiply the per-round cost by 1250, which is why the per-round shape is the whole story. - Not the commits visited. 18.9 M object reads out of a
MemObjectStoreis a few seconds. Fix (1) removed 73% of the wall clock while changingcommits_visitedby exactly zero. The most expensive thing in the negotiation was never a store access at all. - Not
S-004's repository-sizedHashSetclone. MEASURED, and this needed checking:fetchreturns atupload_pack.rs:502on any round that is notready, beforesend_pack, soselect_objects, and with it thelet mut seen = excluded_objects.clone()atgraph.rs:427, runs once per negotiation, on the single ready round, not 1250 times. The arithmetic proves it rather than the reading does: oneselect_objectsover this fixture costs ~100 k commit reads, and the measured total leaves only ~120 k after the negotiation loop's 18.78 M is accounted for. 1250 of them would be 125 M, 6.6× the entire measured figure.S-004stands as a memory finding and is not what makes this arm slow. - Not
multi_ack_detailedeither, and this is worth saying plainly.S-020is a real gap and implementing it is real work, but it is a v0 capability and this arm exercises v2, which has its ownacknowledgments/readysection and never usesmulti_ack. Advertisingmulti_ack_detailedwould not have moved this number by a millisecond. It would fix the round trips a v0 client spends; it would not fix what a round costs, which is what this entry is about. The two are independent, and doingS-020first would have been a week spent on the wrong axis.
- Not the round count. 1250 is
-
Why it matters at load: Per-round cost that grows with the round number is
O(rounds²)per connection and it is entirely client-driven: a client chooses how many rounds to take and how manyhaves to carry. At forge concurrency that is a server-side amplifier on an input nobody controls. -
MEASURED, before and after, oden, 2026-08-03.
gunnar.negotiate_divergent, 20 000-commit shared base + 20 000 commits of divergence each side, sha256, one core. Run inside a detached git worktree pinned at87524d6with a frozen copy of the bench crate, because five agents were editing the tree and one of them brokegunnar-storemid-sweep.
| # | variant | negotiate_ms |
commits_visited |
rounds |
| V0 | baseline (87524d6) |
197 248 | 18 901 251 | 1250 |
| V1 | + set-based have dedup |
37 356 | 18 901 251 | 1250 |
| V2 | + one read per commit in the reach walk | 34 781 | 12 641 251 | 1250 |
| V3 | + per-connection have memo |
17 905 | 6 401 251 | 1250 |
11.0× on wall clock, 2.95× on object reads. The two read reductions land on their predicted values to the object: V1→V2 removes 6 260 000 reads, which is exactly the redundant second read of every commit the walk touched; V2→V3 removes 6 240 000, exactly the re-derived cutoff minus the 20 000 first-time have lookups the memo still pays. Arithmetic that lands on the nose is the evidence that the model of the cost was right, not just the direction.
Repeated back to back in the same sweep, to bracket the noise rather than assert past it: V0 again 173 757 ms, V3 again 16 140 ms, so 10.8×, against the 11.0× above. commits_visited was bit-identical on every repeat.
The wall clock on this box is noisy and is reported as such. V0 measured 158 394 ms on a quiet box, 197 248 ms at load average ~20, against the 154 490 ms the arm first recorded. commits_visited is deterministic and identical across every run, which is why the read counts above carry the argument and the milliseconds only corroborate it.
-
Fix: Landed, in
crates/gunnar-wire:HaveMemoonUploadPack, one entry per distincthave, holding is it here and when was it committed. It is a cache of facts about the object store, not negotiation state: the store does not change under a connection, so a warm memo decides exactly what a cold one decides, and the one race (a concurrent push adding an object a client was already told nothing about) errs towards sending more, never less. Memory is client-sized, bounded by thehavelist the client already forced the server to hold, and deliberately not the repository-sized shapeS-004is about.reaches_commonrewritten as a single downward sweep. The fold is an or, so a walk that exhausts proves every commit it visited cannot reach a common: one read each, and a stronger memo than the post-order produced. Thetruecase memoises onlystart, because the walk stopped early and nothing else on the frontier is settled.- The
Vec::containsdedup is now aHashSet. This is a third site ofS-017's pattern;S-017's own two sites (commit_wants,add_reachable_tags) are untouched and still TODO. That this one cost 81% of a 197-second negotiation without touching the object store once is the strongest argument S-017 has.
-
Verify:
crates/gunnar-wire/tests/negotiate_cost.rs, built onS-005'sconnectivity_cost.rspattern and sharing its counting store (LAW 5: the twinned copy in that file was deleted, because two copies of a measuring instrument can drift silently).an_absent_have_is_probed_once_per_connection_not_once_per_round:containscount.the_have_date_bound_is_not_re_derived_from_every_common_have_every_round:readcount, on a fixture whose walk cost is constant by construction so that the only thing that can grow is the cutoff derivation.a_warm_have_memo_answers_exactly_as_a_cold_one: the same negotiation driven on one reusedUploadPackand on a fresh one per round, comparingcommon,ready,objects_sentand the bytes on the wire, over a fixture asserted to contain bothNAKrounds and thereadyround.
Each asserts cost(round 5) == cost(round 20) exactly, since a generous bound passes on anything, and asserts a floor of >= WINDOW separately, so a negotiation that stopped touching the store cannot pass by doing nothing.
SEEN RED, twice, in a worktree pinned at 87524d6:
| probe | result |
| the pre-fix code | the two cost tests fail with per-round costs in exact arithmetic progression, [21, 41, 61, … 401] and [24, 44, … 404]. That progression is the finding, printed. |
the fix, with HaveMemo deliberately answering every miss Absent |
all three fail, including the warm-versus-cold one. |
The second probe is why the floor is >= WINDOW and not > 0. Against that broken memo the per-round costs were [1, 1, 1, …] and [2, 2, 2, …]: constant, non-zero, and entirely from fetch probing the want. A > 0 floor passed it. The guard was blind and was caught being blind, which is the only reason it is not blind now.
-
NOT done. The remainders, named rather than left to be discovered:
- The reachability walk is still redone every round. 6.26 M of the remaining 6.40 M reads are the walk itself:
Reachabilityis constructed fresh percommand=fetch, so a divergent negotiation re-walks the wants' history from the tip on every round, deeper each time as the cutoff drops. This is the lastO(rounds²)term and the memo above is the wrong shape for it. What it needs is either §4.2's__gunnar_reach__bitmaps, or carrying theknownmap across rounds, which unlikeHaveMemoreally is negotiation state and needs the stateless-rpc case thought about before it is written. - The v0 loop has the identical disease and was left alone deliberately.
first_common(&haves)atupload_pack.rs:795re-scans the whole accumulatedhavelist on every flush,O(rounds²)ObjectStore::containscalls.HaveMemoalready sits onUploadPackand would fix it in about five lines, but no bench arm measures v0 negotiation, and shipping an unmeasured change into the path the 17 real-git interop cases guard is exactly the trade this file exists to refuse. It wants agunnar.negotiate_v0arm first.
- The reachability walk is still redone every round. 6.26 M of the remaining 6.40 M reads are the walk itself:
-
Closed:
correctness
S-023 — MustNotExist is not a create guard on the fs backend, and the conformance suite cannot see it
- Found: 2026-08-03
- Status: DONE
- Severity: correctness
- Where:
crates/gunnar-store/src/fs.rs:239-248(to_gix_previous) · the blind test:crates/gunnar-store/src/conformance.rs:652-674 - What:
FsRefStorepassesPreviousValue::MustNotExiststraight through to gix (fs.rs:244) with no explicit existence check. This is the known gix behaviour recorded in §3 of the spec: gix's file ref store short-circuits an edit whose new value equals the current one before evaluating the precondition, so re-creating a ref at the value it already holds silently succeeds instead of failing the compare-and-swap. It was found and fixed ingunnar-client; thefsbackend delegates to the same machinery and was never checked. The conformance suite has exactly the hole that lets it pass.ref_create_must_not_existcreatesrefs/heads/mainatoid_a, then tries to create it again atoid_c, a different value, and asserts the refusal. The short-circuit only fires when the values are equal, so the one case that breaks is the one case the test does not construct. This is a guard that cannot go red on the bug it exists to catch. - Why it matters at load: Blast radius is small but the class is not: a client is told "ok" for a create that was a no-op, and two concurrent creates at the same value both report success, so "exactly one creator wins" is not true on the
fsbackend. Sincefsis the only backend that survives a restart today (crates/gunnar-cli/src/server/stores.rs:12-14), it is the backend everything actually runs on. It also means the conformance suite's green is weaker than it looks for every other CAS property that delegates the same way. - Fix: In
FsRefStore::transaction, check existence explicitly for everyMustNotExistedit before handing the batch to gix, and fail withError::RefCas. Then add the same-value case toref_create_must_not_existin the shared conformance suite so every backend is held to it (LAW 5: one suite, not a per-backend patch). - Verify: Add to
conformance.rs:652: create atoid_a, then create again atoid_a, and assert refusal. It must be seen RED on thefsharness before the fix. If it is green onfsbefore the fix, the gix behaviour has changed and this finding should be markedINVALIDwith that evidence rather than fixed. - Closed: 2026-08-03, uncommitted in the working tree (
crates/gunnar-store/src/fs.rsFsRefStore::transaction,crates/gunnar-store/src/conformance.rsref_create_must_not_exist). Seen RED first, which is the whole point of this entry: with the same-oid case added and the backend untouched,cargo test -p gunnar-store --features fs --test conformancereported2 of 42 conformance checks failed, both of themref_create_must_not_exist, onfsonly.mem,arrow_logandarrow_log_in_memorystayed green, so the finding is a backend defect and not a bad assertion. The failure printed gix's own rewritten edit and with it the mechanism:Update { expected: MustExistAndMatch(Object(...)), new: Object(<the same oid>) }. The caller wroteMustNotExistand gix replaced it before evaluating it. gix's behaviour is therefore unchanged and theINVALIDbranch of the Verify clause does not apply. Fixed by pre-checking the batch withcrate::refs::check_all, the same functionmemand the Arrow log already decide a CAS with, so there is still one definition of a CAS failure and not a per-backend copy (LAW 5). That also closes the wider short-circuit class rather than onlyMustNotExist. Green after: all 42 checks × 2 hash kinds on all four harnesses, plusconformance/red.rs's deliberately broken backends still going red. Documented in the code as a pre-check and not a lock: it can only add refusals, and the residual race (a concurrent writer landing between the read and gix's prepare with the same value) needs the check inside gix's transaction, which is upstream work (S-038).
S-024 — PushAttestation has no producer; three published event fields are constants
-
Found: 2026-08-03
-
Status: DONE
-
Severity: correctness
-
Where:
crates/gunnar-cli/src/server/transport.rs:354-391(publish) ·crates/gunnar-grpc/src/attest.rs· claimed atcrates/gunnar-grpc/src/lib.rs:72-74andcrates/gunnar-grpc/src/bus.rs:60-62 -
What: Corrected from the working assumption:
RefUpdated,RefDeletedandPushRejectedare published, fromgunnar-cli's transport attransport.rs:391. What is not:PushAttestationhas no producer anywhere insrc.attest.rsdefinesattestation_json_lineand the bus filters on the variant (bus.rs:202), but the only code that constructs one is a test (tests/event_bus.rs:284). The crate docs describe attestation as something the push path emits. It does not.- Three fields are hardcoded rather than filled:
key_fingerprint: String::new()on bothRefUpdatedandRefDeleted(transport.rs:361, 369),gate: String::new()onPushRejected(transport.rs:384), andforced: falseonRefUpdated(transport.rs:378), the last with an honest comment saying the answer was not available, so it was left false rather than guessed.
-
Why it matters at load: A consumer subscribing to
Events.Watchand readingforcedgetsfalsefor every force-push on the server. That is not a missing field, it is a wrong answer, and it is exactly the shape of the failure a sibling project already shipped once (a surface with no producer, consumed as if real).gateandkey_fingerprintbeing empty makes the event stream unusable as an audit trail, which compounds S-022's absence of logs. There is currently no way to answer "who force pushed to this branch and when". -
Fix: Thread the branch-protection gate's verdict (it knows fast-forward vs forced) and its own name back through
PushOutcomesopublishcan fill them, and thread the authenticated key's fingerprint from the SSH session intoServiceRequest. Then wire the attestation producer, or deletePushAttestationandattest.rsuntil it has one. A message with no producer is worse than no message. -
Verify: A test that force-pushes over SSH and asserts the published
RefUpdatedhasforced == true, and that a gate rejection publishes aPushRejectednaming the gate. Seen RED first. Today both are constants, and a test assertingforced == falseon a fast-forward would pass against a completely dead implementation. That is the identity-value trap; assert thetruecase. -
Closed (
forcedonly): 2026-08-03, uncommitted in the working tree (crates/gunnar-cli/src/server/transport.rs: newwas_forced,publishtakes the answer; test incrates/gunnar-cli/tests/push_events.rs,a_force_push_is_published_as_forced_and_a_fast_forward_is_not). Seen RED first. Againstforced: falsethe test failed on the force-push leg with "dc50fb6 does not descend from a7a2e7c, so this push REWROTE refs/heads/main and the event must say so", on a realgit push --forceover real SSH, watched by a liveEvents.Watchsubscriber. The test carries all three legs against one repository and one subscriber (create, fast-forward, force-push), so neither a constantfalsenor a constanttruesurvives it; asserting only the fast-forward leg is the identity-value trap this entry warned about, and it is exactly what the file's existing!updated.forcedassertion was. The answer comes fromgunnar_policy::ancestry::is_descendant_of, the same walkBranchProtectionGatedecides a non-fast-forward with and the one that crate cross-checks againstgit merge-base --is-ancestor. No second implementation was written (LAW 5):old != new, a hex compare and a first-parent walk are all listed as wrong in that gate's own module docs. The gate itself cannot be asked, because it only runs where a protection rule matches and the audit trail must answer for every reference on every repository. An undecidable walk (missing object, exhausted budget) publishesforced: truewith awarn, matching that gate's refuse-on-undecided judgement. Reportingfalsefor a walk that never finished would be the same class of wrong answer this entry is about. Green after: all 6 tests inpush_events.rs. -
Still open on this entry (owned by whoever holds the
gunnar-clitransport work; a concurrent agent landed these in the working tree while this fix was made, but the entry has not been re-verified against them here):PushAttestationnow has a producer (crates/gunnar-cli/src/server/attest.rs),PushRejected.gateis filled from the gate that refused, andkey_fingerprintis filled only when the principal holds exactly one key.ServiceRequeststill does not carry the key the session authenticated with, which is the remaining hole and is agunnar-sshchange. Close this entry when that lands. -
Closed: 2026-08-03. The remaining hole, the key fingerprint.
crates/gunnar-ssh/src/seam.rs(ServiceRequest::key_fingerprint),crates/gunnar-ssh/src/server.rs(Connectionkeeps it;Dispatch::authorisetakes it),crates/gunnar-cli/src/server/transport.rs(session_key_fingerprintreplaces the guess), test incrates/gunnar-cli/tests/push_events.rs.
It is a PARAMETER of authorise, not a builder like with_git_protocol, and that is the whole design. An attach-after-the-fact field can be forgotten, and a forgotten one is silently None, which is the exact shape of the bug being closed. The compiler asks for it instead: adding the parameter turned every call site red until it was supplied, including seven in tests/sandbox.rs and three in tests/anonymous.rs. git_protocol stays a builder for the opposite reason: it comes from the client and must never reach the grant check. None remains a real state, not a missing value: the anonymous path of D23 authenticates with no key at all, and cannot push, so it cannot reach an attestation.
Nothing is recomputed. auth_publickey already had the fingerprint; it is the value the identity table was probed with one line above, so the session keeps it rather than deriving it twice (S-029 had just made it a first-class Option<Fingerprint> at that boundary, which is why this was cheap).
What the guess could never have got right. The replaced code read the identity table back and answered only when the principal held exactly one key, because with two there is nothing in the table that says which one made the connection. So every machine account with a rotation key, and every human with a laptop key and a desktop key, published an empty fingerprint on every RefUpdated, every RefDeleted and every attestation. The audit trail was blank for precisely the accounts it exists for, and no amount of care in gunnar-cli could have fixed it, because the information was not there.
The fixture is two keys, and a one-key fixture is why this survived. With one key the guess is right, and right for the wrong reason, so every existing test agreed with it. the_event_names_the_key_that_authenticated_when_the_principal_holds_two registers a second key for the same principal, pushes with the second over real ssh with IdentitiesOnly=yes, and asserts the published fingerprint is the second key's, taken from ssh-keygen -lf rather than recomputed by gunnar. It asserts its own premise first, that the principal really holds two keys and that the two fingerprint differently, so a degenerate fixture reports itself instead of passing.
SEEN RED TWICE, because one red only proves one of the two things this test claims.
| probe | result |
the pre-fix guess (keys.len() == 1, else empty) |
left: "" · right: "SHA256:A8uKc/…" |
| "just use the principal's first key" | left: "SHA256:H0mO59…" · right: "SHA256:Rnr9MM…" |
The second probe is the one that was not optional. The test's message talks about the empty string, so the empty-string red was always going to appear, and a guard that only catches absence would happily accept a confident, wrong answer, which this entry states plainly is worse: "naming the wrong key in a provenance record is worse than naming none". Watching it reject the wrong key too is what makes that sentence enforced rather than written down.
PushAttestation's producer is proved by applied output, not by grep. The test asserts a PushAttestation arrives on a live Events.Watch subscriber from a real push, carrying the same fingerprint. A surface whose producer is asserted only by the producer's existence is how a neighbouring project's warehouse stayed empty across four sessions. cargo test -p gunnar-ssh -p gunnar-cli --features gunnar-store/fs: all green, push_events 7/7.
S-025 — touch_key is never called, so last_used is never set
-
Found: 2026-08-03
-
Status: IN-PROGRESS
-
Severity: correctness
-
Where:
crates/gunnar-core/src/auth.rs:388-400(defined) ·crates/gunnar-core/src/model.rs:89(last_used) -
What:
AuthTable::touch_keyrecords that a key just authenticated. Grep acrosscrates/*/srcfinds no caller; the only reference is its own definition. SoPublicKey::last_usedisNoneforever, and any UI orkey listoutput showing it is showing a field that is never written. -
Why it matters at load: "Which of my keys are still in use" is the question that makes key rotation possible at a forge, and it cannot be answered. But note the other half, which is why this must not be fixed by simply calling it:
touch_keygoes throughDocRef::update, so wiring it into the auth path would rewrite the entire identity table on every successful login: a several-hundred-megabyte serialise, hash, blob write and single-ref CAS per connection (S-001), all contending on one ref. It would take the server down under load. The feature is dead, and dead is currently the safer state. -
Fix: Do not call
touch_keyas it stands. Fix S-001 first; once the identity table is an append-only log, "key K authenticated at T" is one appended row and the feature becomes cheap and correct. Until then, either leave it uncalled with a comment pointing here, or write last-used to a separate, low-durability sidecar that is allowed to lose updates. -
Verify: After S-001, a test asserting
last_usedmoves after an authentication and that N authentications cost O(N) bytes written rather than O(N × table). The second assertion is the one that must be seen RED against a naive fix. -
Re-costed 2026-08-03, because S-001 has since landed and this entry's stated precondition is now half-met. The conclusion is unchanged: do NOT wire it. Confirmed still true:
grep -rn touch_key crates/*/srcfinds only the definition atcrates/gunnar-core/src/auth.rs:421.last_usedis read out over gRPC (control_plane.rs:201→last_used_unix_ms), so a consumer is shown a field that isNonefor every key on the server. What changed: S-001 was fixed by sharding, not by the append-only log this entry assumed.AuthTable::keysis now aShardedMap(doc.rs), sotouch_key'sdoc.keys.modify(...)insideDocRef::updateno longer rewrites the whole identity table. It rewrites one leaf plus two 32-entry trie nodes plus the document: ~5 KB below about 30000 keys, and ~250 KB per write at a million (arithmetic off the shard shape atdoc.rs:70-80, not measured). So the "several-hundred-megabyte write per login" and "it would take the server down" reasoning in the body above is stale by three orders of magnitude and should not be quoted. What did not change, and is why this stays open: it is still oneRefStore::transactioncompare-and-swap on one reference (refs/gunnar/auth) per successful authentication, server-wide, withMAX_ATTEMPTS = 8(doc.rs:67). At the stated target of thousands of concurrent clones every login contends on that single ref, and the failure mode is not slowness. It isErrorafter eight losses, on the authentication path. A key that authenticated correctly would be refused because a bookkeeping write lost a race. That is a strictly worse outcome than aNonefield. The two routes out, either of which closes this: (a) the append-only log this entry originally named, where "key K authenticated at T" is one appended row and needs no CAS; or (b) the low-durability sidecar the Fix clause already allows, written best-effort off the connection path and permitted to lose updates. (b) is much the smaller change and is the one to take unless the identity log is being built anyway. Ready to apply now (gunnar-core, held by another agent this session): a// S-025:comment ontouch_keysaying it is deliberately uncalled and why, so the next reader does not "fix" it by adding the call. That is the cheap half of the Fix clause and it is the only part that is safe today. -
Re-checked 2026-08-03, a third time, after
S-024landed. STILL NO PRODUCER, and it stays open.S-024was the neighbouring correctness finding about surfaces with no producer, and closing it did not give this one a sidecar:grep -rn 'touch_key\|last_used' crates/*/srcfinds the definition (auth.rs:440), the field (model.rs:148), the twoNoneinitialisers and the gRPC read atcontrol_plane.rs:210. No sidecar type, no sidecar file, no writer. So the two routes this entry names are both still unbuilt, and half-wiring the surface — adding the call, or adding a sidecar with nothing writing to it — would repeat exactly the failureS-024was about, and the one the fleet'sSubmitBuildProvenancewarehouse sat in for four sessions: a table with 0 rows next to a working one, while the diagnosis chased the read path. The cheap half of the Fix clause HAS now landed,gunnar-corebeing free this time:touch_keycarries a// S-025block saying it is deliberately uncalled, that the several-hundred-megabyte cost argument above is stale by three orders of magnitude and must not be quoted, that what forbids the call is the single-ref CAS on the authentication path and not the size, and that no sidecar exists yet. The next reader who greps for an uncalled function now finds the reason at the function. -
Re-checked 2026-08-04, a fourth time. STILL NO PRODUCER — and the entry's own premise turns out to be already violated by its twin, which is the finding.
The verification, so the next agent need not repeat it: grep -rn 'touch_key\|last_used' crates/*/src returns the definition (auth.rs:519), its // S-025 block (auth.rs:495-518), the field (model.rs:148), three None initialisers and the gRPC read at control_plane.rs:236. grep -rni sidecar crates/*/src finds nothing relevant — every hit is repack.rs's pack sidecars. No sidecar type, no sidecar file, no writer. Both routes this entry names are still unbuilt, so it stays open and half-wiring it would repeat exactly the failure S-024 was about.
What is new: touch_token — the same function for tokens, three hundred lines below touch_key in the same file — IS wired, on the HTTP request path. gunnar-http/src/service.rs:131 calls it for every authenticated request. It is the same DocRef::update — clone the document, modify one shard, flush the blobs, then a compare-and-swap on the single refs/gunnar/auth reference with MAX_ATTEMPTS = 8. That is the very cost this entry refuses to pay for keys, already being paid for tokens.
It is not the same defect, and the difference matters: the call is let _ =, so a lost CAS does not refuse a request that has already authenticated. This entry's stated fear — "a key that authenticated correctly would be refused because a bookkeeping write lost a race" — is avoided there. What is not avoided is the throughput cost, which is filed separately as S-048 rather than being folded in here, because it is a live serialisation point on a working path and not a dead function.
- Closed:
S-026 — The real-git interop suites self-skip and report green
-
Found: 2026-08-03
-
Status: DONE
-
Severity: correctness
-
Where:
crates/gunnar-store/tests/real_git_reads_what_we_wrote.rs:144-160·crates/gunnar-ssh/tests/stock_clients.rs:73-78,:122-127,:160-165 -
What:
#[test]
fn git_reads_a_sha1_repository_gunnar_wrote() {
if !git_available() {
eprintln!("SKIPPED: `git` is not on PATH, so §15 interop was NOT verified");
return;
}
check(gix_hash::Kind::Sha1);
}
The message is honest, and it goes to stderr where no test harness reports it. To cargo test this is a pass with zero assertions executed. The same pattern is in three stock_clients.rs tests for ssh. The contrast is instructive: crates/gunnar-cli/tests/ssh_end_to_end.rs uses assert!(tools_present(), "this test requires gitandssh on PATH"), a hard failure. That is the right shape, in the same repository.
-
Why it matters at load: These are the only tests that prove gunnar's output is readable by real git; the crate's own header calls it "the only honest proof". On any build image without
gitorsshinstalled, the entire interop story reports green while never running. There is no CI today (S-033), so the first CI image anyone builds is where this bites, and it will bite silently. -
Fix: Use the
ssh_end_to_end.rsform, a hardassert!on tool presence, in all four places. If a genuinely optional tool is ever needed, gate it with#[ignore]plus an explicit CI job that runs--ignored, so absence is visible in the harness output rather than in stderr. -
Verify: Temporarily rename
gitoffPATHand run the suite; it must fail, not pass. That is the RED. Do this before trusting any future CI green. -
Re-counted 2026-08-03: the finding is live and it has GROWN from four self-skips to eleven.
grep -rn SKIP crates/*/tests/*.rs→ 11 hits in four files:crates/gunnar-store/tests/repack.rs:392, 401, 412, 460, 835: five new ones, added with S-006's repack in this same session. Every one isif !git_available() { eprintln!("SKIPPING: …"); return; }, and they guard the tests that prove the cold tier is readable by real git. S-006'sClosed:citesa_repack_folds_every_pack_and_loose_object_into_one_sha256as evidence; on an image withoutgitthat evidence silently does not exist.crates/gunnar-store/tests/real_git_reads_what_we_wrote.rs:147, 156: the two the entry named.crates/gunnar-ssh/tests/stock_clients.rs:76, 129, 171: the three the entry named.crates/gunnar-wire/tests/real_git_interop.rs:63: worse than a self-skipping#[test], and not in the original list. That target isharness = false(crates/gunnar-wire/Cargo.toml:34-35), so the skip iseprintln!("SKIP: …"); std::process::exit(0);, and the whole 17-case driver reports success having run nothing, andcargo testprints no test names at all to give it away. S-005'sClosed:leans on "17/17real_git_interop" as its proof that a narrower connectivity walk still refuses an incomplete push. Both tools are present on oden (/usr/bin/git,/usr/bin/ssh), which is exactly why this has stayed invisible.
-
Ready to apply. All three crates were held by another agent this session, so nothing was edited. The change is mechanical and identical in all eleven places: replace the
if !tool() { eprintln!(…); return; }prologue with the formcrates/gunnar-cli/tests/ssh_end_to_end.rsalready uses,assert!(git_available(), "this test requiresgiton PATH"), and inreal_git_interop.rsreplaceeprintln! + exit(0)withexit(1)and a message naming the missing tool, since that driver's exit code is its result. Do not add a#[ignore]instead: an ignored test is visible in the harness output, a skipped one is not, and the point of the change is visibility. The RED is the whole exercise and must not be skipped: run the suite withgitandsshshadowed offPATH(PATH=/usr/bin/env-only cargo test …, or a scratch dir prepended with the two names absent) before the change and confirm it reports green. That green is the bug. Then after the change confirm it reports failure naming the missing tool. Asserting on a machine that has both tools proves nothing either way. -
Closed: 2026-08-03. All eleven are fixed, and the RED this entry insisted on has now been run. The edits themselves landed with the crates' other work this session; what was missing was the only step that can tell a fix from a claim, and it is recorded here so nobody has to take the count on trust again.
The RED. The four test binaries were built once, then executed directly with PATH pointed at an empty directory, so git and ssh are genuinely absent while the toolchain that built them is not involved. Before this change every one of these reported a pass; each now fails, naming the tool:
| binary | with git/ssh absent |
the message |
gunnar-wire tests/real_git_interop (harness = false) |
exit 101, was exit 0 | this test requires git on PATH; §15 interop cannot be proven without it |
gunnar-store tests/real_git_reads_what_we_wrote |
2 failed, 0 passed | this test requires git on PATH (:146, :152) |
gunnar-store tests/repack |
5 failed | this test requires git on PATH (:356, :362, :370, :415, :759) |
gunnar-ssh tests/stock_clients |
3 failed, 0 passed | this test requires ssh on PATH, and git and ssh for the ls-remote case |
1 + 2 + 5 + 3 = 11 of 11, the exact count this entry re-counted to. Every one is the hard assert! shape crates/gunnar-cli/tests/ssh_end_to_end.rs uses; no #[ignore] was added, so absence stays visible in harness output rather than in stderr. grep -rn SKIP crates/*/tests/*.rs now returns only stock_clients.rs's module doc explaining what the pattern used to be.
The green side is intact on a machine that has the tools: repack reports 7 passed; 0 failed; 1 ignored and real_git_interop reports 17 passed, 0 failed, out of 17, so the assertions did not simply become permanently red.
S-027 — write_stream allocates a client-declared size with no cap
-
Found: 2026-08-03
-
Status: DONE
-
Severity: correctness
-
Where:
crates/gunnar-store/src/gix_adapter.rs:105-121 -
What:
fn write_stream(&self, kind: Kind, size: u64, from: &mut dyn std::io::Read) -> Result<…> {
let mut buf = Vec::with_capacity(size as usize);
Read::take(&mut *from, size).read_to_end(&mut buf)?;
size is a declared length, not an observed one, and there is no upper bound check, unlike gunnar_pack::IndexOptions::alloc_limit_bytes (read.rs:29-31), which exists precisely for this class of input. A declared u64::MAX either aborts the process on a capacity-overflow panic or attempts an absurd allocation, before a single byte is read. The buf.len() as u64 != size check at gix_adapter.rs:114 catches the mismatch only afterwards. On a 32-bit target size as usize truncates silently first.
-
Why it matters at load: This is
gix_object::Write::write_stream, reachable from any gix plumbing path that streams an object of a caller-declared size into the store. Whether a remote client can reach it today depends on the ingest wiring, which isgix_pack's; it is a latent one-call denial of service either way, and the fix is three lines. -
Fix: Clamp the pre-allocation (
Vec::with_capacity(size.min(CAP) as usize)) and reject asizeabove a configured maximum object size outright, with a typed error. Reusegunnar-pack'salloc_limit_bytesnotion rather than inventing a second knob (LAW 5). -
Verify: A test calling
write_streamwithsize = u64::MAXand a short reader, asserting a clean typed error and no large allocation. Seen RED first; today it panics. -
Re-confirmed 2026-08-03, unchanged.
crates/gunnar-store/src/gix_adapter.rs:105-121still readslet mut buf = Vec::with_capacity(size as usize);with no bound, andwrite_stream_with_known_id(:123-131) delegates straight to it, so there are two entry points, not one.gunnar_pack::IndexOptions::alloc_limit_bytes(read.rs:29-31) is still the existing knob to reuse. -
Ready to apply.
gunnar-storewas held by another agent this session, so nothing was edited. Three changes ingix_adapter.rs:- A module constant for the ceiling, taken from
gunnar-pack's existing notion rather than invented (LAW 5):gunnar_pack::IndexOptions::default().alloc_limit_bytesis already the number this crate's sibling uses for exactly this class of client-declared length. If that field is not reachable fromgunnar-store, thread it in rather than declaring a second number. - Refuse before allocating:
if size > CAP { return Err(Box::new(std::io::Error::new( InvalidInput, format!("declared object size {size} exceeds the {CAP}-byte limit")))); }. A refusal, not a clamp, because a clamp would silently truncate an object and store it under a hash of the wrong bytes. - Clamp the pre-allocation anyway,
Vec::with_capacity(size.min(CAP) as usize), so the 32-bitsize as usizetruncation the entry names cannot turn a rejected size into a small accepted one behind the check. The RED:write_stream(Kind::Blob, u64::MAX, &mut &b"hi"[..])must return the typed error. Assert on the error, not on "it did not crash": today that call either aborts on capacity overflow or attempts the allocation, so the test fails loudly before the fix and cannot pass vacuously after it. Add the twin call throughwrite_stream_with_known_id, or the second entry point stays unguarded and untested.
- A module constant for the ceiling, taken from
-
Closed: 2026-08-03,
crates/gunnar-store/src/gix_adapter.rs, plus one re-export incrates/gunnar-pack/src/lib.rs.
write_stream now refuses a declared size above gunnar_pack::DEFAULT_ALLOC_LIMIT_BYTES (1 GiB) with an InvalidInput error naming the size and the limit, before it allocates, and clamps the pre-allocation as well so the 32-bit size as usize truncation cannot slip a rejected size past the check. A refusal rather than a clamp, for the reason the plan gave: a clamp reads a prefix and stores it under the hash of bytes that are not the object.
The ceiling is the sibling's number, not a second one (LAW 5). It turned out to be reachable without threading anything: gunnar-store already depends on gunnar-pack unconditionally (Cargo.toml; ObjectStore::packed() is why). DEFAULT_ALLOC_LIMIT_BYTES was pub in gunnar-pack's private read module but never re-exported, so the only change needed there was adding it to the existing pub use read::{…} line. No constant was declared in gunnar-store.
The RED, watched, under ulimit -v 25165824 as the fixture rule requires. With the check removed and only Vec::with_capacity(size as usize) left:
panicked at library/alloc/src/raw_vec/mod.rs:28:5: capacity overflowtest result: FAILED. 1 passed; 1 failed
The case is u64::MAX deliberately, and the test says why: a merely large declared size (2 GiB) reaches the allocator, and an allocation failure aborts the whole test process instead of failing one test. u64::MAX exceeds isize::MAX, so Vec rejects it arithmetically and never calls malloc. cap + 1 is asserted alongside it, and both entry points, write_stream and write_stream_with_known_id, are driven separately, because "it delegates today" is a fact about the body, not the contract.
The second test is what stops the first being hollow: a_declared_size_within_the_ceiling_still_writes_the_object asserts a normal blob still round-trips and lands under the same id write_buf gives it. A write_stream that refused everything would satisfy every assertion in the first test perfectly. cargo test -p gunnar-store --lib → 42 passed, 0 failed.
S-028 — Unchecked subtraction in the loose-object decoder
-
Found: 2026-08-03
-
Status: INVALID
-
Severity: correctness
-
Where:
crates/gunnar-store/src/object.rs:131-146, specificallyobject.rs:137 -
What:
stored.len() as u64 - offset as u64panics on subtract-overflow in debug builds and wraps in release ifoffset > stored.len().offsetcomes from parsing (gix_object::decode::loose_header), not from a construction that proves it is in range. In today's call graph the bytes are only ever ones this crate wrote, so it is not reachable from the network. But the function's entire purpose is decoding storage that may be corrupt, and unchecked arithmetic is the wrong tool for that job. -
Why it matters at load: Storage corruption at forge scale is not hypothetical, and the difference between "returns a corrupt-object error" and "panics inside a connection handler" is the difference between one failed clone and a dropped connection with no diagnosis (S-022).
-
Fix:
checked_sub, returning the crate's corrupt-object error. -
Verify: A test feeding a truncated/forged loose header with an out-of-range offset and asserting a typed error. Seen RED first; today it panics.
-
Closed: 2026-08-03. INVALID.
offsetcannot exceedstored.len(), so the subtraction cannot underflow and the panic this entry describes is unreachable from any input at all, not merely unreachable from the network, which is what the entry already conceded.
The proof is in the callee. gix_object::decode::loose_header ends (gix-object-0.63.0/src/lib.rs:354-370, the version this workspace resolves):
let size_end = input.find_byte(0x0).ok_or(InvalidHeader { … })?;
…
Ok((kind, size, size_end + 1))
size_end is an index of a byte inside input, so size_end <= input.len() - 1 and the returned offset is <= input.len(). There is no path that returns a larger one.
MEASURED, and the measurement was proved able to fail (LAW 2). A standalone probe linking the same gix-object 0.63.0 walked 503539 inputs — every truncation of thirteen hand-forged headers (blob 18446744073709551615\0, blob -1\0, blob\0, \0, …), every legal <kind> <size>\0 shape across four kinds and seven sizes with payload lengths 0-7 and every prefix of each, and 500000 deterministic-fuzz strings over an alphabet weighted to the bytes the parser keys on. Of those, 1027 decoded successfully, min(len - offset) was 0, and offsets past the end: 0. The 1027 is asserted, not reported: an earlier corpus decoded only 19 inputs and the probe refused to draw a conclusion from it. The detector was then run against a deliberately broken decoder returning size_end + 2 and reported 239 violations and min(len - offset) = -1, so it is not blind.
A checked_sub was therefore NOT added, and that is the point of recording this rather than "fixing" it: with the callee's contract as it is, the error arm would be dead code that no test in this repository could ever reach, a guard that cannot go red, which is the class this file exists to catch. The residual risk is a future gix-object changing that contract; the honest defence against that is the length check decode_loose already performs on the next line (header claims {size} bytes but {n} follow), which is a real, reachable corrupt-object error. Nothing in crates/gunnar-store/ was edited. It was held by another agent this session, and there is now nothing to edit.
S-029 — expect() on client key bytes in the auth hot path, and on lock poisoning per request
-
Found: 2026-08-03
-
Status: DONE
-
Severity: correctness
-
Where:
crates/gunnar-ssh/src/access.rs:76-79·(closed, see below)crates/gunnar-cli/src/server/stores.rs:149,:163,:175 -
What:
let blob = key.to_bytes()
.expect("a public key russh authenticated with always re-encodes");
This runs in auth_publickey, on every SSH connection, over a key the remote client supplied. The reasoning in the message is probably sound for a key russh has already parsed and signature-verified, but it is an expect() on client-influenced input in the authentication path, and D20 already records that this codebase has two incompatible ssh-key copies in the graph, which is exactly the situation where a future point release changes an edge-case encoding. Separately, stores.rs guards its handle-cache mutex with .expect("the repository handle cache lock") in three places. Any panic under that lock poisons it and turns every subsequent request into a panic: a single-fault server-wide outage. For balance: the SSH command parser (command.rs) returns typed CommandError variants for every malformed input with no unwraps, and gunnar-pktline's decoder is clean (S-014). This finding is narrow, not systemic.
-
Why it matters at load: A panic inside an auth handler or under the global cache lock is not one failed connection; it is a poisoned lock or an aborted task on a server with thousands of live connections and no supervision beyond systemd.
-
Fix: Return
Ok(Auth::Reject)with a loggedwarninstead of theexpectinaccess.rs. Handle poisoning explicitly instores.rs: recover the guard, or return a typed error, rather than propagating a panic. -
Verify: A test that a key which fails
to_bytes()is rejected rather than panicking (inject via a stub), and a test that a panic under the cache lock does not make the next request panic. Both seen RED first. -
Closed (the
stores.rshalf only): 2026-08-03, as a side effect of S-010, not under this id, and re-verified here. All three.expect("the repository handle cache lock")calls are gone;crates/gunnar-cli/src/server/stores.rs:281-285is now onefn cache()that every caller goes through (LAW 5: one recovery point, not three) doing.lock().unwrap_or_else(|poison| poison.into_inner()), with the reasoning written at the function: what the lock protects is a cache of complete, already-opened handles, so a panicking thread cannot leave it half-written and recovering is strictly better than propagating.grep -c 'expect' crates/gunnar-cli/src/server/stores.rs→ the only remaining hits are unrelated. The "poisoned lock turns every subsequent request into a panic" half of this finding is therefore fixed. -
Still open, and it is the half this entry is named for:
crates/gunnar-ssh/src/access.rs:76-79is unchanged:key.to_bytes().expect("a public key russh authenticated with always re-encodes"), on every SSH connection, over client-supplied bytes. Ready to apply (gunnar-ssh, held by another agent this session):fingerprint_of_keycurrently returnsFingerprint. Change it toOption<Fingerprint>, returningNoneon ato_bytes()error with atracing::warn!(%err, "a public key russh authenticated with did not re-encode; \ rejecting"), and have the single caller inauth_publickeymapNonetoOk(Auth::Reject): a rejection, not a panic, and not an accept. The RED that makes this worth anything is the one the entry names and it needs a seam:to_bytes()cannot be made to fail on a key russh has verified, so extract the body asfingerprint_of_blob_result(blob: Result<Vec<u8>, _>)and drive the error arm directly from a test. Without that seam the newNonearm is dead code and the guard cannot go red. Do not land it green-only. -
Closed: 2026-08-03,
crates/gunnar-ssh/src/access.rsandcrates/gunnar-ssh/src/server.rs.fingerprint_of_keyreturnsOption<Fingerprint>;auth_publickeymapsNoneto a refusal through a newrefuse_unencodable_key. The seam the Ready-to-apply block insisted on isfingerprint_of_encoding(Result<Vec<u8>, E>).to_bytes()cannot be made to fail on a key russh has verified, so without it theNonearm would be dead code and this would have landed green-only. Seen RED, by putting the pre-fix body back behind the same seam:
thread 'access::tests::a_key_that_does_not_re_encode_is_refused_rather_than_panicked_on'panicked at crates/gunnar-ssh/src/access.rs:101:43:a public key russh authenticated with always re-encodestest result: FAILED. 16 passed; 1 failed
That panic is the production failure this entry describes, reproduced in a test rather than argued about. Two tests, because either alone is hollow. a_key_that_does_not_re_encode_is_refused_rather_than_panicked_on drives the error arm and asserts a real key still fingerprints to the ssh-keygen -lf known answer, so a fingerprint_of_encoding that answered None to everything, which would refuse every login on the server, cannot pass. server::tests::a_key_that_does_not_re_encode_is_rejected_and_counted asserts the applied output at the caller: the value handed back to russh is Auth::Reject, connections_refused moved by 1, and connections_accepted did not. An Auth::Accept would put a key nobody can fingerprint onto the server, which is the one outcome worse than the panic. auth_faults is deliberately left alone: it means "the identity table could not be read", and this key never reached it. The warn carries reason = "key-does-not-re-encode", which is what tells the two apart. cargo test -p gunnar-ssh: 78 passed, 0 failed. Not claimed: the refusal is asserted at the seam and at the helper, not through a live russh handshake. russh will not hand over a key that failed to parse, which is the reason the seam exists.
S-030 — Five swallowed errors, none logged or counted
Retitled 2026-08-03, from "Four swallowed errors, one of which leaks disk forever". The count is now five (a fifth appeared with S-006's
repack), and the "leaks disk forever" claim is false sincerepack()exists. Both are corrected in the re-check below. The original title is kept here because the finding is cited by it elsewhere.
-
Found: 2026-08-03
-
Status: DONE
-
Severity: correctness
-
Where:
crates/gunnar-cli/src/server/transport.rs:272-274·crates/gunnar-store/src/mem.rs:41-47·crates/gunnar-store/src/fs.rs:310,:324,:342·crates/gunnar-ssh/src/server.rs:391-397,:494-495 -
What:
transport.rs:272,let _ = std::fs::remove_file(keep);. If removing the.keepfile fails, the pack stays permanently "kept" and no latergccan reclaim it. At millions of pushes this is a monotonic disk leak with no log line and no counter. The worst of the four.mem.rs:41,self.objects.read().map(|o| o.len()).unwrap_or(0). A poisoned lock, meaning another thread panicked mid-mutation and the store's invariants may be broken, is reported as "the store is empty". A storage fault that reads as an empty store is the exact failure modedoc.rs:99-104correctly refuses to allow for the auth table.fs.rs:310, 324, 342,from_gix_target(actual).ok()insidemap_edit_error. If the observed value cannot be decoded,Error::RefCas { actual: None }says "nothing was there" instead of "there was something we could not read", losing the diagnosis on the concurrent-push rejection path, which is where it is needed.server.rs:391-397, 494-495,let _ =on session teardown. Best-effort teardown is defensible, but a systemic failure to close sessions (file-descriptor exhaustion, S-010) would be completely silent. Not a finding:bus.rs:96'slet _ = self.tx.send(...)is deliberate and documented. Publishing must not fail because nobody is watching.
-
Fix: Log and count every one of them. The
.keepfailure needs a retry list or at minimum anerror!plus a counter so the leak is visible.mem.rsshould propagate poisoning as an error, matching howarrow_log.rs:261-265already handles it.fs.rsshould carry the decode failure into the error rather than dropping it. -
Verify: A test that a failing
.keepremoval produces a logged error and increments a counter. Seen RED today, where it produces nothing observable at all. -
Re-checked 2026-08-03. The class is live; the headline is now WRONG and is corrected here rather than left to mislead:
- "one of which leaks disk forever" no longer holds. That claim rested on "no later
gccan reclaim it", which was true whenrepack()did not exist. It does now (S-006).crates/gunnar-store/src/repack.rs:575-600snapshots packs by their.idxand does not skip a pack carrying a.keep;PackFiles::all()(repack.rs:186-190) includes the sidecars, andrepack.rs:390-392unlinks all of them. A leaked.keepis therefore collected by the nextgunnar gc, and what the swallowed error actually costs is a stale marker, not unreclaimable disk. Still a silent failure with no log and no counter, still worth fixing, but not the worst of the four any more, and nothing about it is monotonic. - A fifth instance has appeared, in the code that closed S-006:
crates/gunnar-store/src/repack.rs:369-371,let _ = std::fs::remove_file(keep_file);on the new pack's own.keep. Same swallow, and this one matters more than the original: that.keepis what guarded the freshly written pack while the old tier was still being read, so one left behind is a marker on the live tier. - Line numbers have drifted; current sites are
crates/gunnar-cli/src/server/transport.rs:318·crates/gunnar-store/src/mem.rs:45·crates/gunnar-store/src/fs.rs:490and:510(two, not the three the entry lists;map_edit_error'sMustExistarm already passesactual: Nonehonestly because gix reports no observed value there) ·crates/gunnar-store/src/repack.rs:370·crates/gunnar-ssh/src/server.rs:318, 569, 788. - Also stale: the comment at
transport.rs:314-316says "whengunnar gclearns to repack, this is where it stops". It has learned; the comment should say what is true now, which is that dropping the.keephere is what makes the pack eligible.
- "one of which leaks disk forever" no longer holds. That claim rested on "no later
-
Ready to apply.
gunnar-cli,gunnar-storeandgunnar-sshwere all held this session, so nothing was edited.transport.rs:318andrepack.rs:370: one shared helper, not two copies (LAW 5).fn drop_keep(path: &Path)that does the removal and on error emitstracing::error!(keep = %path.display(), %err, "the .keep marker could not be removed"). It belongs ingunnar-storebesiderepack, withgunnar-clicalling it.mem.rs:45.len()must not answer0for a poisoned lock. Matcharrow_log.rs:261-265, which already has the right shape in this crate: recover or propagate, never report an empty store.len()/is_empty()returnusize/booltoday, so either they change signature or they recover the guard withunwrap_or_else(|p| p.into_inner())asstores.rsnow does for its cache. The second is a one-line change and still ends the "a storage fault reads as an empty store" failure.fs.rs:490, 510: carry the decode failure instead of dropping it.Error::RefCasneeds a way to say "there was something we could not read" that is distinct fromactual: None; the smallest honest change is to make the field carry that third state rather than collapsing it.server.rs:318, 569, 788: teardown stays best-effort, but atdebug!with the error, so a systemic close failure is findable. The RED: for (1), point the helper at a path inside a directory made read-only and assert theerror!was emitted, with atracingsubscriber capturing events, not eyeballs. For (2), poison the lock from a panicking thread and assertlen()does not answer0while the store holds objects. That is the exact wrong answer, and asserting it is what makes the test able to fail.
-
Landed 2026-08-03: the two
gunnar-storesites, and a sixth the audit did not list. Three of the six remain, in crates another agent held while this ran; they are named at the end so the next agent does not have to re-derive which.
1. mem.rs: a poisoned lock no longer reads as an empty store. len() was .map(|o| o.len()).unwrap_or(0) and contains() was .map(|o| o.contains_key(id)).unwrap_or(false). Both are now recovered guards (unwrap_or_else(|poison| poison.into_inner())), matching what arrow_log.rs already does in this crate. Sound for the reason stated at the function: RwLock poisoning is raised by a panicking writer, and what the lock protects is a map of complete objects, so there is no half-inserted object to observe. contains was not in the audit's list of five and is the worse of the two: false tells connectivity that a reachable object is missing and tells a fetch that the client does not have something it does. Found while fixing the one beside it.
2. repack.rs, the .keep swallow, as ONE writer for both call sites. pub fn drop_keep(path) in gunnar-store, exported from the crate root, emitting error! on gunnar::store with the marker's path, and silent on NotFound, because already-gone is the outcome it wants. It does not return a Result, and the reason is written at the function: a surviving .keep is a stale marker, not lost data and not a lost repack, since PackFiles::all does not skip a pack that carries one. Failing a finished repack over it would turn a cosmetic leftover into an operator-visible error.
SEEN RED, and the first red probe was itself blind, which is worth recording.
mem.rs: with both pre-fix bodies restored,a_poisoned_lock_does_not_report_an_empty_storefails with
assertion failed: a poisoned lock reported an EMPTY store; the object is stillthere — left: 0, right: 1
The assertion is on the wrong answer, not on an error type: 0 and false are shaped exactly like a correct answer about an empty store, which is the whole finding. the_fallible_paths_still_report_the_fault holds the other side: read must keep returning Error::backend on a poisoned lock, or a recover-everywhere fix would look identical from the outside.
drop_keep: the first probe put the swallow before the match and left the logging reachable, so the suite stayed green against a broken function. That is this file's own disease, in a probe written while fixing it. The probe was corrected to replace the body outright, and then:
a .keep that could not be removed produced 0 lines, not one. All events: []
The failure is forced by a real EACCES, a marker inside a directory made read-only, rather than by a stub, so it is the error the production call sites actually discarded. removing_a_keep_that_is_there_is_silent_and_actually_removes_it is what stops a drop_keep that logs unconditionally from passing, which would put an error line in an operator's journal on every successful push.
tracing-subscriber is a new dev-dependency of gunnar-store, invisible to cargo tree -e normal and so unable to reach the appliance graph S-007 guards. cargo test -p gunnar-store --features fs: 80 passed, 0 failed.
- Closed: 2026-08-03, second pass. The remaining three sites, in a detached worktree off
origin/master.
3. transport.rs: the .keep swallow now calls the shared writer. gunnar_store::drop_keep(keep), one line, so both places that retire a .keep go through one function instead of two copies kept in step by hand (LAW 5). Verified as a property rather than by reading the diff: grep remove_file … | grep -i keep over gunnar-cli/src and gunnar-store/src now returns no second writer, only the two drop_keep call sites and its definition. The stale comment the re-check named, "when gunnar gc learns to repack, this is where it stops", is replaced with what is true now: gc does repack, and dropping the marker here is what makes the pack eligible.
4. fs.rs. The dropped decode failure, fixed by giving the error a third state. Error::RefCas's actual was Option<Target>, filled with from_gix_target(actual).ok(), so "there was something I could not read" arrived as None, the same value as "nothing was there", on the concurrent-push rejection path, which is exactly where the difference matters. It is now Observed { Nothing, Value(Target), Unreadable(String) }, with a Display that says which, and the reason carried rather than dropped.
The finding's own count was off by one and the correction is in the code: it lists map_prepare_error's three .ok() sites, and there are two. The third line it counted is the MustExist arm, which passes Nothing honestly because gix reports no observed value there. That is now written at the arm so nobody "fixes" it again.
SEEN RED, on a reachable value rather than a forged one. MERGE_HEAD is a legal gix FullName and is legal nowhere in gunnar_store::refs, which admits HEAD and refs/… only, so a symbolic target pointing at it decodes in gix and not here. With observed_from_gix put back to from_gix_target(target).ok().into():
a reference that could not be decoded was reported as nothing
The test asserts the premise first (from_gix_target(&undecodable).is_err()), so if a future gix or a future validate() closes that gap the test says so instead of quietly exercising the decodable path. It also asserts the ordinary Object(id) case still comes back as Value, without which an observed_from_gix that answered Unreadable to everything would report every lost push as a corrupt repository.
5. server.rs. Teardown stays best-effort and stops being silent. note_teardown(step, result) emits debug! on gunnar::ssh with the step and the error. debug! and not warn! on purpose: one failed close is the ordinary case of a peer that hung up first, a thousand is descriptor exhaustion, and what tells them apart is a count in a journal rather than a severity on a line that fires constantly.
- Re-enumerated 2026-08-03, and the entry's site list was incomplete AGAIN. A sweep of
let _ =over everycrates/*/src(29 hits) found five more of this shape outside the six already closed. Two are genuine and are fixed here; three are not, and saying so is the point of re-enumerating rather than fixing a list.
Genuine, fixed.
gunnar-lfs/src/fs_store.rs,FsPendingWrite::drop'slet _ = std::fs::remove_file(temp). This is the one with no backstop, and it is worse than the.keepswallows this entry is named for. The.keepcases cost a stale marker, becausePackFiles::allcollects a pack that carries one; the repack scratch cases cost nothing, becausesweep_stale_scratchretries. Nothing in the tree ever looks atlfs/incomplete/— grep finds onlyopen_or_initcreating it and one test asserting it is empty. So a rejected or severed upload whose temp file cannot be unlinked stays on disk at full size, under a name no oid resolves to, silently and monotonically. Nowdrop_incomplete,error!ongunnar.lfs, silent onNotFound, still infallible because it runs inDrop. Notgunnar_store::drop_keep, and the reason is written at the function: reaching it would makegunnar-lfsdepend ongunnar-store, against that crate's stated dependency budget ofgunnar-pktline+gix-hash+thiserror+tracing. Six lines is a poor price for a storage crate. SEEN RED with the swallow restored outright (not moved, which is the mistake thedrop_keepprobe made in this entry's own second pass):
an abandoned LFS upload that could not be removed produced 0 lines, not one.All events: []
Forced by a real EACCES — the marker inside a directory made read-only — and the test asserts the premise first (temp_files == 1, the file really did survive), so on a machine where the removal succeeds anyway it fails saying so instead of passing having proved nothing. A companion asserts the ordinary cancelled upload is silent, so a drop_incomplete that logged unconditionally cannot pass.
sync_direxisted TWICE, inrepack.rsandmidx.rs, and the two had already diverged.repack's returnedErron an unexpectedopenfailure;midx's dropped it. Same directory (objects/pack), same durability primitive, opposite answers to the same fault, which is the twin LAW 5 forbids and the divergence was real rather than hypothetical. Fixed by construction: onepub(crate) sync_dirinrepack,midxcalls it, the second copy is gone rather than watched. The swallowedsync_allinside it keeps its behaviour — a filesystem that refuses to fsync a directory handle is a durability weakening, not a correctness one, and is not worth failing a repack over — but now emitsdebug!, because a systemic one means every rename in the tree is unordered against a power cut and nothing else would ever mention it.
Named by the audit and NOT of this shape, with the reason, so the next agent does not "fix" them:
repack.rs'sprune_empty_loose_dirs:remove_diron a fan-out directory that may legitimately still hold objects. Failing is the normal case, and its doc comment already says nothing depends on it being gone. Logging it would fire on almost every repack.repack.rs'ssweep_stale_scratchandDrop for Scratch: best-effort removal of a file whose backstop is the other one of the pair. A leftover is swept by the next run.midx.rs:153'slet _ = remove_file(&scratch): a pre-clean beforeFile::create, which truncates anyway and which reports the failure itself on the very next line. The swallow is unreachable as a silent failure.
What is left of the class, and not claimed: nothing found in this sweep. The six sites from the two passes above plus these two are all of them; bus.rs:96 remains the documented non-finding it always was. RED, with the body replaced by the swallow:
assertion failed: expected exactly one line — the failure — got []
Both directions are asserted in one test, because a note_teardown that logged unconditionally would put a line in an operator's journal on every clean disconnect and destroy the signal it exists to give. The first red probe for this one was worthless and was replaced: it made the helper unwrap() a None, so the test failed on a panic inside the probe rather than on the assertion: green-looking evidence that proved only that the function was called.
Two of the entry's server.rs line numbers are not swallows and are deliberately left alone. let _ = handle_tx.send(running.handle()) is a oneshot to a receiver three lines below that does handle the failure, with a message about the listener ending before it accepted. Reporting it twice would be noise.
tracing-subscriber is now a dev-dependency of gunnar-ssh as well as gunnar-store, for the same reason and with the same property: invisible to cargo tree -e normal, so it cannot reach the appliance graph S-007 guards.
Six sites, not five. contains() in mem.rs was found while fixing the one beside it, and MustExist turned out not to be one at all. The count in the title is left as it was, because the entry is cited by it.
- Independently re-enumerated 2026-08-04, and this time the entry's account HELD. A fresh sweep of
let _ =over everycrates/*/src— 31 hits — was read without consulting the lists above, then compared to them. Recorded because "the site list is incomplete" had been true of this entry three times running, and a fourth sweep that found nothing new is worth as much as the ones that did.
The five sites named as outstanding are all handled at master, verified at the code rather than from the entry: gunnar-lfs/src/fs_store.rs now calls drop_incomplete; sync_dir is one pub(crate) function at repack.rs:993 with midx.rs:191 calling it, so the twin is gone by construction rather than watched; and midx.rs:156 is the pre-clean before File::create, which truncates anyway and reports on the next line — not a silent failure, as the entry says.
Four remaining candidates were read and are NOT of this shape, named so the next sweep does not re-derive them:
gunnar-client/src/objects.rs:84,let _ = peel_root(…). Ahavethat does not peel is simply not a negotiation boundary; the cost of missing one is extra transfer, never a wrong answer, and the comment says so.gunnar-client/src/fetch.rs:234,committer_or_set_generic_fallback. A failure here surfaces at the fetch's own ref update — loudly, and by the very path the line exists to protect. Not silent.gunnar-store/src/odb.rs:240,Exists::existson the null oid. A guaranteed miss taken for its scan side effect; the result is meaningless by construction.gunnar-ssh/src/command.rs:485is inside#[cfg(test)].
Two genuine sites WERE found, both outside every crate this entry ever looked at, and both are gunnar-coldtier, which another lane held: unfold.rs:107 and lib.rs:496 are a third and fourth copy of repack::sync_dir's body — the same directory fsync, the same swallow, and not the debug! that copy was given when this entry deduplicated the first two. Filed as S-049 rather than fixed here.
S-031 — deepen-since / deepen-not / deepen-relative were never run against stock git
- Found: 2026-08-03
- Status: TODO
- Severity: correctness
- Where:
crates/gunnar-wire/src/shallow.rs(implementation) ·crates/gunnar-wire/tests/real_git_interop.rsandcrates/gunnar-cli/tests/ssh_end_to_end.rs(nodeepencoverage; grep finds none) - What: The three deepening variants have unit-test coverage inside the crate. Grep across the interop suites finds no
deepenat all: only--depthwas ever exercised against real git. The distinction between a shallow boundary and a parent restriction is subtle enough thatgraph.rs:150-154documents it as "a bug worth naming", precisely the kind of thing a unit test written from the same understanding as the implementation will confirm rather than challenge. - Why it matters at load: Shallow and partial fetches are what CI systems use, so at forge scale they are a large fraction of all traffic. A protocol disagreement with stock git here shows up as failing builds at customers, not as a server error.
- Fix: Extend the interop suite: for each of
--shallow-since,--shallow-excludeand--deepen, run stock git against gunnar and against a stock git server over the same fixture, and assert the same set of objects arrives. Comparing against another implementation is the only assertion that can disagree with the implementation's own assumptions. - Verify: Build the fixture so the three variants give three different answers (a merge with one side excluded is the case
graph.rs:150-154warns about). If the three tests pass against a stubbed-out deepening that ignores the argument, the fixture is wrong. Check that first, and see it RED. - Re-confirmed 2026-08-03, unchanged.
grep -rn 'deepen\|shallow-since\|shallow-exclude'overcrates/gunnar-wire/tests/andcrates/gunnar-cli/tests/finds the three arguments in exactly two places, both of them unit tests written from the same understanding as the implementation:crates/gunnar-wire/tests/protocol.rs:373(deepen_since_cuts_on_the_committer_date) and:392(deepen_not_cuts_at_a_named_reference).crates/gunnar-wire/tests/advertisement.rs:81-82, 114only asserts the arguments are advertised, which is the weakest possible statement about them.real_git_interop.rsandssh_end_to_end.rscontain nodeepenat all, so nothing here has ever disagreed with stock git.deepen-relativehas no test of any kind. - Ready to apply.
gunnar-wirewas under active edit by another agent this session (its tree did not compile at the time of writing), so nothing was touched. The work is three cases incrates/gunnar-wire/tests/real_git_interop.rs, alongside the existing--depthcase:git clone --shallow-since=<t>,git clone --shallow-exclude=<ref>, andgit fetch --deepen=<n>on an already-shallow clone (which is the only way to exercisedeepen-relative). The assertion must be differential, not self-consistent. Run each against gunnar and against a stockgit upload-packover the same fixture and compare the set of object ids that arrived (git rev-list --objects --allin the two clones), plus.git/shallowverbatim. A test that asserts gunnar returned what gunnar's own shallow logic computes is the trap this entry exists for. The fixture is where this is won or lost. Build a merge with one side excluded, the casecrates/gunnar-wire/src/graph.rs:150-154names as "a bug worth naming", so that--depth,--shallow-sinceand--shallow-excludegive three different object sets. Then check the fixture before trusting the tests: stub the deepening so it ignores its argument, and confirm all three go RED. If any stays green the fixture is degenerate and the suite proves nothing, which is the same failure as having no suite. - Closed:
S-041 — PushGate is handed no new-object set and no pack byte count
-
Found: 2026-08-03
-
Status: DONE
-
Severity: correctness
-
Where:
crates/gunnar-wire/src/receive_pack.rs:92-99(thePushGate::checksignature) · the workaround incrates/gunnar-cli/src/server/transport.rs -
What: MEASURED 2026-08-03, exposed when
gunnar-cliwired the real gate.PushGate::checkreceives a&RefCommandand a&dyn ObjectStoreand nothing else: no set of objects this push actually introduced, and no count of bytes the pack carried. Sogunnar-clirecovers the object list by reading back the written.idx, which over-approximates: a thin pack's resolved bases land in the index too, so objects the client already had are attributed to this push.pack_bytesis left asNonebecause nothing measured it. -
Why it matters at load:
gunnar-policy's quota and size gates (crates/gunnar-policy/src/gates/limits.rs) are the things standing between a forge and a client pushing until the disk fills. A gate fed an over-approximated object set rejects pushes it should accept; a gate fedNonefor the byte count cannot enforce a size limit at all. The §2 correction atTOTAL-PROMPT.md:1060already narrowed what these gates can promise; this is why. Reading the.idxback is also an extra full index read on every push. -
Fix: Widen the seam rather than working around it.
receive-packknows exactly which objects the pack introduced (IndexedPackcarries the count, and the connectivity walk already computes the new set) and exactly how many bytes it read. Pass both toPushGate::checkas aPushFactsstruct, so future fields do not re-break the signature, and delete the.idxread-back. -
Verify: A test that pushes a thin pack whose bases already exist and asserts the gate sees only the genuinely new objects. Seen RED first; today the bases are included. And a size-limit test asserting a push over the limit is refused, which cannot even be written today because
pack_bytesisNone; that inability is the RED. -
Re-confirmed 2026-08-03, and one part of it has been quietly worked around rather than fixed, which is worth naming:
PushGate::check(crates/gunnar-wire/src/receive_pack.rs:92-99) still takes exactly(&RefCommand, &dyn ObjectStore). The seam is unchanged.- The
.idxread-back is still there:crates/gunnar-cli/src/server/transport.rs:294-311runsindex_thin_pack, thenpack_object_ids(index, hash_kind)over the written index, and stuffs the result into anArc<Mutex<Vec<ObjectId>>>thatPolicyGatereads attransport.rs:768-776. It still over-approximates a thin pack's bases, for the reason the entry gives. - What has been added since the audit is an honest failure path, not a fix:
unknown_objects(transport.rs:288) carries the reason the object list could not be produced, and the gate refuses the push naming it rather than deciding on an empty set (transport.rs:766-772). That is right, and it should survive whatever replaces it, but it makes the over-approximation less visible, not less real. pack_bytesis now measured on the attestation path (transport.rs:465reads it offoutcome.pack,:502publishes it asnew_object_bytes) and is still not handed to the gate:PushContext::new(transport.rs:770-777) takes principal, repo, updates, new objects, store and keys, and no byte count. So the size gate still cannot be written, which is the second half of the RED this entry asks for.
-
Ready to apply.
gunnar-wire,gunnar-cliandgunnar-policywere all held this session. The change is the one the Fix clause names, and it should be done asPushFactsso the next field does not re-break the signature:- In
gunnar-wire:pub struct PushFacts<'a> { pub new_objects: &'a [ObjectId], pub pack_bytes: Option<u64>, pub objects: &'a dyn ObjectStore }andfn check(&self, command: &RefCommand, facts: &PushFacts<'_>) -> Result<(), String>.AllowAll(receive_pack.rs:105-113) updates with it. - Fill it from where the truth already is, not from the
.idx:IndexedPackcarries the entry count andreceive_packknows the bytes it read, and S-005's rewritten connectivity walk already computes the genuinely-new set. That walk is the correct source, and reusing it is what stops this being a third enumeration of the same thing (LAW 5). Deletepack_object_idsand theArc<Mutex<Vec<ObjectId>>>plumbing. - Keep
unknown_objects' refuse-on-undecidable behaviour, moved ontoPushFacts. The RED, both halves, and neither is optional: (a) push a thin pack whose bases already exist in the repository and assert the gate observes only the new objects. Today it sees the bases too, so the count assertion fails before the change; assert the exact set, not the size, or a resolved base swapping for a new object would pass. (b) a size-limit test: configurelimits.rs's byte cap below the pack and assert the push is refused. Today that test cannot be written at all becausepack_bytesnever reaches the gate. Write it first and watch it fail to express itself; that inability is the RED, and it is the thing standing between a forge and a client pushing until the disk fills.
- In
-
Closed: 2026-08-03,
PushFactsas the Fix clause named, in a detached worktree offorigin/master.
The seam. PushGate::check(&RefCommand, &dyn ObjectStore) is now check(&RefCommand, &PushFacts<'_>), and PushFacts carries the store, the genuinely-new object set and the byte count. AllowAll and the two test gates (protocol.rs's RefuseEverything, real_git_interop.rs's RefuseThese) moved with it; four implementations in the tree, which is why a struct rather than a wider tuple — the next fact does not re-break them.
1. The object set is now exact, not over-approximated, and the subtraction is done where the answer exists. The .idx read-back is unchanged and still over-counts on its own; what changed is that the thin-pack base lookup is wrapped. index_thin_pack is handed RecordingBases, which delegates to GixObjects and records every id it is asked for and finds — by definition an object the repository already held, since gix consults it only for a delta base that is not inside the pack. Then introduced = everything in the .idx − the bases we handed back. That needed no second enumeration of anything (LAW 5) and no change in gunnar-pack, which another lane held.
2. pack_bytes is counted off the wire, by ReceivePack::serve itself. A Counting BufRead wraps the stream for the duration of ingest, so the number is true by construction: it is not IndexedPack's business and not the backend's, and the only code that sees every byte go past is the code that hands the stream over. Deliberately not the size of the file that landed — completing a thin pack writes bases the client never sent, and a store with no filesystem has no file to measure. A deletions-only push reports Some(0), a measured zero, so a configured byte limit refuses an oversized pack rather than a git push --delete.
3. The Arc<Mutex<Vec<ObjectId>>> + Arc<Mutex<Option<String>>> side channel is gone. PackIngest::ingest returns a PackReceipt — the indexed pack and Result<Vec<ObjectId>, String> — so the facts travel on the return path instead of through two locks the gate had to know to read. unknown_objects' refuse-on-undecidable behaviour survives, moved onto PushFacts::new_objects, whose Err arm is the reason it is a Result and not an Option: handing an empty slice for "I do not know" is exactly the shape that lets a configured limit be silently satisfied. The blanket closure impl of PackIngest (which most of gunnar-wire's own tests use) returns PackReceipt::without_object_set, i.e. the refusing default, not an empty set.
SEEN RED, three probes, each watched failing for its stated reason (crates/gunnar-cli/tests/push_facts.rs, real git push over real SSH):
- Subtraction removed, the pre-fix over-approximation restored →
the gate was shown a different object set than the push introduced. Extra (basesthe repository already held, attributed to this push):["705ea49b57af308ab5ef36727f16fb094ed9719f33911b7e1eb48e2e6db7ee5c"]. Missing: []
Exactly one extra id, and it is the first commit's blob — the delta base git did not send. Ground truth is git rev-list --objects HEAD --not HEAD^ on the client, not anything gunnar computes.
with_pack_bytesremoved → both byte tests fail, with the pre-fix message the entry predicted:
! [remote rejected] main -> main ([limits] a 512-byte packfile limit isconfigured but the pack size was not measured, so the limit cannot be enforced)
That is the entry's "a size-limit test cannot even be written today", made visible. The over-limit test asserts !told.contains("was not measured") precisely so it cannot pass on that refusal, and a companion test asserts a push under the limit still lands and the reference actually moved, so a gate that refuses whenever a limit is configured fails too.
pack_bytesmultiplied by ten → the cross-check fails:
the gate was told the pack was 77770 bytes, but the pack gunnar wrote for thesame push is 7777 bytes. Those are not measurements of the same pack.
Written because "packfile is N bytes" with a constant, a zero or the uncompressed file size would satisfy an assertion that only looked for the phrase.
The vacuity trap this file was most exposed to, and what closes it. If git sends a self-contained pack, no base is pulled, the subtraction removes nothing, and probe 1 cannot tell the fix from its absence. So the fixture writes a ~117 KB compressible file and edits one line of it, and the test fails — not skips (S-026) — unless the pack gunnar stored holds strictly more object ids than the push introduced. That inequality is the evidence a base was pulled in. Measured: 4 ids stored, 3 introduced.
cargo test --workspace --all-features: green apart from gunnar-wire's pack_copy::a_client_without_ofs_delta_gets_a_pack_it_can_read and ::a_clone_of_a_packed_repository_copies_entries_instead_of_recompressing_them, which fail identically at origin/master (e4d8a78) in a clean worktree — the regression another lane holds, not this change, and checked rather than assumed.
S-042 — Nested-namespace repos are clonable over SSH and invisible to gRPC
-
Found: 2026-08-03
-
Status: DONE
-
Severity: correctness
-
Where:
crates/gunnar-proto/src/lib.rs(RepoPath::validate) ·crates/gunnar-core/src/catalog.rs:118-139(create_namespaceacceptsteam/sub) · D19 -
What: MEASURED 2026-08-03. D19 makes nested namespaces first-class and
RepoCatalog::create_namespaceexplicitly validates each/-separated level, soteam/sub/repo.gitis legal over SSH and is served.gunnar-proto'sRepoPath::validaterejects/in a namespace. The two halves disagree about what a legal path is. -
Why it matters at load: Such repositories exist, are pushable and clonable, and are invisible to every gRPC consumer: dwarves, korp, any UI. They cannot be listed, have their access changed, or be deleted through the control plane. A tenant's repositories silently missing from the admin surface is a security-relevant divergence, not a cosmetic one: an operator auditing grants through gRPC gets an incomplete answer and has no signal that it is incomplete.
-
Fix: Make
RepoPath::validateaccept the same nested formgunnar-core's sandbox accepts, including theMAX_COMPONENTSbound, and make it call the same validator rather than reimplementing it (LAW 5; the divergence is exactly what two copies of a rule produce). If instead nested namespaces are to be dropped,gunnar-coremust refuse them at creation, so the two ends still agree. -
Verify: A test that creates
team/sub/repo, then assertsRepos.Listover gRPC returns it. Seen RED first. Add a cross-check test that iterates every path the sandbox accepts and assertsRepoPath::validateaccepts it too, a property test that cannot be satisfied by patching one example. -
Closed: 2026-08-03, uncommitted in the working tree:
crates/gunnar-proto/src/lib.rs(RepoPath::parse/::validate, newMAX_COMPONENTSandRepoPathError::TooManyComponents),crates/gunnar-proto/Cargo.toml(a dev-dependency ongunnar-core),crates/gunnar-proto/tests/nested_namespace.rs(new),crates/gunnar-proto/tests/surface.rs(two rows moved from "rejected" to "accepted", which is the finding).
Seen RED first, on assertions and not on a compile error. The constant and the error variant were added inert, so the new suite ran against the old validator: 3 of 4 tests failed, the load-bearing one with
the sandbox serves
"team/sub/repo.git"asteam/sub/repo, but the control plane refuses to name it: repository path must be exactly<namespace>/<repo>.
The fix is one line of structure and one of policy. parse splits at the last / (rsplit_once) instead of demanding exactly two segments; validate walks the namespace's levels plus the repository name, applies the same per-segment rules to each, refuses a / in the repository half (one component by definition), refuses an empty level, and bounds the total at MAX_COMPONENTS. Content is checked before the count, matching gunnar_core::sandbox, so a traversal is still reported as a traversal.
Nothing else had to change, and that is the measured part. The gRPC choke point is gunnar_grpc::status::repo_path → RepoPath::validate, and the one control-plane entry point is CoreControlPlane::repo_ref, which is path.validate() followed by RepoRef::new, the real sandbox. So the only thing that had ever rejected a nested path was this shape check, and Repos.List already emitted them (control_plane.rs:172, RepoPath::new(&repo.namespace, &repo.name) off a RepoRef that preserves nesting). Green after: 17/17 gunnar-proto, 52/52 gunnar-grpc.
On LAW 5: the rule is still stated twice, deliberately, and the sharing is a test. The entry asks for one validator called from both sides. It is not reachable: gunnar-proto is a leaf that dwarves and korp link for the control-plane types alone, and gunnar_core::sandbox arrives behind gunnar-store and gunnar-pack, and neither crate can take the other as a normal dependency without putting a storage stack in a client or a tonic stack in the appliance's model layer. So the second-best construction: tests/nested_namespace.rs takes gunnar-core as a dev-dependency (invisible to cargo tree -e normal, so feature_split.rs's appliance guard is unaffected) and reads gunnar_core::sandbox::red::LEGITIMATE, the sandbox's own corpus, asserting every path it accepts is nameable over gRPC. It re-derives each row through resolve_repo_path rather than trusting the table, refuses to run unless the corpus holds at least two nested rows (or it would pass against the validator that rejects all of them), and pins MAX_COMPONENTS against the real gunnar_core::sandbox::MAX_COMPONENTS so the one restated constant cannot drift silently. That guard was itself proved able to fail, because it was green from the moment it was written and a green never seen red is worth nothing: setting gunnar-proto's MAX_COMPONENTS to 5 turns the_component_bound_is_the_sandboxs_and_not_a_second_number and accepting_nesting_did_not_open_the_structural_escapes red: two independent detectors of the same drift, the second one because a/b/c/d/e becomes an accept.
Also asserted, because widening a validator is how an escape gets in: six rows from the sandbox's ESCAPES table that this change could plausibly have turned into accepts (a/b/c/d/e, ns//repo, ns/../../etc/passwd, ns/repo/../../../root, ns/repo/../other, ns/..\..\etc) are still refused, each for the right reason, and the test first asserts each row really is in ESCAPES, which caught two rows invented for this test that were not.
Deliberately NOT changed: RepoPath stays laxer than the sandbox. It still accepts non-ASCII, over-long components and shell metacharacters, because it is the shape check at the edge and RepoRef::new is what refuses those one layer down. Making it stricter would recreate this bug from the other side. A leading / is still EmptyNamespace: that is the ssh:// URL form, the control plane never sends one, and the property test skips those rows explicitly rather than quietly.
Ready to apply, in a crate held by another agent: crates/gunnar-cli/src/server/control_plane.rs:28-32 still carries model-mismatch note 1, "RepoPath cannot express a nested namespace … That is a gunnar-proto change, not one to make here." It is now stale and should be deleted (and notes 2 and 3 renumbered). The end-to-end half of this entry's Verify, create team/sub/repo and assert Repos.List returns it, belongs in crates/gunnar-cli/tests/ and is not written; the property test above covers the rule, not the wiring.
S-043 — Policy has no store: Policy.* is Unimplemented and gates read a JSON file off disk
-
Found: 2026-08-03
-
Status: TODO
-
Severity: correctness
-
Where:
crates/gunnar-grpc/src/service/policy.rs(returnsUnimplemented) ·crates/gunnar-policy/src/config.rs(loadsgunnar-policy.jsonbeside each repo) ·crates/gunnar-core(no branch-protection or ref-name-policy store) -
What: MEASURED 2026-08-03.
gunnar-corehas nowhere to keep branch protection or ref-name policy, so the wholePolicy.*gRPC service isUnimplemented, and the gates instead load agunnar-policy.jsonsitting next to each repository on disk. -
Why it matters at load: Two separate problems.
- Branch protection is unmanageable. It cannot be read or set through the control plane, which is the only management surface gunnar has (§0: no web UI, no REST). An operator must have shell access to the server's filesystem to protect a branch. At forge scale, per-repository shell edits are not an administration model.
- It reads a file, in an
Scrate. §0's one-line test is "does this still work when there is no filesystem?"gunnar-policyis on theSlist, and the answer for its configuration path is no. On the tunnr appliance there is nogunnar-policy.jsonand no directory to put one in, so branch protection silently does not exist there. A gate that is absent rather than failing is the worst shape: pushes that should be refused are accepted, and nothing says so.
-
Fix: Policy belongs in the control tables next to the catalogue and the ACL, behind the same
ObjectStore/RefStoreseam, which means it inherits S-001/S-002's shape and should be built on whatever replaces them rather than adding a third whole-document blob. The JSON file becomes an import path for a normal-OS deployment, not the source of truth. Then implementPolicy.*against that store. -
Verify: A test on the memory backend (no filesystem at all) that sets a branch protection through the control plane and asserts a force-push is refused. Seen RED first; today it cannot even be expressed. Also assert that a repository with no policy configured refuses nothing, so the guard is not passing by refusing everything.
-
Re-confirmed 2026-08-03, unchanged. All five policy methods on the real control plane (
get_protection,set_protection,delete_protection,get_ref_name_policy,set_ref_name_policy,crates/gunnar-cli/src/server/control_plane.rs:730-765) open the repository and then returnControlError::Unimplemented(PROTECTION_GAP). That is the right shape for a gap (an explicit refusal, never an empty success), so the honesty of the surface is fine; what is missing is the store behind it.gunnar-policystill loadsgunnar-policy.jsonfrom beside each repository (crates/gunnar-policy/src/config.rs), andcrates/gunnar-cli/src/server/transport.rs:333-347logs atinfowhen a repository has no gates configured, saying in as many words that this is "an empty policy, not a passing one". That is the best available mitigation and still means the appliance, where no such file can exist, silently protects nothing. -
This is the largest of the correctness entries and is NOT a small change. It is a new control table with the same shape as the catalogue and the identity table, and it spans
gunnar-core(the store),gunnar-policy(read from it instead of the file),gunnar-cli(implement the five methods) andgunnar-grpc(nothing, if the seam holds). Four crates, all held this session. It should be taken as its own piece of work, not folded into a sweep. -
Ready to apply, in this order:
gunnar-core: aPolicyDocbehindrefs/gunnar/policy, built onDocRef+ShardedMap, the machinery S-001/S-002 already landed, keyed by the repository's catalog key. Do not add a third whole-document blob; that is the bug those two entries just closed, and the sharded map is right there. Branch protections and the ref-name policy are the two maps.gunnar-policy:config.rsreads from aPolicySourcetrait. The store is one implementation; the existinggunnar-policy.jsonreader becomes the import path for a normal-OS deployment, and stays, because operators have those files.gunnar-cli: the five methods above stop returningPROTECTION_GAPand read/write the store.PROTECTION_GAPand its constant go with them. The RED, and the second assertion is the one that matters: on the memory backend, no filesystem, which is §0's one-line test, set a branch protection through the control plane and assert a force-push is refused. Today that test cannot be expressed:set_protectionreturnsUnimplemented, so it fails at the first line, and that is the RED. Then assert a repository with no protection configured refuses nothing. Without it, a gate that refuses everything passes the first test perfectly, and a policy engine that refuses every push is not a working policy engine, it is an outage.
-
Closed:
smell
S-032 — design.md, storage-reasoning.md and both READMEs describe a design that was dropped
-
Found: 2026-08-03
-
Status: DONE
-
Severity: smell
-
Where:
.nornir/storage-reasoning.md(12redbmentions, including lines 106, 111-112, 118, 124, 146, 175, 301, 308, 322, 348, 351) ·.nornir/design.md:69,:124,:179·README.md:96·.nornir/README.md:102 -
What: MEASURED by grep, 2026-08-03. D18 dropped redb and replaced refs and secrets with an Arrow IPC log. Four documents still describe the redb design as current:
storage-reasoning.md:106gives redb as the ref answer in the backend table;:111-112states the default configuration asTieredObjectStore { hot: redb, cold: znippy }plusRedbRefStore;:118heads a whole section "Why objects are tiered: hot redb, cold znippy";:322listsredbas a conformance backend. It is not a stale sentence, it is the document's spine.design.md:69listsredbas one ofgunnar-store's backends;:179says the secret store is "backed by the same redb as the ref store".- Both READMEs list wave 9 as "redb hot tier, znippy cold tier,
repack()". design.mdalso still says "Status: early… one empty crate directory", against 27182 lines of Rust. For contrast,TOTAL-PROMPT.mdis current and carries the corrections.
-
Why it matters: An agent picking up
storage-reasoning.md, which is exactly what its class is for, will implement against a design that was deliberately abandoned. The.nornirdoc set is the handoff mechanism; a doc that contradicts the spec is worse than an absent one. -
Fix: Rewrite
storage-reasoning.mdaround the arrow log and the incoming-pack tier, keeping the redb section as an explicitly dated "why this was dropped (D18)". The reasoning about why a database between two archive formats was wrong is valuable and should not be deleted. Updatedesign.md's backend list, secret-store paragraph and status line. RegenerateREADME.mdfrom.nornir/README.md(never edit the root copy) with wave 9 restated. -
Verify:
grep -rn redb .nornir/ README.mdreturns only lines inside a dated "dropped" section. Run it before and after. -
Closed: 2026-08-03, verified by re-measurement, not by trust. Fixed by another agent between this finding being written and this file being saved, so the counts above are the before. After:
grep -c redb .nornir/design.md .nornir/storage-reasoning.md README.md .nornir/README.md→ 0 · 3 · 0 · 0. The three survivors instorage-reasoning.mdare exactly the wanted shape, all inside the dated## There is no database (D18)section at:142-144, the arrow-vs-redb dependency arithmetic at:162-164, and the one-writer-constraint comparison at:225. The abandoned design is now recorded as history rather than asserted as current, which is what this entry asked for. Not re-checked:design.md's "one empty crate directory" status line. The grep forStatus:in that file now returns nothing, so the line appears to have gone with the rewrite, but that was not independently confirmed. -
Re-measured 2026-08-03 during a status audit. The entry stays DONE, but its Verify clause no longer holds as written, and that is worth one line rather than a silent green. The clause is "
grep -rn redb .nornir/ README.mdreturns only lines inside a dated 'dropped' section". It does not:grep -c redbnow gives README.md 1 · .nornir/README.md 1 · design.md 0 · storage-reasoning.md 3 against the0 · 3 · 0 · 0the closure recorded. Both new hits are the same sentence, "skade's catalog is one redb file opened in-process", which is a comparison to a different project, not a claim about gunnar's storage, so the finding's substance is intact and nothing was reverted. What is wrong is the guard: a baregrep redbcannot tell gunnar's abandoned design from a neighbouring project's live one, so it will keep going false. Anyone re-running this clause should scope it to gunnar's own backend prose, or drop it and rely onstorage-reasoning.md's dated section headings.
S-033 — There is no CI system; cargo xtask ci is the gate and a human types it
-
Found: 2026-08-03
-
Status: WONTFIX
-
Severity: smell
-
Where: nowhere. There is no
.github/in this repository. -
What: There is no CI of any kind. §2 says "checked in CI" of the dependency direction, and "A CI guard asserts both sets from
cargo metadata". No such job exists, sofeature_split.rs(which does exist, see S-007) is only run when someone runs it. The workspace is at 484 tests green as of 2026-08-03, and nothing keeps it there. The missing benchmark harness is a separate and more serious item, S-044. -
Re-measured 2026-08-03, later the same day: a
.github/workflows/ci.ymlhas appeared. Its entire content is# placeholder, and that is a fullwidth number sign (U+FF03), not#, so it is not a YAML comment, it is a bare scalar, and the file does not parse as a workflow at all. This makes the finding worse, not better: the repository now looks like it has CI. A.github/workflows/directory is the first thing a reviewer checks and the last thing anyone re-reads. This is the silent-green shape applied to the build system itself: a guard that exists, runs nothing, and reports nothing. -
Why it matters: S-026's self-skipping tests will report green in the first CI image built without
gitinstalled. S-011's parallelism work cannot be justified or regression-guarded without a benchmark. S-007's guard is asserting something that is currently false and nobody is being told. -
Fix: A workflow that runs, on every push:
cargo test --workspace,cargo test -p gunnar-cli --test feature_split,cargo clippy -- -D warnings, and the interop suites on an image withgitandsshpresent (after S-026 makes their absence fatal). The benchmark job comes with S-044. -
Verify: Push a commit that deliberately adds a rayon dependency and see the pipeline go red; push one that removes
gitfrom the test image and see the interop job go red. Neither is true today. Before anything else: make the workflow file fail. Break a test on purpose and confirm the pipeline goes red. A placeholder that parses and runs zero jobs is indistinguishable from a passing build, and that is the state the repository is in right now. -
Status → IN-PROGRESS 2026-08-03. Two of this entry's three title clauses are now false and the third is unchanged. Corrected here rather than left standing:
- "is not valid YAML" is no longer true.
.github/workflows/ci.ymlparses.python3 -c "import yaml; print(yaml.safe_load(open('.github/workflows/ci.yml')))"yields adictwith keysname,True(theon:key, which PyYAML reads as the boolean it is in YAML 1.1),permissions,concurrency,env,jobs. The fullwidth#placeholder is gone. - The silent-green shape is gone too, which was the actual hazard. The file now opens with a banner reading
⚠ THIS WORKFLOW HAS NEVER RUN. NOT ONCE. IT IS THEREFORE UNVERIFIED., and its trigger isworkflow_dispatchonly, deliberately, so that merely pushing it cannot start spending Actions minutes on a private repo in an org Rickard does not work in. A reviewer who checks.github/workflows/now reads "not covered" instead of inferring "covered". - "runs nothing" is still true of the workflow, and now false of the repository.
cargo xtask ci(xtask/src/main.rs:227-273) is a complete gate:fmt --all --check→clippy --workspace --all-targets --features gunnar-store/fs -- -D warnings→test --workspace --features gunnar-store/fs→ the appliance build (-p gunnar-cli --no-default-features --features server) →task_guard(§2 dependency guard + the P-020 pack-pipeline guard), withtask_preflight()gating all of it. It needs nothing but cargo and reads noGITHUB_*variable. - The decision about what invokes it is written up in
.nornir/ci-options.mdand is Rickard's: pre-push hook now,dwarves run gunnar.runa.ronon oden for history, or GitHub Actions for real triggering. That doc measures the gate at ~5 min wall on oden (626 passed / 0 failed / 4 ignored) and says plainly that nothing is implemented.
- "is not valid YAML" is no longer true.
-
The RED, seen 2026-08-03, because this entry's own Verify clause demands one. The gate's first step is the one whose failure the audit cared about: S-026's self-skipping interop tests reporting green on an image with no
git. So that is the step that was made to fail, deliberately:
# green, normal PATH
cargo xtask preflight
# git git version 2.53.0
# ssh OpenSSH_10.2p1 …
# preflight: PASS — every external binary the §15 tests need is present (exit 0)
# red, a PATH containing ssh/ssh-keygen/cargo/rustc but NOT git
PATH=<stripped> cargo xtask preflight
# preflight: FAIL — the interop tests of §15 cannot be proven:
# git: No such file or directory (os error 2)
# Do NOT run the suite without them: the interop tests print `SKIPPED:` and pass,
# which is a green nobody has seen red. (exit 1)
task_ci returns ExitCode::FAILURE immediately on that (xtask/src/main.rs:260-262), so the whole pipeline goes red before it compiles anything. That is the exact scenario the entry names, "push one that removes git from the test image and see the interop job go red", proven against the gate that exists rather than against the workflow that has never run. What is not claimed: no GitHub Actions run has ever happened, so the workflow's own jobs remain unverified, and the other half of the Verify clause (add a rayon dependency, see the pipeline go red) was not exercised because it means editing a Cargo.toml while eight agents are in the tree.
- Re-measured 2026-08-05. Everything above this line describes a file that does not exist, and had not existed for two days when it was last read.
ls .github→ No such file or directory. The directory was added inb07ca38(200 lines) and deleted ine50b962, 2026-08-03 14:16 UTC — a commit whose own message says "Nothing a tool parses moved: only prose,descriptionstrings and#comments" while its diff is.github/workflows/ci.yml | 300 ---------. The deletion was collateral and unannounced.
Neither this entry nor .nornir/ci-options.md §4 noticed. This entry sat open, quoting a python invocation that loads .github/workflows/ci.yml and reporting its keys; §4 settled a whole decision about a file that was gone. Two documents agreeing with each other, both wrong, which is the failure mode the whole stinky.md mechanism exists to catch and did not — the record went stale about the record.
Nothing has been restored. A workflow that has never run is not evidence, and putting one back on release day would re-create exactly the "looks like it has CI" hazard this entry was filed for.
-
The decision, stated rather than left open:
cargo xtask ciis gunnar's CI, and a human types it. There is no other CI, and none is being added for this release. That is a real answer and not a placeholder, for three reasons:- The gate is complete and portable. One command runs fmt, clippy, preflight, the workspace suite, the four detached packages, the cold tier, the appliance build, the systemd feature check and the dependency guard. It reads no
GITHUB_*variable and assumes no runner image (xtask/src/main.rs). - Every hosted option available today would ship a green nobody has seen red. A
brokkr.tomlpipeline greenscontainersteps having executed nothing (ci-options.md§2); GitHub Actions has never executed once, so its jobs are unverified; the livedwarves-serveron oden dispatches toDemoRunner. Adopting any of them on release day would put the silent-green disease into the build system itself. - What made "run it by hand" untrustworthy was fixed, not argued away. Until 2026-08-05
cargo xtask cirun from a linked worktree printedNOT RUNfor theznippyandcoldtiersteps and returned SUCCESS, becausecrates/gunnar-coldtierreaches znippy by a relative path that resolves to the worktree's parent. Every lane ran from a worktree, so the covering command was quietly reporting a smaller suite than the same command in/home/rickard/git/gunnar— aNOT RUNreturningtrueinside the thing every quoted number comes from. It now refuses in that case, loudly, naming the main checkout to run in.
- The gate is complete and portable. One command runs fmt, clippy, preflight, the workspace suite, the four detached packages, the cold tier, the appliance build, the systemd feature check and the dependency guard. It reads no
-
What would close this as DONE rather than WONTFIX, if it is ever wanted: something that invokes
cargo xtask ciwithout being asked. The cheapest is apre-pushhook, five minutes of work and deliberately not installed here — a 5-minute gate on every push, with forty lane worktrees live, is a decision with a cost and it is Rickard's..nornir/ci-options.mdcosts the three options. -
Closed: 2026-08-05 as WONTFIX — the decision is made and written down above.
S-034 — A 250 ms sleep per completed transfer, in the request-completion path
- Found: 2026-08-03
- Status: TODO
- Severity: smell
- Where:
crates/gunnar-cli/src/server/transport.rs:148-185(linger) - What: Self-documented as "a workaround with a timing dependency and it does not belong here": a russh ordering race where
ChannelCloseOnDrop::dropcan beatexit_status_request. The workaround spawns a task per finished transfer that sleeps 250 ms before dropping the channel. Honest, and correctly labelled; still a timing dependency in the hot completion path. This is the mitigation for S-039, which is the underlying defect. It exists so the end-to-end test is honest rather than green-by-luck. Delete this workaround when S-039 lands. Leaving both in place would mean a fixed race still costing a task and a timer per transfer, and would hide a regression if the real fix were ever reverted. - Why it matters at load: At high transfer rates this is a task and a timer per completed transfer, each holding a channel handle for a quarter second after the work is done. At a thousand transfers per second that is 250 live lingering tasks and 250 undropped channels at steady state, a fixed tax that grows with throughput. And because it is a race, the 250 ms is a guess: under load it may be too short and the bug returns intermittently, which is the worst failure mode a timing workaround has.
- Fix: Fix the ordering rather than the timing: await the
exit_status_requestacknowledgement before dropping, or hold the channel in a structure whose drop order is explicit. If russh cannot express that, it is an upstream item and should be filed as one with a reproduction. - Verify: Remove the sleep and see the exit-status test go red, which pins that the race is real and that the replacement addresses it. Then run the transfer test under concurrency to confirm the ordering fix holds where a timer would not.
- Closed:
S-035 — The three znippy git indexes are unbuilt, and the stree key diverges from the spec
-
Found: 2026-08-03
-
Status: TODO
-
Severity: smell
-
Where: nothing in gunnar references them.
grepfor__gunnar_oid__,__gunnar_graph__,__gunnar_reach__,streeacrosscrates/*/srcfinds only prose atcrates/gunnar-wire/src/graph.rs:17,crates/gunnar-wire/src/upload_pack.rs:81andcrates/gunnar-policy/src/ancestry.rs:20 -
What: D7 puts all three reserved znippy modules in scope. None is wired into gunnar, and
znippy-zoomiesis not a dependency (grep -c znippy Cargo.lock→ 0). Three source files correctly point at them as the place a cost goes away, which is honest, but it means the design's answers to S-004, S-005 and S-013 all live in an unbuilt module.__gunnar_reach__is not unbuilt, it is unwired. It is built and tested in the znippy repository on branchfeat/git-package-format, which is pushed. gunnar does not consume it, so object exclusion stays O(client history). That distinction is now stated inREADME.md, so it is a commitment. Two known divergences to carry forward when it is wired:- the
__gunnar_oid__stree key in the znippy repo uses parallel row/ordinal arrays becausei64::from_be_bytesover oid bytes is not order-preserving. §4.1's corrected form, XOR the top bit,(u64::from_be_bytes(first8) ^ (1 << 63)) as i64, makes rank equal row and removes the arrays. Align before consuming it. - the znippy
feat/git-package-formatbranch's 111 tests were not independently verified; its worktree cannot resolve because of a pre-existing xtask→nornir→znippy-common lockfile collision. Treat that 111 as unverified.
- the
-
Why it matters: This is the largest single block of unbuilt design, and three blocker-severity findings above name it as their real fix. Recording it here keeps those three from being "fixed" with a workaround that has to be undone.
-
Fix: Sequenced behind S-006's cold tier:
__gunnar_oid__and__gunnar_graph__in wave 9b,__gunnar_reach__in its own wave. No speedup may be claimed for the reachability bitmaps until it is measured on a real repository with real merge history (§4.2, LAW 7). In scope is not the same as earned. -
Verify: For the oid index, a test that constructs a deliberate 8-byte-prefix collision and is seen to fail before the full-oid verify step is added. §4.1 says so explicitly and it is the one case the batch path can silently get wrong. Also test the empty index (
STree64::newassertscount > 0) as a clean miss, not a panic. -
Closed:
S-036 — Spec'd and unstarted: mirror replication, LFS
- Found: 2026-08-03
- Status: TODO
- Severity: smell
- Where: D15 / §11.1 (gunnar→gunnar replication to an external mirror) · §12 (LFS over pure SSH).
crates/gunnar-client/src/mirror.rsandcrates/gunnar-grpc/src/service/mirror.rsexist and are the client/control-plane halves; the server-to-server replication leg is not started. No LFS code exists. - What: Recorded so the gap is countable rather than discovered later. Both are decided features with no implementation.
- Why it matters at load: Replication is how a forge survives a machine, and neither the failover story nor the geo-distribution story exists. LFS is how a forge survives large binaries; without it every large file is a git object served whole and undeltaed (S-003).
- Fix: Own waves. Neither should be started before S-006 and S-011, because both multiply whatever the object layer costs.
- Verify: n/a until scoped.
- Closed:
S-037 — unreachable! on a clap verb match
-
Found: 2026-08-03
-
Status: DONE
-
Severity: smell
-
Where:
crates/gunnar-cli/src/service.rs:147 -
What:
other => unreachable!("unhandled service verb {other}"), the onlyunreachable!in the workspace, and the only panicking macro of its family (grep confirms zerotodo!/unimplemented!anywhere; the codebase uses typedControlError::Unimplemented/TransportError::Unimplementedvariants instead, which is the right pattern and worth keeping). It is reached only by adding aclapsubcommand variant and forgetting to handle it, so it is a compile-time-adjacent mistake turned into a runtime abort. -
Why it matters: Low. It is an operator-facing CLI verb, not a connection path. It is here because it is the single instance of the class, and a future variant addition is exactly when someone will trip it.
-
Fix: Return a typed error naming the verb, or restructure the match so the compiler proves exhaustiveness.
-
Verify: Adding a new
Service*variant must fail to compile, not compile and panic at runtime. -
No code changed 2026-08-03: this was already fixed, and the
TODOstatus field was simply wrong.service.rsmatches the enum with no wildcard in bothargs_ofandrun, and says so in a doc comment. Verified the way the Verify clause asks rather than by reading it: aReload(ServiceArgs)variant was added toServiceCommandand nothing else touched, andcargo check -p gunnar-cli --features systemdanswered
error[E0004]: non-exhaustive patterns: &ServiceCommand::Reload(_) not covered--> crates/gunnar-cli/src/service.rs:109:11error[E0004]: non-exhaustive patterns: ServiceCommand::Reload(_) not covered--> crates/gunnar-cli/src/service.rs:131:11
Two errors, both at compile time, which is the whole of the clause. The probe variant was then removed. No unreachable! remains in any src/; the six hits in the workspace are all in tests/ and examples/.
- Closed: 2026-08-03
S-038 — Three upstream nornir-git items forcing duplication here
-
Found: 2026-08-03
-
Status: DONE
-
Severity: smell
-
Where:
crates/gunnar-client/src/ssh.rs(the duplicated connector) ·crates/gunnar-client/src/porcelain.rs(the re-walked status) -
What: Recorded for the other repository, not to be fixed here (LAW 5 says share the code; the sharing has to happen upstream):
connect_upload_packbakesgit-upload-pack '<path>'in, so push needs its own copy, about 40 lines of connect / auth / exec /SyncIoBridge/ in-runtimeDropduplicated ingunnar-client/src/ssh.rs. Fix upstream:connect_exec(loc, key, cmd), withconnect_upload_packas a one-line call to it. The copy then deletes itself.gitio::porcelain_statusis private and its two public derivatives drop the per-path state astatusverb must print, forcing about 35 re-walked lines here. Fix upstream: make itpub.- The connector takes a key path, so
fetch_sshcannot usessh-agentwhile push can, an asymmetry with no reason behind it.
-
Why it matters: Three small upstream changes delete roughly 75 lines of twinned code in gunnar and remove a user-visible capability asymmetry. Until then the copies must be kept in step by hand, which LAW 5 exists to prevent.
-
Fix: File against
nornir-gitinedda; do not fix in gunnar. -
Verify: After the upstream release, the duplicated block in
gunnar-client/src/ssh.rsis deleted and the push interop test still passes. The deletion is the verification. -
Status changed to DONE 2026-08-03. All three items shipped upstream in
nornir-git 0.1.6, which is the version gunnar already resolves (Cargo.toml:134→nornir-git = "0.1.6"; confirmed in acargo checkline readingChecking nornir-git v0.1.6), and the twinned code in gunnar has already been deleted. Verified by reading the published crate source in the registry, not by trusting a changelog.~/.cargo/registry/src/index.crates.io-*/nornir-git-0.1.6/:connect_execexists atsrc/ssh.rs:789, andconnect_upload_pack(src/ssh.rs:759-760) is now literally a one-line call to it,connect_exec(loc, key_path, &upload_pack_command(&loc.path)?), which is exactly the shape this entry asked for.connect_exec_authandconnect_exec_withare the other two arities.porcelain_statusispubatsrc/gitio.rs:151, returning(PathBuf, Vec<StatusEntry>), the per-path state whose absence forced the re-walk, withporcelain_status_inbeside it at:162and an upstream testporcelain_status_carries_per_path_stateat:1270.- The key-path asymmetry is gone.
SshAuthcarriesuse_agent(src/ssh.rs:221) with constructorsSshAuth::agent()(:258),with_agent(:275) and a key-file-only form (:242); the agent is tried first and only when$SSH_AUTH_SOCKis set (:403).connect_upload_pack_auth(:765) andconnect_receive_pack_auth(:771) both take it, so fetch can now usessh-agentexactly as push can.
The verification this entry named is the deletion, and the deletion has happened. crates/gunnar-client/src/ssh.rs is 280 lines of delegation, not duplication: :170 calls nornir_git::ssh::connect_exec_with, :194 calls connect_exec_auth, :179 calls nornir_git::ssh::receive_pack_command, and :34/:41/:48 re-export SshAuth / HostKeyPolicy / SshChannel rather than restating them. Its own module doc at :21-22 names connect_exec and SshAuth as the reason the copy is gone. crates/gunnar-client/src/porcelain.rs:291 calls nornir_git::gitio::porcelain_status_in and its doc at :279 says in as many words that the walk used to be re-implemented here. Not claimed: the push interop suite was not re-run for this closure, because the deletion was landed by the agent holding gunnar-client and that crate's tests belong to that agent's own verification. What is asserted here is the narrow thing this entry tracked, the three upstream items and the twinned code, and all four are gone.
- Closed: 2026-08-03, by evidence, with no code written under this id. Read the published
nornir-git 0.1.6source for all three API changes (file:line above) and the currentgunnar-clientsource for the deletions.S-038was a tracking entry for another repository's work; that work shipped and gunnar consumed it.
S-045 — russh forces two C crates — the "no C toolchain" rule is not achievable
- Found: 2026-08-03
- Status: WONTFIX
- Severity: smell
- Where:
russh/src/lib.rs:90(a hardcompile_error!) · recorded atTOTAL-PROMPT.md:213-226andcrates/gunnar-ssh/src/lib.rs:111 - What: This is an accepted deviation, not a bug. It has an entry so nobody rediscovers it and files it as one. D17 builds gunnar to Brokkr's software criteria, and §2's original wording forbade "any C toolchain requirement". That is not achievable with russh:
russh/src/lib.rs:90is a hardcompile_error!demanding either theringor theaws-lc-rsfeature, and 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. So the rule was being violated the moment SSH was linked, which is to say always. The honest rule, and the one §2 now states, is "no unvendored C, and no system libraries". Both backends vendor their source, so the build stays hermetic and needs no distro packages, which is what the appliance case actually requires. Dropping russh would takenornir-gitwith it and mean writing an SSH implementation; not worth it. - Why it matters: Only as a documentation hazard. An agent reading §2's older wording or the Brokkr criteria will conclude the build is non-conforming and try to "fix" it by removing the crypto backend, which does not compile. Recording it closes that loop.
- Fix: Nothing to fix. If the crate count is ever worth trimming,
ringis the smaller of the two backends. That is the only action item here, and it is optional. Do not restore the "no C toolchain" wording anywhere. - Verify: n/a. If a future guard asserts "zero C crates in the graph", it will go red and the guard is what is wrong. Any such guard must assert unvendored C / system libraries instead, e.g. that the build needs no
pkg-configand no*-syscrate linking a system library. - Re-MEASURED 2026-08-03, and the conclusion holds while two of the numbers moved. Commands, both against the current tree:
cargo tree -p gunnar-ssh -e normal --prefix none | sed 's/ (\*)$//' | sort -u | wc -l # 244
cargo tree -p gunnar-ssh -e normal,build --prefix none | sed 's/ (\*)$//' | sort -u | wc -l # 257
-
244 crates on normal edges, not the 196 this entry recorded. The tree grew by 48 since the audit; that is other work landing, not a change in this finding.
-
Still exactly two C crates:
aws-lc-rs v1.17.3andaws-lc-sys v0.43.0. The third*-sysname in the tree,linux-raw-sys v0.12.1, is not C. It is generated raw syscall bindings, ships no C and links nothing, and a guard that matched on the-syssuffix would wrongly flag it. Named here so nobody writes that guard. -
russh 0.61.2'scompile_error!is still atsrc/lib.rs:91and still demandsringoraws-lc-rs, so the deviation is still forced rather than chosen. -
No
openssl, nolibz-sys, nolibgit2, novcpkganywhere in the graph, so the "no system libraries" half of the honest rule is clean. -
Correction to this entry's own Verify clause, found while checking it. It proposes a future guard asserting "the build needs no
pkg-config". That guard would go red today, and it would be wrong.cargo tree -e normal,buildputspkg-config v0.3.33ingunnar-ssh's graph, along withcmake v0.1.58,cc v1.4.0,dunceandfs_extra, all build-dependencies ofaws-lc-sys, which vendors its source and builds it, and reaches forpkg-configonly on the external-libcrypto path gunnar does not take. Being in the build graph and being used to find a system library are different things, and only the second is what §2's honest rule forbids. A guard must therefore assert on the linkage (no*-syscrate resolving a system library, noDEP_*_INCLUDEfrom outside the source tree) or simply on a hermetic build in a container with no-devpackages installed, not on the presence of a build-dep name. -
Closed: 2026-08-03, WONTFIX, which is this entry's correct terminal state and was always what it described: an accepted deviation with a reason, not a defect. There is nothing to fix and nothing was changed. Verified by the two
cargo treeruns above and by readingrussh-0.61.2/src/lib.rs:85-93in the registry; the optional action item (swapaws-lc-rsfor the smallerring) is left untaken deliberately, since trading a working crypto backend for a crate count is not worth the risk on a listener.
S-046 — Two tests are load-flaky: green alone, red under a saturated workspace run
-
Found: 2026-08-03
-
Status: IN-PROGRESS
-
Severity: correctness
-
Where:
crates/gunnar-store/tests/repack.rs::real_git_clones_a_repacked_repository,crates/gunnar-cli/tests/control_plane_starvation.rs -
What: Both pass in isolation and both have failed during a full
cargo test --workspacewhile other agents were building.real_git_clones_a_repacked_repositoryfailed once at 617 passing and then passed 7/7 alone, unchanged.control_plane_starvationfailed at load average 58 and passed 3/3 alone. -
Why it matters at load: a flaky test is worse than a failing one. It trains everyone to re-run rather than read, so the day it catches a real regression the reflex is to run it again. Both of these guard things that were real bugs, the D14 swap protocol and the runtime starvation fix, so losing confidence in them costs exactly what they were written to protect.
-
Fix: find whether it is a timeout, a shared temp path, a port, or genuine resource starvation. If a wall-clock assertion is the cause, the assertion is measuring the machine rather than the code and needs to become a relative or resource-based one. Do NOT simply widen the timeout; that hides it rather than fixing it, and a starvation test with a generous timeout cannot detect starvation.
-
Verify: run the suite under deliberate load (
stress-ngor a parallel build) and require the test to be stable across a number of runs, not one. -
control_plane_starvationfixed 2026-08-03;repack.rsNOT touched — that file belongs to another lane this session. One of two, so this entry stays IN-PROGRESS.
The cause was NOT the one everybody assumed, and that is the finding. The obvious suspect was assert!(worst <= SSH_IDENT_DEADLINE), an absolute 500 ms wall clock, which does measure the machine. It was replaced. But reproducing the flake showed the failure was somewhere else entirely: at load average 105 the test died on its vacuity check, hammered >= 2 * HAMMERS, with
the control plane answered only 18 calls in 4.3s of probing, so it was never actuallyloaded and the SSH measurement below is a measurement of an idle server
A true statement about the box, reported as a regression. The probing loop ran for a fixed second and the assertion then demanded a fixed amount of work inside it, so a saturated box failed the test by being saturated. Had only the wall-clock deadline been widened — the fix this entry explicitly forbids — the flake would have survived and looked cured.
Both are now relative or waited-for, not clocked.
- The deadline is a ratio: the same probe is taken against the same server seconds earlier with nothing loading it, and the assertion is
median <= baseline × 200. Load raises both sides and cancels; starvation raises one. The margin is measured, not guessed —S-009's bug produced 1.59–3.02 s against 86 µs–2.8 ms, ~16 000×. - The vacuity check is waited for rather than asserted against a clock: the loop exits when it has both its measurements and its served calls, giving up only at a 90 s budget that nothing is compared against. A slow box takes longer; it does not fail.
- The baseline itself is checked for usability and retried up to five times. If
baseline × 200ever reached the 1.5 s that starvation actually produces, the bound would have gone blind and any green would be meaningless — the identity-value trap in this test's own shape. It then fails saying the box could not be measured, which is a readable failure rather than a silent pass, and not a skip (S-026).
MEASURED under deliberate load, oden 2026-08-03: 64 busy-loop processes on 32 cores, load average 155–176, the condition this entry describes. 8 runs, 8 green. Probe medians 249–602 µs against baselines of the same order — ratios of 1.1×–2.4× against the 200× bound. The old assertion's quantity, worst, ranged over 4 ms – 94 ms across those runs while the ratio barely moved: that is the load sensitivity that made it the wrong thing to assert on.
SEEN RED with the final loop shape, by restoring S-009's pre-fix shared runtime (tokio::spawn in place of control_runtime.spawn):
SSH identification had a median of 3.048758582s (worst 4.529513194s) … against243.086µs on the SAME server seconds earlier … That is 12542× slower under load,past the 200× bound (48.6172ms). The control plane is starving the git listener
So the relative assertion still catches the bug it exists for, with two orders of margin.
The first version of this fix introduced a NEW flake, and it took a load run to see it. MAX_PROBES broke the waiting loop, so on a fast box the probe quota filled while the loaded control plane had served 31 of the 32 calls the measurement needs, and the test failed one call short. MAX_PROBES now caps the sample and not the wait. Recorded because a de-flaking change that adds a flake is the same disease.
-
Still TODO:
crates/gunnar-store/tests/repack.rs::real_git_clones_a_repacked_repository, which failed once at 617 passing and then passed 7/7 alone. Not diagnosed and not touched. It should get the same treatment, and the lesson above first: find what is actually failing under load before changing any number, because the assertion everybody suspects may not be the one that breaks. -
Attempted 2026-08-03 and NOT REPRODUCED. Nothing was changed, deliberately.
Following this entry's own instruction — find what fails before touching a number — the test was run under the condition it describes, on oden (32 cores):
| condition | runs | failures |
| 64 busy-loop processes, load average 31 → 51 | 30 | 0 |
96 busy-loop processes plus two concurrent full cargo test --workspace runs, load average 58 → 154 |
369 | 0 |
whole gunnar-store suite under the first condition |
3 | 0 |
399 isolated runs and 5 whole-suite runs, zero reproductions. The two workspace suites that ran alongside failed only on gunnar-wire's pack_copy pair, which fails identically at origin/master in a clean worktree and belongs to another lane.
What that rules out, which is the useful half of a negative result:
- Not a wall-clock assertion. Unlike
control_plane_starvation, this test contains none:git()asserts on a subprocess's exit status and nothing is timed. - Not tmpfs exhaustion, the obvious suspect given
tempfileputs every fixture on oden's/tmp, which is a 100 GB tmpfs, i.e. RAM. It sat at 4.4 GB used and 96 GB free throughout the loaded run. - Not CPU-load sensitivity of the test itself at the load averages this entry names.
What is therefore still open, stated rather than guessed: the single observed failure has no captured output, so what assertion fired is unknown. The condition that was NOT reproduced here is the one the original run had and this one did not — other agents' cargo invocations competing for the same target directories and the same git binary, rather than synthetic CPU load. The next agent should capture the failure text when it happens rather than run more loops; this run cost about ten minutes and bought only eliminations. This entry stays IN-PROGRESS.
- The capture landed 2026-08-04, commit
83e6d6e. No loops were run and no number was changed. Following the instruction above literally: the next occurrence will leave evidence, where the one observed failure left none.
What the report used to drop, each of which narrows what broke:
- the exit status.
128(a git usage/repository error),141(SIGPIPE) and137(SIGKILL, i.e. the OOM killer — a live hypothesis for a saturated box) are different diagnoses, and the message told them apart not at all: it printed stderr alone, and a process killed by a signal frequently has none. - stdout.
git fsckreports corruption there rather than on stderr, so the single most likely genuine failure in this test had its evidence discarded. - the working directory, a
tempfilepath and the only handle on the fixture.
Also: the loop runs twice, over Sha1 and Sha256, and said which in none of its assertions. "sha1 only" and "both" are different bugs. Every assertion now names it.
The formatting is split out of git() as git_report so it is reachable without catch_unwind — a panic-formatting path that can only be exercised by panicking is a path that cannot be seen red, and this one now is. SEEN RED against the old stderr-only report, driven by a real failing git rather than a synthesized Output:
the report must name the exit status — 128, 137 and 141 are different diagnoses:git ["rev-parse", "--verify", "HEAD"] failed: fatal: not a git repository (or anyparent up to mount point /)
The test asserts its own premise first — that git rev-parse outside a repository really does fail — so on a future git that resolved it, it says so rather than passing having exercised the success path.
This entry stays IN-PROGRESS, and deliberately so. The flake is instrumented, not diagnosed. Nothing here explains the original failure and nothing should be read as claiming it does; what changed is that the next occurrence is readable.
- Closed:
S-048 — Every authenticated HTTP request does a single-ref CAS on the identity table
- Found: 2026-08-04
- Status: TODO
- Severity: scale
- Where:
crates/gunnar-http/src/service.rs:131·crates/gunnar-core/src/auth.rs:626(touch_token) ·crates/gunnar-core/src/doc.rs:67(MAX_ATTEMPTS) - What: After authenticating a request, the HTTP service records that the token was used:
if let Some(hash) = &token {
let _ = self.auth().touch_token(hash, gunnar_core::now_ms());
}
touch_token goes through DocRef::update, which clones the whole identity document, modifies one shard, flushes the shard blobs, and then does a compare-and-swap on the single refs/gunnar/auth reference, retrying up to MAX_ATTEMPTS = 8 times. So every authenticated HTTP request performs a document clone, a blob write and a CAS on one server-wide reference, synchronously, before the request is served.
- Why it matters at load: this is the cost
S-025refuses to pay for SSH keys, being paid for HTTP tokens. At the stated target — thousands of concurrent clients — every request contends for one reference, so the identity table becomes a server-wide serialisation point on the request path, and each lost race costs up to eight re-reads and re-applies of the document before it gives up. - How it is NOT
S-025, which matters for the fix: the call islet _ =, so a lost CAS does not refuse a request that already authenticated.S-025's stated fear — a correct key refused because a bookkeeping write lost a race — does not apply here. The cost is throughput, not correctness, which is why this isscaleand that one iscorrectness. - Fix: the same two routes
S-025names, and the fix should serve both rather than being written twice (LAW 5): an append-only "principal P authenticated at T" log, where a row needs no CAS, or the low-durability sidecar that is allowed to lose updates. Off the request path either way. Until one exists, the honest interim is to make the write conditional on the timestamp having moved by more than some granularity — a token used 500 times a second does not need 500 CAS attempts to record alast_usedan operator reads to the minute. - Verify: count
RefStore::transactioncalls againstrefs/gunnar/authacross N authenticated requests and assert it is not N. Seen RED today, where it is exactly N. Asserting on latency instead would measure the machine (S-046). - NOT MEASURED. Every figure above is read off the code —
DocRef::updateatdoc.rs:733-745and the shard arithmetic atdoc.rs:71-79. Nothing here has been benchmarked, and the entry should not be quoted as if it had been (LAW 7). - Closed:
S-051 — the build stamp's rerun-if-changed names nothing in a git worktree
- Found: 2026-08-05
- Status: DONE
- Severity: correctness
- Where:
crates/gunnar-cli/build.rs:57-58(as landed in008d74b) - What: the build script stamps
git describeinto--version, and declares
cargo:rerun-if-changed=../../.git/HEAD
cargo:rerun-if-changed=../../.git/refs
In a git worktree ../../.git is a file holding gitdir: …, not a directory, so neither path exists. Cargo treats a rerun-if-changed path that does not exist as always dirty, so the trigger did not fire on the thing it watches — it fired on every build instead.
- Why it matters at load: gunnar is developed almost entirely out of worktrees; there were 60+ of them on oden the day this was found, and the main checkout is the exception rather than the rule. So the normal case was:
gunnar-cli's build script re-runs on every singlecargo build, in every lane, forever. It also makes the build script's output file get rewritten under any concurrent build sharing aCARGO_TARGET_DIR, which is the most plausible mechanism for the transientenvironment variable GUNNAR_BUILD_REF not defined at compile timethat failed onextask cidoc-test step on 2026-08-05 and did not reproduce on two re-runs. The irony is the finding: this file exists because a lane silently measured a ten-hour-old binary, and its own staleness trigger was dead in the environment every lane actually works in. - Fix: the paths come from
git rev-parse --git-path HEAD/refs, which resolves correctly in a worktree, in a plain checkout and underGIT_DIR, and each is emitted only if it exists — so the no-.gittarball case this file already handles emits no trigger at all rather than naming one that is not there. - Verify: the applied output is whether cargo re-runs the build script, watched on the mtime of
target/debug/build/gunnar-cli-*/outputacross two consecutive no-op builds. Seen RED: with the original../../.git/…paths the script re-ran on a no-op build. Seen GREEN: with--git-pathit did not. And the other direction, which is the one that matters — an empty commit movedHEAD, the script re-ran, and the stamp wentv1.0-10-g1bcca78→v1.0-11-gb437c09. A fix that merely stopped the trigger firing would have passed the first probe and failed this one. - Closed: 2026-08-05. After the fix a full
cargo xtask cion the same box went green end to end — all 11 steps, 1224 passes summed off its owntest result: ok. N passedlines,dependency guard: PASS (7 tracked, 0 violations), appliance 280 crates (267 third-party). That is a third observation against the transient and not a proof of causation: the doc-test failure was never made to happen again on demand, so "the rewritten build-script output is what rustdoc read mid-write" stays the likely mechanism. What IS proven is the dead trigger and its repair.
S-050 — every file:line in the cycle diagram was unguarded, and 8 of 24 had rotted
- Found: 2026-08-05
- Status: DONE
- Severity: correctness
- Where:
xtask/src/cycle_svg.rs(theEvidencemodel andno_evidence_is_a_placeholder) · fixed by construction inxtask/src/datapath_svg.rs(every_citation_still_points_at_what_it_names) - What: the whole value of these diagrams is that a claim carries the line it was read at, so the code can be opened from the picture. The guard that was supposed to protect that checked
line > 0andfile.contains('/'). Neither can fail on a citation that has drifted, which is the only way this datum ever goes wrong: the number stays a number, the picture keeps rendering, and it points at a closing brace. Opening all 24 in-repo citations found 8 stale, among themcrates/gunnar-wire/src/graph.rs:785forselect_objects, which now lives at:1513— 728 lines away — under the caption "closure WALK, no bitmaps", beside a repository that has hadcrates/gunnar-bitmapandcrates/gunnar-wire/src/bitmap.rsfor a day. Six further citations nameznippy-plugin-git/, a sibling checkout, and cannot be checked from this tree at all. - Why it matters at load: it is not a load problem at all, it is the silent-green pattern (LAW 2). A diagram whose addresses are wrong is worse than one with no addresses, because the wrong one is trusted. Two of the eight had survived long enough that the prose beside them described a design gunnar no longer has.
- Fix: the eight are repointed, and the new generator carries an
anchor: &'static strper citation — a token that must still be on the cited line. The guard opens the file and looks.cycle_svg's own 24 do not have anchors yet; that is the remaining work under this id's follow-up. - Verify:
every_citation_still_points_at_what_it_nameswas seen RED twice while being written — once oncrates/gunnar-pack/src/checksum.rs:103, which namedTrailerHasher's plain-SHA-1 arm rather than the collision-detecting hash the box claimed, and once on an off-by-one. Changing any line number by one fails it, naming the box and quoting what it found instead. - Closed: 2026-08-05,
ac42711/880a50b. Not closed forcycle_svg: see Fix. - Follow-up: give
cycle_svg'sEvidencethe sameanchorfield, and decide what a citation into a sibling checkout should do when the sibling is absent — skip loudly, asrun_znippy_testsdoes, rather than pass.
S-049 — sync_dir has a third and fourth copy in gunnar-coldtier, unlogged
- Found: 2026-08-04
- Status: TODO
- Severity: smell
- Where:
crates/gunnar-coldtier/src/unfold.rs:107·crates/gunnar-coldtier/src/lib.rs:496· againstcrates/gunnar-store/src/repack.rs:993(sync_dir, the one writer) - What:
S-030's last pass foundsync_direxisted twice, inrepack.rsandmidx.rs, that the two had already diverged on how they handled a failedopen, and fixed it by construction: onepub(crate) sync_dirinrepack,midxcalling it. It also gave the swallowedsync_allinside it adebug!, on the reasoning that a systemic fsync failure means every rename in the tree is unordered against a power cut and nothing else would ever mention it. There are two more copies of that body, in a crate that sweep never looked at:
if let Ok(f) = std::fs::File::open(&pack_dir) {
let _ = f.sync_all();
}
Same directory-durability primitive, same swallow, and no debug! — so the reasoning that earned repack's copy a log line has simply not reached these.
- Why it matters at load: low severity on its own; the cost is a durability weakening that nothing reports. It is filed as
smellbecause the real finding is the pattern: a twin was deduplicated between two crates while two further copies survived in a third, which is precisely the failure mode LAW 5 says to fix by construction rather than by keeping copies in step by hand. The next person to changesync_dirwill change one of four. - Fix:
gunnar-coldtiercalls the existing writer, or — if the dependency direction forbids it, as it did forgunnar-lfs'sdrop_incompleteinS-030— the reason is written at the function, as it was there, so the fourth copy is a decision and not an oversight. - Verify: a
grepforsync_allovercrates/*/srcreturning one definition and N call sites, asserted as a property rather than read by eye — the shapeS-030used to provedrop_keephad no second writer. Plus, for whichever copies remain, thedebug!seen RED by pointing them at a path that cannot be fsynced. - Not fixed here:
gunnar-coldtierwas held by the cold-tier lane during this session. Nothing in that crate was edited. - Closed: